diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 527b8acd31..7b7c889a72 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,11 +3,11 @@ name: CI on: push: branches: - - 'main' - + - "main" + pull_request: branches: - - 'main' + - "main" jobs: build: @@ -23,36 +23,37 @@ jobs: steps: - uses: actions/checkout@v2 - + - id: rust_toolchain uses: actions-rs/toolchain@v1 with: - toolchain: 'stable-2022-01-20' - + toolchain: "stable-2022-01-20" + - id: flutter uses: subosito/flutter-action@v2 with: - channel: 'stable' + channel: "stable" cache: true - flutter-version: '3.0.5' + flutter-version: "3.0.5" - name: Cache Cargo + id: cache-cargo uses: actions/cache@v2 - with: + with: path: | ~/.cargo key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - name: Cache Rust uses: actions/cache@v2 - with: + with: path: | frontend/rust-lib/target shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} + key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - name: Setup Environment - run: | + run: | if [ "$RUNNER_OS" == "Linux" ]; then sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list @@ -64,11 +65,16 @@ jobs: fi shell: bash - - name: Deps + - if: steps.cache-cargo.outputs.cache-hit != 'true' + name: Deps working-directory: frontend run: | cargo install cargo-make cargo install duckscript_cli + + - name: Cargo make flowy_dev + working-directory: frontend + run: | cargo make flowy_dev - name: Config Flutter diff --git a/.github/workflows/dart_lint.yml b/.github/workflows/dart_lint.yml index 3ff87dc680..251848fb31 100644 --- a/.github/workflows/dart_lint.yml +++ b/.github/workflows/dart_lint.yml @@ -7,14 +7,14 @@ name: Flutter lint on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] env: CARGO_TERM_COLOR: always -jobs: +jobs: flutter-analyze: name: flutter analyze runs-on: ubuntu-latest @@ -23,16 +23,38 @@ jobs: uses: actions/checkout@v2 - uses: subosito/flutter-action@v1 with: - flutter-version: '3.0.5' + flutter-version: "3.0.5" channel: "stable" - uses: actions-rs/toolchain@v1 with: - toolchain: 'stable-2022-01-20' + toolchain: "stable-2022-01-20" - - name: Rust Deps + - name: Cache Cargo + id: cache-cargo + uses: actions/cache@v2 + with: + path: | + ~/.cargo + key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} + + - name: Cache Rust + id: cache-rust-target + uses: actions/cache@v2 + with: + path: | + frontend/rust-lib/target + shared-lib/target + key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} + + - if: steps.cache-cargo.outputs.cache-hit != 'true' + name: Rust Deps working-directory: frontend run: | cargo install cargo-make + + - name: Cargo make flowy dev + working-directory: frontend + run: | cargo make flowy_dev - name: Flutter Deps @@ -53,4 +75,3 @@ jobs: - name: Run Flutter Analyzer working-directory: frontend/app_flowy run: flutter analyze - diff --git a/.github/workflows/dart_test.yml b/.github/workflows/dart_test.yml index e2a858999c..33cbeb1a3a 100644 --- a/.github/workflows/dart_test.yml +++ b/.github/workflows/dart_test.yml @@ -3,12 +3,12 @@ name: Unit test(Flutter) on: push: branches: - - 'main' - + - "main" + pull_request: branches: - - 'main' - - 'feat/flowy_editor' + - "main" + - "feat/flowy_editor" env: CARGO_TERM_COLOR: always @@ -18,42 +18,49 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - + - uses: actions-rs/toolchain@v1 with: - toolchain: 'stable-2022-01-20' - + toolchain: "stable-2022-01-20" + - uses: subosito/flutter-action@v2 with: - channel: 'stable' - flutter-version: '3.0.5' + channel: "stable" + flutter-version: "3.0.5" cache: true - name: Cache Cargo uses: actions/cache@v2 - with: + with: path: | ~/.cargo key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} - name: Cache Rust + id: cache-rust-target uses: actions/cache@v2 - with: + with: path: | frontend/rust-lib/target shared-lib/target - key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} + key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }} + + - if: steps.cache-cargo.outputs.cache-hit != 'true' + name: Rust Deps + working-directory: frontend + run: | + cargo install cargo-make + + - name: Cargo make flowy dev + working-directory: frontend + run: | + cargo make flowy_dev - name: Flutter Deps working-directory: frontend/app_flowy run: | flutter config --enable-linux-desktop - - - name: Rust Deps - working-directory: frontend - run: | - cargo install cargo-make - cargo make flowy_dev + - name: Build FlowySDK working-directory: frontend run: | @@ -65,15 +72,9 @@ jobs: flutter packages pub get flutter packages pub run easy_localization:generate -f keys -o locale_keys.g.dart -S assets/translations -s en.json flutter packages pub run build_runner build --delete-conflicting-outputs - + - name: Run bloc tests working-directory: frontend/app_flowy run: | flutter pub get flutter test - - - name: Run FlowyEditor tests - working-directory: frontend/app_flowy/packages/flowy_editor - run: | - flutter pub get - flutter test \ No newline at end of file diff --git a/.github/workflows/flowy_editor_test.yml b/.github/workflows/flowy_editor_test.yml new file mode 100644 index 0000000000..1bff599ace --- /dev/null +++ b/.github/workflows/flowy_editor_test.yml @@ -0,0 +1,36 @@ +name: FlowyEditor test + +on: + push: + branches: + - "main" + + pull_request: + branches: + - "main" + +env: + CARGO_TERM_COLOR: always + +jobs: + tests: + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: "3.0.5" + cache: true + + - name: Run FlowyEditor tests + working-directory: frontend/app_flowy/packages/appflowy_editor + run: | + flutter pub get + flutter test diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json index 036b5ab35c..e70b9ffb97 100644 --- a/frontend/.vscode/launch.json +++ b/frontend/.vscode/launch.json @@ -29,7 +29,7 @@ "program": "./lib/main.dart", "type": "dart", "env": { - "RUST_LOG": "debug" + "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/app_flowy" }, @@ -44,7 +44,7 @@ "type": "dart", "preLaunchTask": "AF: Clean + Rebuild All", "env": { - "RUST_LOG": "info" + "RUST_LOG": "trace" }, "cwd": "${workspaceRoot}/app_flowy" }, diff --git a/frontend/app_flowy/analysis_options.yaml b/frontend/app_flowy/analysis_options.yaml index 5717d49105..e345c3da76 100644 --- a/frontend/app_flowy/analysis_options.yaml +++ b/frontend/app_flowy/analysis_options.yaml @@ -14,7 +14,7 @@ analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" - - "packages/flowy_editor/**" + - "packages/appflowy_editor/**" - "packages/editor/**" # - "packages/flowy_infra_ui/**" linter: diff --git a/frontend/app_flowy/assets/images/emoji/1F42F.svg b/frontend/app_flowy/assets/images/emoji/1F42F.svg new file mode 100644 index 0000000000..a6e8e3e81f --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F42F.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F431.svg b/frontend/app_flowy/assets/images/emoji/1F431.svg new file mode 100644 index 0000000000..26aa279abc --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F431.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F435.svg b/frontend/app_flowy/assets/images/emoji/1F435.svg new file mode 100644 index 0000000000..0220a6e58e --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F435.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F43A.svg b/frontend/app_flowy/assets/images/emoji/1F43A.svg new file mode 100644 index 0000000000..3e29b3a6a9 --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F43A.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F600.svg b/frontend/app_flowy/assets/images/emoji/1F600.svg new file mode 100644 index 0000000000..e9e1d0ea88 --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F600.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F984.svg b/frontend/app_flowy/assets/images/emoji/1F984.svg new file mode 100644 index 0000000000..a5f8206cbc --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F984.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F9CC.svg b/frontend/app_flowy/assets/images/emoji/1F9CC.svg new file mode 100644 index 0000000000..eb30038228 --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F9CC.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F9DB.svg b/frontend/app_flowy/assets/images/emoji/1F9DB.svg new file mode 100644 index 0000000000..590829d25c --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F9DB.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F9DD-200D-2642-FE0F.svg b/frontend/app_flowy/assets/images/emoji/1F9DD-200D-2642-FE0F.svg new file mode 100644 index 0000000000..62e5101f53 --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F9DD-200D-2642-FE0F.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F9DE-200D-2642-FE0F.svg b/frontend/app_flowy/assets/images/emoji/1F9DE-200D-2642-FE0F.svg new file mode 100644 index 0000000000..e662de70a6 --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F9DE-200D-2642-FE0F.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/assets/images/emoji/1F9DF.svg b/frontend/app_flowy/assets/images/emoji/1F9DF.svg new file mode 100644 index 0000000000..e2ea11f33a --- /dev/null +++ b/frontend/app_flowy/assets/images/emoji/1F9DF.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app_flowy/lib/plugins/blank/blank.dart b/frontend/app_flowy/lib/plugins/blank/blank.dart index 11f779eeb0..350a30ed50 100644 --- a/frontend/app_flowy/lib/plugins/blank/blank.dart +++ b/frontend/app_flowy/lib/plugins/blank/blank.dart @@ -16,7 +16,7 @@ class BlankPluginBuilder extends PluginBuilder { String get menuName => "Blank"; @override - PluginType get pluginType => DefaultPlugin.blank.type(); + PluginType get pluginType => PluginType.blank; } class BlankPluginConfig implements PluginConfig { diff --git a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart index e69de29bb2..ac72b6e24e 100644 --- a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart +++ b/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart @@ -0,0 +1,290 @@ +import 'dart:async'; +import 'package:app_flowy/plugins/grid/application/block/block_cache.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flowy_sdk/log.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:collection'; + +import 'board_data_controller.dart'; +import 'group_controller.dart'; + +part 'board_bloc.freezed.dart'; + +class BoardBloc extends Bloc { + final BoardDataController _dataController; + late final AFBoardDataController afBoardDataController; + final MoveRowFFIService _rowService; + Map groupControllers = {}; + + GridFieldCache get fieldCache => _dataController.fieldCache; + String get gridId => _dataController.gridId; + + BoardBloc({required ViewPB view}) + : _rowService = MoveRowFFIService(gridId: view.id), + _dataController = BoardDataController(view: view), + super(BoardState.initial(view.id)) { + afBoardDataController = AFBoardDataController( + onMoveColumn: ( + fromIndex, + toIndex, + ) {}, + onMoveColumnItem: ( + columnId, + fromIndex, + toIndex, + ) { + final fromRow = groupControllers[columnId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[columnId]?.rowAtIndex(toIndex); + _moveRow(fromRow, toRow); + }, + onMoveColumnItemToColumn: ( + fromColumnId, + fromIndex, + toColumnId, + toIndex, + ) { + final fromRow = groupControllers[fromColumnId]?.rowAtIndex(fromIndex); + final toRow = groupControllers[toColumnId]?.rowAtIndex(toIndex); + _moveRow(fromRow, toRow); + }, + ); + + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + await _loadGrid(emit); + }, + createRow: (groupId) async { + final result = await _dataController.createBoardCard(groupId); + result.fold( + (rowPB) { + emit(state.copyWith(editingRow: some(rowPB))); + }, + (err) => Log.error(err), + ); + }, + endEditRow: (rowId) { + assert(state.editingRow.isSome()); + state.editingRow.fold(() => null, (row) { + assert(row.id == rowId); + emit(state.copyWith(editingRow: none())); + }); + }, + didReceiveGridUpdate: (GridPB grid) { + emit(state.copyWith(grid: Some(grid))); + }, + didReceiveRows: (List rowInfos) { + emit(state.copyWith(rowInfos: rowInfos)); + }, + didReceiveError: (FlowyError error) { + emit(state.copyWith(noneOrError: some(error))); + }, + ); + }, + ); + } + + void _moveRow(RowPB? fromRow, RowPB? toRow) { + if (fromRow != null && toRow != null) { + _rowService + .moveRow( + fromRowId: fromRow.id, + toRowId: toRow.id, + ) + .then((result) { + result.fold((l) => null, (r) => add(BoardEvent.didReceiveError(r))); + }); + } + } + + @override + Future close() async { + await _dataController.dispose(); + for (final controller in groupControllers.values) { + controller.dispose(); + } + return super.close(); + } + + void initializeGroups(List groups) { + for (final group in groups) { + final delegate = GroupControllerDelegateImpl(afBoardDataController); + final controller = GroupController( + gridId: state.gridId, + group: group, + delegate: delegate, + ); + controller.startListening(); + groupControllers[controller.group.groupId] = (controller); + } + } + + GridRowCache? getRowCache(String blockId) { + final GridBlockCache? blockCache = _dataController.blocks[blockId]; + return blockCache?.rowCache; + } + + void _startListening() { + _dataController.addListener( + onGridChanged: (grid) { + if (!isClosed) { + add(BoardEvent.didReceiveGridUpdate(grid)); + } + }, + didLoadGroups: (groups) { + List columns = groups.map((group) { + return AFBoardColumnData( + id: group.groupId, + desc: group.desc, + items: _buildRows(group.rows), + customData: group, + ); + }).toList(); + + afBoardDataController.addColumns(columns); + initializeGroups(groups); + }, + onRowsChanged: (List rowInfos, RowsChangedReason reason) { + add(BoardEvent.didReceiveRows(rowInfos)); + }, + onError: (err) { + Log.error(err); + }, + ); + } + + List _buildRows(List rows) { + final items = rows.map((row) { + return BoardColumnItem(row: row); + }).toList(); + + return [...items]; + } + + Future _loadGrid(Emitter emit) async { + final result = await _dataController.loadData(); + result.fold( + (grid) => emit( + state.copyWith(loadingState: GridLoadingState.finish(left(unit))), + ), + (err) => emit( + state.copyWith(loadingState: GridLoadingState.finish(right(err))), + ), + ); + } +} + +@freezed +class BoardEvent with _$BoardEvent { + const factory BoardEvent.initial() = InitialGrid; + const factory BoardEvent.createRow(String groupId) = _CreateRow; + const factory BoardEvent.endEditRow(String rowId) = _EndEditRow; + const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError; + const factory BoardEvent.didReceiveRows(List rowInfos) = + _DidReceiveRows; + const factory BoardEvent.didReceiveGridUpdate( + GridPB grid, + ) = _DidReceiveGridUpdate; +} + +@freezed +class BoardState with _$BoardState { + const factory BoardState({ + required String gridId, + required Option grid, + required Option editingRow, + required List rowInfos, + required GridLoadingState loadingState, + required Option noneOrError, + }) = _BoardState; + + factory BoardState.initial(String gridId) => BoardState( + rowInfos: [], + grid: none(), + gridId: gridId, + editingRow: none(), + noneOrError: none(), + loadingState: const _Loading(), + ); +} + +@freezed +class GridLoadingState with _$GridLoadingState { + const factory GridLoadingState.loading() = _Loading; + const factory GridLoadingState.finish( + Either successOrFail) = _Finish; +} + +class GridFieldEquatable extends Equatable { + final UnmodifiableListView _fields; + const GridFieldEquatable( + UnmodifiableListView fields, + ) : _fields = fields; + + @override + List get props { + if (_fields.isEmpty) { + return []; + } + + return [ + _fields.length, + _fields + .map((field) => field.width) + .reduce((value, element) => value + element), + ]; + } + + UnmodifiableListView get value => UnmodifiableListView(_fields); +} + +class BoardColumnItem extends AFColumnItem { + final RowPB row; + + BoardColumnItem({required this.row}); + + @override + String get id => row.id; +} + +class CreateCardItem extends AFColumnItem { + @override + String get id => '$CreateCardItem'; +} + +class GroupControllerDelegateImpl extends GroupControllerDelegate { + final AFBoardDataController controller; + + GroupControllerDelegateImpl(this.controller); + + @override + void insertRow(String groupId, RowPB row, int? index) { + final item = BoardColumnItem(row: row); + if (index != null) { + controller.insertColumnItem(groupId, index, item); + } else { + controller.addColumnItem(groupId, item); + } + } + + @override + void removeRow(String groupId, String rowId) { + controller.removeColumnItem(groupId, rowId); + } + + @override + void updateRow(String groupId, RowPB row) { + // + } +} diff --git a/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart b/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart new file mode 100644 index 0000000000..1d17431713 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart @@ -0,0 +1,142 @@ +import 'dart:collection'; + +import 'package:app_flowy/plugins/grid/application/block/block_cache.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/application/grid_service.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; +import 'dart:async'; +import 'package:dartz/dartz.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; + +typedef OnFieldsChanged = void Function(UnmodifiableListView); +typedef OnGridChanged = void Function(GridPB); +typedef DidLoadGroups = void Function(List); +typedef OnRowsChanged = void Function( + List, + RowsChangedReason, +); +typedef OnError = void Function(FlowyError); + +class BoardDataController { + final String gridId; + final GridFFIService _gridFFIService; + final GridFieldCache fieldCache; + + // key: the block id + final LinkedHashMap _blocks; + LinkedHashMap get blocks => _blocks; + + OnFieldsChanged? _onFieldsChanged; + OnGridChanged? _onGridChanged; + DidLoadGroups? _didLoadGroup; + OnRowsChanged? _onRowsChanged; + OnError? _onError; + + List get rowInfos { + final List rows = []; + for (var block in _blocks.values) { + rows.addAll(block.rows); + } + return rows; + } + + BoardDataController({required ViewPB view}) + : gridId = view.id, + _blocks = LinkedHashMap.new(), + _gridFFIService = GridFFIService(gridId: view.id), + fieldCache = GridFieldCache(gridId: view.id); + + void addListener({ + OnGridChanged? onGridChanged, + OnFieldsChanged? onFieldsChanged, + DidLoadGroups? didLoadGroups, + OnRowsChanged? onRowsChanged, + OnError? onError, + }) { + _onGridChanged = onGridChanged; + _onFieldsChanged = onFieldsChanged; + _didLoadGroup = didLoadGroups; + _onRowsChanged = onRowsChanged; + _onError = onError; + + fieldCache.addListener(onFields: (fields) { + _onFieldsChanged?.call(UnmodifiableListView(fields)); + }); + } + + Future> loadData() async { + final result = await _gridFFIService.loadGrid(); + return Future( + () => result.fold( + (grid) async { + _onGridChanged?.call(grid); + + return await _loadFields(grid).then((result) { + return result.fold( + (l) { + _loadGroups(grid.blocks); + return left(l); + }, + (err) => right(err), + ); + }); + }, + (err) => right(err), + ), + ); + } + + Future> createBoardCard(String groupId) { + return _gridFFIService.createBoardCard(groupId); + } + + Future dispose() async { + await _gridFFIService.closeGrid(); + await fieldCache.dispose(); + + for (final blockCache in _blocks.values) { + blockCache.dispose(); + } + } + + Future> _loadFields(GridPB grid) async { + final result = await _gridFFIService.getFields(fieldIds: grid.fields); + return Future( + () => result.fold( + (fields) { + fieldCache.fields = fields.items; + _onFieldsChanged?.call(UnmodifiableListView(fieldCache.fields)); + return left(unit); + }, + (err) => right(err), + ), + ); + } + + Future _loadGroups(List blocks) async { + for (final block in blocks) { + final cache = GridBlockCache( + gridId: gridId, + block: block, + fieldCache: fieldCache, + ); + + cache.addListener(onRowsChanged: (reason) { + _onRowsChanged?.call(rowInfos, reason); + }); + _blocks[block.id] = cache; + } + + final result = await _gridFFIService.loadGroups(); + return Future( + () => result.fold( + (groups) { + _didLoadGroup?.call(groups.items); + }, + (err) => _onError?.call(err), + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart new file mode 100644 index 0000000000..3834db112c --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/board_checkbox_cell_bloc.dart @@ -0,0 +1,71 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; + +part 'board_checkbox_cell_bloc.freezed.dart'; + +class BoardCheckboxCellBloc + extends Bloc { + final GridCheckboxCellController cellController; + void Function()? _onCellChangedFn; + BoardCheckboxCellBloc({ + required this.cellController, + }) : super(BoardCheckboxCellState.initial(cellController)) { + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + }, + didReceiveCellUpdate: (cellData) { + emit(state.copyWith(isSelected: _isSelected(cellData))); + }, + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + cellController.dispose(); + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.startListening( + onCellChanged: ((cellContent) { + if (!isClosed) { + add(BoardCheckboxCellEvent.didReceiveCellUpdate(cellContent ?? "")); + } + }), + ); + } +} + +@freezed +class BoardCheckboxCellEvent with _$BoardCheckboxCellEvent { + const factory BoardCheckboxCellEvent.initial() = _InitialCell; + const factory BoardCheckboxCellEvent.didReceiveCellUpdate( + String cellContent) = _DidReceiveCellUpdate; +} + +@freezed +class BoardCheckboxCellState with _$BoardCheckboxCellState { + const factory BoardCheckboxCellState({ + required bool isSelected, + }) = _CheckboxCellState; + + factory BoardCheckboxCellState.initial(GridCellController context) { + return BoardCheckboxCellState( + isSelected: _isSelected(context.getCellData())); + } +} + +bool _isSelected(String? cellData) { + return cellData == "Yes"; +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart new file mode 100644 index 0000000000..76267ededb --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart @@ -0,0 +1,85 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; +part 'board_date_cell_bloc.freezed.dart'; + +class BoardDateCellBloc extends Bloc { + final GridDateCellController cellController; + void Function()? _onCellChangedFn; + + BoardDateCellBloc({required this.cellController}) + : super(BoardDateCellState.initial(cellController)) { + on( + (event, emit) async { + event.when( + initial: () => _startListening(), + didReceiveCellUpdate: (DateCellDataPB? cellData) { + emit(state.copyWith( + data: cellData, dateStr: _dateStrFromCellData(cellData))); + }, + didReceiveFieldUpdate: (FieldPB value) => + emit(state.copyWith(field: value)), + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + cellController.dispose(); + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.startListening( + onCellChanged: ((data) { + if (!isClosed) { + add(BoardDateCellEvent.didReceiveCellUpdate(data)); + } + }), + ); + } +} + +@freezed +class BoardDateCellEvent with _$BoardDateCellEvent { + const factory BoardDateCellEvent.initial() = _InitialCell; + const factory BoardDateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = + _DidReceiveCellUpdate; + const factory BoardDateCellEvent.didReceiveFieldUpdate(FieldPB field) = + _DidReceiveFieldUpdate; +} + +@freezed +class BoardDateCellState with _$BoardDateCellState { + const factory BoardDateCellState({ + required DateCellDataPB? data, + required String dateStr, + required FieldPB field, + }) = _BoardDateCellState; + + factory BoardDateCellState.initial(GridDateCellController context) { + final cellData = context.getCellData(); + + return BoardDateCellState( + field: context.field, + data: cellData, + dateStr: _dateStrFromCellData(cellData), + ); + } +} + +String _dateStrFromCellData(DateCellDataPB? cellData) { + String dateStr = ""; + if (cellData != null) { + dateStr = cellData.date + " " + cellData.time; + } + return dateStr; +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart new file mode 100644 index 0000000000..2cc4882357 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/board_number_cell_bloc.dart @@ -0,0 +1,67 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; + +part 'board_number_cell_bloc.freezed.dart'; + +class BoardNumberCellBloc + extends Bloc { + final GridNumberCellController cellController; + void Function()? _onCellChangedFn; + BoardNumberCellBloc({ + required this.cellController, + }) : super(BoardNumberCellState.initial(cellController)) { + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + }, + didReceiveCellUpdate: (content) { + emit(state.copyWith(content: content)); + }, + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + cellController.dispose(); + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.startListening( + onCellChanged: ((cellContent) { + if (!isClosed) { + add(BoardNumberCellEvent.didReceiveCellUpdate(cellContent ?? "")); + } + }), + ); + } +} + +@freezed +class BoardNumberCellEvent with _$BoardNumberCellEvent { + const factory BoardNumberCellEvent.initial() = _InitialCell; + const factory BoardNumberCellEvent.didReceiveCellUpdate(String cellContent) = + _DidReceiveCellUpdate; +} + +@freezed +class BoardNumberCellState with _$BoardNumberCellState { + const factory BoardNumberCellState({ + required String content, + }) = _BoardNumberCellState; + + factory BoardNumberCellState.initial(GridCellController context) => + BoardNumberCellState( + content: context.getCellData() ?? "", + ); +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart new file mode 100644 index 0000000000..df36033cfa --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/board_select_option_cell_bloc.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'package:flowy_sdk/protobuf/flowy-grid/select_option.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; + +part 'board_select_option_cell_bloc.freezed.dart'; + +class BoardSelectOptionCellBloc + extends Bloc { + final GridSelectOptionCellController cellController; + void Function()? _onCellChangedFn; + + BoardSelectOptionCellBloc({ + required this.cellController, + }) : super(BoardSelectOptionCellState.initial(cellController)) { + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + }, + didReceiveOptions: (List selectedOptions) { + emit(state.copyWith(selectedOptions: selectedOptions)); + }, + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + cellController.dispose(); + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.startListening( + onCellChanged: ((selectOptionContext) { + if (!isClosed) { + add(BoardSelectOptionCellEvent.didReceiveOptions( + selectOptionContext?.selectOptions ?? [], + )); + } + }), + ); + } +} + +@freezed +class BoardSelectOptionCellEvent with _$BoardSelectOptionCellEvent { + const factory BoardSelectOptionCellEvent.initial() = _InitialCell; + const factory BoardSelectOptionCellEvent.didReceiveOptions( + List selectedOptions, + ) = _DidReceiveOptions; +} + +@freezed +class BoardSelectOptionCellState with _$BoardSelectOptionCellState { + const factory BoardSelectOptionCellState({ + required List selectedOptions, + }) = _BoardSelectOptionCellState; + + factory BoardSelectOptionCellState.initial( + GridSelectOptionCellController context) { + final data = context.getCellData(); + + return BoardSelectOptionCellState( + selectedOptions: data?.selectOptions ?? [], + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_text_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_text_cell_bloc.dart new file mode 100644 index 0000000000..e11d7b5ac6 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/board_text_cell_bloc.dart @@ -0,0 +1,66 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; + +part 'board_text_cell_bloc.freezed.dart'; + +class BoardTextCellBloc extends Bloc { + final GridCellController cellController; + void Function()? _onCellChangedFn; + BoardTextCellBloc({ + required this.cellController, + }) : super(BoardTextCellState.initial(cellController)) { + on( + (event, emit) async { + await event.when( + initial: () async { + _startListening(); + }, + didReceiveCellUpdate: (content) { + emit(state.copyWith(content: content)); + }, + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + cellController.dispose(); + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.startListening( + onCellChanged: ((cellContent) { + if (!isClosed) { + add(BoardTextCellEvent.didReceiveCellUpdate(cellContent ?? "")); + } + }), + ); + } +} + +@freezed +class BoardTextCellEvent with _$BoardTextCellEvent { + const factory BoardTextCellEvent.initial() = _InitialCell; + const factory BoardTextCellEvent.didReceiveCellUpdate(String cellContent) = + _DidReceiveCellUpdate; +} + +@freezed +class BoardTextCellState with _$BoardTextCellState { + const factory BoardTextCellState({ + required String content, + }) = _BoardTextCellState; + + factory BoardTextCellState.initial(GridCellController context) => + BoardTextCellState( + content: context.getCellData() ?? "", + ); +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart new file mode 100644 index 0000000000..045a1633fa --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/board_url_cell_bloc.dart @@ -0,0 +1,78 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; + +part 'board_url_cell_bloc.freezed.dart'; + +class BoardURLCellBloc extends Bloc { + final GridURLCellController cellController; + void Function()? _onCellChangedFn; + BoardURLCellBloc({ + required this.cellController, + }) : super(BoardURLCellState.initial(cellController)) { + on( + (event, emit) async { + event.when( + initial: () { + _startListening(); + }, + didReceiveCellUpdate: (cellData) { + emit(state.copyWith( + content: cellData?.content ?? "", + url: cellData?.url ?? "", + )); + }, + updateURL: (String url) { + cellController.saveCellData(url, deduplicate: true); + }, + ); + }, + ); + } + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener(_onCellChangedFn!); + _onCellChangedFn = null; + } + cellController.dispose(); + return super.close(); + } + + void _startListening() { + _onCellChangedFn = cellController.startListening( + onCellChanged: ((cellData) { + if (!isClosed) { + add(BoardURLCellEvent.didReceiveCellUpdate(cellData)); + } + }), + ); + } +} + +@freezed +class BoardURLCellEvent with _$BoardURLCellEvent { + const factory BoardURLCellEvent.initial() = _InitialCell; + const factory BoardURLCellEvent.updateURL(String url) = _UpdateURL; + const factory BoardURLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) = + _DidReceiveCellUpdate; +} + +@freezed +class BoardURLCellState with _$BoardURLCellState { + const factory BoardURLCellState({ + required String content, + required String url, + }) = _BoardURLCellState; + + factory BoardURLCellState.initial(GridURLCellController context) { + final cellData = context.getCellData(); + return BoardURLCellState( + content: cellData?.content ?? "", + url: cellData?.url ?? "", + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart new file mode 100644 index 0000000000..ab6aeacfcc --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart @@ -0,0 +1,116 @@ +import 'dart:collection'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; + +import 'card_data_controller.dart'; + +part 'card_bloc.freezed.dart'; + +class BoardCardBloc extends Bloc { + final RowFFIService _rowService; + final CardDataController _dataController; + + BoardCardBloc({ + required String gridId, + required CardDataController dataController, + }) : _rowService = RowFFIService( + gridId: gridId, + blockId: dataController.rowPB.blockId, + ), + _dataController = dataController, + super(BoardCardState.initial( + dataController.rowPB, dataController.loadData())) { + on( + (event, emit) async { + await event.map( + initial: (_InitialRow value) async { + await _startListening(); + }, + didReceiveCells: (_DidReceiveCells value) async { + final cells = value.gridCellMap.values + .map((e) => GridCellEquatable(e.field)) + .toList(); + emit(state.copyWith( + gridCellMap: value.gridCellMap, + cells: UnmodifiableListView(cells), + changeReason: value.reason, + )); + }, + ); + }, + ); + } + + @override + Future close() async { + _dataController.dispose(); + return super.close(); + } + + RowInfo rowInfo() { + return RowInfo( + gridId: _rowService.gridId, + fields: UnmodifiableListView( + state.cells.map((cell) => cell._field).toList(), + ), + rowPB: state.rowPB, + ); + } + + Future _startListening() async { + _dataController.addListener( + onRowChanged: (cells, reason) { + if (!isClosed) { + add(BoardCardEvent.didReceiveCells(cells, reason)); + } + }, + ); + } +} + +@freezed +class BoardCardEvent with _$BoardCardEvent { + const factory BoardCardEvent.initial() = _InitialRow; + const factory BoardCardEvent.didReceiveCells( + GridCellMap gridCellMap, RowsChangedReason reason) = _DidReceiveCells; +} + +@freezed +class BoardCardState with _$BoardCardState { + const factory BoardCardState({ + required RowPB rowPB, + required GridCellMap gridCellMap, + required UnmodifiableListView cells, + RowsChangedReason? changeReason, + }) = _BoardCardState; + + factory BoardCardState.initial(RowPB rowPB, GridCellMap cellDataMap) => + BoardCardState( + rowPB: rowPB, + gridCellMap: cellDataMap, + cells: UnmodifiableListView( + cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList(), + ), + ); +} + +class GridCellEquatable extends Equatable { + final FieldPB _field; + + const GridCellEquatable(FieldPB field) : _field = field; + + @override + List get props => [ + _field.id, + _field.fieldType, + _field.visibility, + _field.width, + ]; +} diff --git a/frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart b/frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart new file mode 100644 index 0000000000..f362fdf0e6 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/card/card_data_controller.dart @@ -0,0 +1,49 @@ +import 'package:app_flowy/plugins/board/presentation/card/card_cell_builder.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_field_notifier.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; +import 'package:flutter/foundation.dart'; + +typedef OnCardChanged = void Function(GridCellMap, RowsChangedReason); + +class CardDataController extends BoardCellBuilderDelegate { + final RowPB rowPB; + final GridFieldCache _fieldCache; + final GridRowCache _rowCache; + final List _onCardChangedListeners = []; + + CardDataController({ + required this.rowPB, + required GridFieldCache fieldCache, + required GridRowCache rowCache, + }) : _fieldCache = fieldCache, + _rowCache = rowCache; + + GridCellMap loadData() { + return _rowCache.loadGridCells(rowPB.id); + } + + void addListener({OnCardChanged? onRowChanged}) { + _onCardChangedListeners.add(_rowCache.addListener( + rowId: rowPB.id, + onCellUpdated: onRowChanged, + )); + } + + void dispose() { + for (final fn in _onCardChangedListeners) { + _rowCache.removeRowListener(fn); + } + } + + @override + GridCellFieldNotifier buildFieldNotifier() { + return GridCellFieldNotifier( + notifier: GridCellFieldNotifierImpl(_fieldCache)); + } + + @override + GridCellCache get cellCache => _rowCache.cellCache; +} diff --git a/frontend/app_flowy/lib/plugins/board/application/group.dart b/frontend/app_flowy/lib/plugins/board/application/group.dart new file mode 100644 index 0000000000..1e59350826 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/group.dart @@ -0,0 +1,20 @@ +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; + +class BoardGroupService { + final String gridId; + FieldPB? groupField; + + BoardGroupService(this.gridId); + + void setGroupField(FieldPB field) { + groupField = field; + } +} + +abstract class CanBeGroupField { + String get groupContent; +} + +// class SingleSelectGroup extends CanBeGroupField { +// final SingleSelectTypeOptionContext typeOptionContext; +// } diff --git a/frontend/app_flowy/lib/plugins/board/application/group_controller.dart b/frontend/app_flowy/lib/plugins/board/application/group_controller.dart new file mode 100644 index 0000000000..3f545dae3b --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/group_controller.dart @@ -0,0 +1,63 @@ +import 'package:flowy_sdk/log.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; + +import 'group_listener.dart'; + +typedef OnGroupError = void Function(FlowyError); + +abstract class GroupControllerDelegate { + void removeRow(String groupId, String rowId); + void insertRow(String groupId, RowPB row, int? index); + void updateRow(String groupId, RowPB row); +} + +class GroupController { + final GroupPB group; + final GroupListener _listener; + final GroupControllerDelegate delegate; + + GroupController({ + required String gridId, + required this.group, + required this.delegate, + }) : _listener = GroupListener(group); + + RowPB? rowAtIndex(int index) { + if (index < group.rows.length) { + return group.rows[index]; + } else { + return null; + } + } + + void startListening() { + _listener.start(onGroupChanged: (result) { + result.fold( + (GroupRowsChangesetPB changeset) { + for (final insertedRow in changeset.insertedRows) { + final index = insertedRow.hasIndex() ? insertedRow.index : null; + delegate.insertRow( + group.groupId, + insertedRow.row, + index, + ); + } + + for (final deletedRow in changeset.deletedRows) { + delegate.removeRow(group.groupId, deletedRow); + } + + for (final updatedRow in changeset.updatedRows) { + delegate.updateRow(group.groupId, updatedRow); + } + }, + (err) => Log.error(err), + ); + }); + } + + Future dispose() async { + _listener.stop(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/application/group_listener.dart b/frontend/app_flowy/lib/plugins/board/application/group_listener.dart new file mode 100644 index 0000000000..797177deca --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/group_listener.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; + +import 'package:app_flowy/core/grid_notification.dart'; +import 'package:flowy_infra/notifier.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/dart_notification.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart'; + +typedef UpdateGroupNotifiedValue = Either; + +class GroupListener { + final GroupPB group; + PublishNotifier? _groupNotifier = PublishNotifier(); + GridNotificationListener? _listener; + GroupListener(this.group); + + void start({ + required void Function(UpdateGroupNotifiedValue) onGroupChanged, + }) { + _groupNotifier?.addPublishListener(onGroupChanged); + _listener = GridNotificationListener( + objectId: group.groupId, + handler: _handler, + ); + } + + void _handler( + GridNotification ty, + Either result, + ) { + switch (ty) { + case GridNotification.DidUpdateGroup: + result.fold( + (payload) => _groupNotifier?.value = + left(GroupRowsChangesetPB.fromBuffer(payload)), + (error) => _groupNotifier?.value = right(error), + ); + break; + default: + break; + } + } + + Future stop() async { + await _listener?.stop(); + _groupNotifier?.dispose(); + _groupNotifier = null; + } +} diff --git a/frontend/app_flowy/lib/plugins/board/board.dart b/frontend/app_flowy/lib/plugins/board/board.dart index 11a6d415eb..c55d7f2e17 100644 --- a/frontend/app_flowy/lib/plugins/board/board.dart +++ b/frontend/app_flowy/lib/plugins/board/board.dart @@ -20,15 +20,18 @@ class BoardPluginBuilder implements PluginBuilder { String get menuName => "Board"; @override - PluginType get pluginType => DefaultPlugin.board.type(); + PluginType get pluginType => PluginType.board; @override - ViewDataType get dataType => ViewDataType.Grid; + ViewDataTypePB get dataType => ViewDataTypePB.Database; + + @override + ViewLayoutTypePB? get subDataType => ViewLayoutTypePB.Board; } class BoardPluginConfig implements PluginConfig { @override - bool get creatable => true; + bool get creatable => false; } class BoardPlugin extends Plugin { 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 612e7c6770..e7202e0a6d 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart @@ -1,17 +1,174 @@ // ignore_for_file: unused_field +import 'dart:collection'; + +import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_detail.dart'; +import 'package:appflowy_board/appflowy_board.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../grid/application/row/row_cache.dart'; +import '../application/board_bloc.dart'; +import 'card/card.dart'; +import 'card/card_cell_builder.dart'; class BoardPage extends StatelessWidget { - final ViewPB _view; - - const BoardPage({required ViewPB view, Key? key}) - : _view = view, - super(key: key); + final ViewPB view; + BoardPage({required this.view, Key? key}) : super(key: ValueKey(view.id)); @override Widget build(BuildContext context) { - return Container(); + return BlocProvider( + create: (context) => + BoardBloc(view: view)..add(const BoardEvent.initial()), + child: BlocBuilder( + builder: (context, state) { + return state.loadingState.map( + loading: (_) => + const Center(child: CircularProgressIndicator.adaptive()), + finish: (result) { + return result.successOrFail.fold( + (_) => BoardContent(), + (err) => FlowyErrorPage(err.toString()), + ); + }, + ); + }, + ), + ); + } +} + +class BoardContent extends StatelessWidget { + final config = AFBoardConfig( + columnBackgroundColor: HexColor.fromHex('#F7F8FC'), + ); + + BoardContent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Container( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + child: AFBoard( + // key: UniqueKey(), + scrollController: ScrollController(), + dataController: context.read().afBoardDataController, + headerBuilder: _buildHeader, + footBuilder: _buildFooter, + cardBuilder: (_, data) => _buildCard(context, data), + columnConstraints: const BoxConstraints.tightFor(width: 240), + config: AFBoardConfig( + columnBackgroundColor: HexColor.fromHex('#F7F8FC'), + ), + ), + ), + ); + }, + ); + } + + Widget _buildHeader(BuildContext context, AFBoardColumnData columnData) { + return AppFlowyColumnHeader( + icon: const Icon(Icons.lightbulb_circle), + title: Text(columnData.desc), + addIcon: const Icon(Icons.add, size: 20), + moreIcon: const Icon(Icons.more_horiz, size: 20), + height: 50, + margin: config.columnItemPadding, + ); + } + + Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) { + return AppFlowyColumnFooter( + icon: const Icon(Icons.add, size: 20), + title: const Text('New'), + height: 50, + margin: config.columnItemPadding, + onAddButtonClick: () { + context.read().add(BoardEvent.createRow(columnData.id)); + }); + } + + Widget _buildCard(BuildContext context, AFColumnItem item) { + final rowPB = (item as BoardColumnItem).row; + final rowCache = context.read().getRowCache(rowPB.blockId); + + /// Return placeholder widget if the rowCache is null. + if (rowCache == null) return SizedBox(key: ObjectKey(item)); + + final fieldCache = context.read().fieldCache; + final gridId = context.read().gridId; + final cardController = CardDataController( + fieldCache: fieldCache, + rowCache: rowCache, + rowPB: rowPB, + ); + + final cellBuilder = BoardCellBuilder(cardController); + final isEditing = context.read().state.editingRow.fold( + () => false, + (editingRow) => editingRow.id == rowPB.id, + ); + + return AppFlowyColumnItemCard( + key: ObjectKey(item), + child: BoardCard( + gridId: gridId, + isEditing: isEditing, + cellBuilder: cellBuilder, + dataController: cardController, + onEditEditing: (rowId) { + context.read().add(BoardEvent.endEditRow(rowId)); + }, + openCard: (context) => _openCard( + gridId, + fieldCache, + rowPB, + rowCache, + context, + ), + ), + ); + } + + void _openCard(String gridId, GridFieldCache fieldCache, RowPB rowPB, + GridRowCache rowCache, BuildContext context) { + final rowInfo = RowInfo( + gridId: gridId, + fields: UnmodifiableListView(fieldCache.fields), + rowPB: rowPB, + ); + + final dataController = GridRowDataController( + rowInfo: rowInfo, + fieldCache: fieldCache, + rowCache: rowCache, + ); + + RowDetailPage( + cellBuilder: GridCellBuilder(delegate: dataController), + dataController: dataController, + ).show(context); + } +} + +extension HexColor on Color { + static Color fromHex(String hexString) { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); } } diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart new file mode 100644 index 0000000000..c816964d3c --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_checkbox_cell.dart @@ -0,0 +1,59 @@ +import 'package:app_flowy/plugins/board/application/card/board_checkbox_cell_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardCheckboxCell extends StatefulWidget { + final GridCellControllerBuilder cellControllerBuilder; + + const BoardCheckboxCell({ + required this.cellControllerBuilder, + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardCheckboxCellState(); +} + +class _BoardCheckboxCellState extends State { + late BoardCheckboxCellBloc _cellBloc; + + @override + void initState() { + final cellController = + widget.cellControllerBuilder.build() as GridCheckboxCellController; + _cellBloc = BoardCheckboxCellBloc(cellController: cellController); + _cellBloc.add(const BoardCheckboxCellEvent.initial()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cellBloc, + child: BlocBuilder( + builder: (context, state) { + final icon = state.isSelected + ? svgWidget('editor/editor_check') + : svgWidget('editor/editor_uncheck'); + return Align( + alignment: Alignment.centerLeft, + child: FlowyIconButton( + iconPadding: EdgeInsets.zero, + icon: icon, + width: 20, + ), + ); + }, + ), + ); + } + + @override + Future dispose() async { + _cellBloc.close(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart new file mode 100644 index 0000000000..4a52d82116 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_date_cell.dart @@ -0,0 +1,59 @@ +import 'package:app_flowy/plugins/board/application/card/board_date_cell_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardDateCell extends StatefulWidget { + final GridCellControllerBuilder cellControllerBuilder; + + const BoardDateCell({ + required this.cellControllerBuilder, + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardDateCellState(); +} + +class _BoardDateCellState extends State { + late BoardDateCellBloc _cellBloc; + + @override + void initState() { + final cellController = + widget.cellControllerBuilder.build() as GridDateCellController; + + _cellBloc = BoardDateCellBloc(cellController: cellController) + ..add(const BoardDateCellEvent.initial()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cellBloc, + child: BlocBuilder( + builder: (context, state) { + if (state.dateStr.isEmpty) { + return const SizedBox(); + } else { + return Align( + alignment: Alignment.centerLeft, + child: FlowyText.regular( + state.dateStr, + fontSize: 14, + ), + ); + } + }, + ), + ); + } + + @override + Future dispose() async { + _cellBloc.close(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart new file mode 100644 index 0000000000..096592583e --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_number_cell.dart @@ -0,0 +1,59 @@ +import 'package:app_flowy/plugins/board/application/card/board_number_cell_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardNumberCell extends StatefulWidget { + final GridCellControllerBuilder cellControllerBuilder; + + const BoardNumberCell({ + required this.cellControllerBuilder, + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardNumberCellState(); +} + +class _BoardNumberCellState extends State { + late BoardNumberCellBloc _cellBloc; + + @override + void initState() { + final cellController = + widget.cellControllerBuilder.build() as GridNumberCellController; + + _cellBloc = BoardNumberCellBloc(cellController: cellController) + ..add(const BoardNumberCellEvent.initial()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cellBloc, + child: BlocBuilder( + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox(); + } else { + return Align( + alignment: Alignment.centerLeft, + child: FlowyText.regular( + state.content, + fontSize: 14, + ), + ); + } + }, + ), + ); + } + + @override + Future dispose() async { + _cellBloc.close(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart new file mode 100644 index 0000000000..373bb3c850 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_select_option_cell.dart @@ -0,0 +1,63 @@ +import 'package:app_flowy/plugins/board/application/card/board_select_option_cell_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardSelectOptionCell extends StatefulWidget { + final GridCellControllerBuilder cellControllerBuilder; + + const BoardSelectOptionCell({ + required this.cellControllerBuilder, + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardSelectOptionCellState(); +} + +class _BoardSelectOptionCellState extends State { + late BoardSelectOptionCellBloc _cellBloc; + + @override + void initState() { + final cellController = + widget.cellControllerBuilder.build() as GridSelectOptionCellController; + _cellBloc = BoardSelectOptionCellBloc(cellController: cellController) + ..add(const BoardSelectOptionCellEvent.initial()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cellBloc, + child: BlocBuilder( + builder: (context, state) { + final children = state.selectedOptions + .map((option) => SelectOptionTag.fromOption( + context: context, + option: option, + )) + .toList(); + return Align( + alignment: Alignment.centerLeft, + child: AbsorbPointer( + child: Wrap( + children: children, + spacing: 4, + runSpacing: 2, + ), + ), + ); + }, + ), + ); + } + + @override + Future dispose() async { + _cellBloc.close(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart new file mode 100644 index 0000000000..2da156ded8 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_text_cell.dart @@ -0,0 +1,61 @@ +import 'package:app_flowy/plugins/board/application/card/board_text_cell_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardTextCell extends StatefulWidget { + final GridCellControllerBuilder cellControllerBuilder; + const BoardTextCell({required this.cellControllerBuilder, Key? key}) + : super(key: key); + + @override + State createState() => _BoardTextCellState(); +} + +class _BoardTextCellState extends State { + late BoardTextCellBloc _cellBloc; + + @override + void initState() { + final cellController = + widget.cellControllerBuilder.build() as GridCellController; + + _cellBloc = BoardTextCellBloc(cellController: cellController) + ..add(const BoardTextCellEvent.initial()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cellBloc, + child: BlocBuilder( + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox(); + } else { + return Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints.loose( + const Size(double.infinity, 100), + ), + child: FlowyText.regular( + state.content, + fontSize: 14, + ), + ), + ); + } + }, + ), + ); + } + + @override + Future dispose() async { + _cellBloc.close(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart new file mode 100644 index 0000000000..31cca41e6a --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_url_cell.dart @@ -0,0 +1,66 @@ +import 'package:app_flowy/plugins/board/application/card/board_url_cell_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BoardUrlCell extends StatefulWidget { + final GridCellControllerBuilder cellControllerBuilder; + + const BoardUrlCell({ + required this.cellControllerBuilder, + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardUrlCellState(); +} + +class _BoardUrlCellState extends State { + late BoardURLCellBloc _cellBloc; + + @override + void initState() { + final cellController = + widget.cellControllerBuilder.build() as GridURLCellController; + _cellBloc = BoardURLCellBloc(cellController: cellController); + _cellBloc.add(const BoardURLCellEvent.initial()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + return BlocProvider.value( + value: _cellBloc, + child: BlocBuilder( + builder: (context, state) { + if (state.content.isEmpty) { + return const SizedBox(); + } else { + return Align( + alignment: Alignment.centerLeft, + child: RichText( + textAlign: TextAlign.left, + text: TextSpan( + text: state.content, + style: TextStyle( + color: theme.main2, + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ), + ); + } + }, + ), + ); + } + + @override + Future dispose() async { + _cellBloc.close(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart new file mode 100644 index 0000000000..a5c7b7ba2c --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart @@ -0,0 +1,98 @@ +import 'package:app_flowy/plugins/board/application/card/card_bloc.dart'; +import 'package:app_flowy/plugins/board/application/card/card_data_controller.dart'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/row/row_action_sheet.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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'card_cell_builder.dart'; +import 'card_container.dart'; + +typedef OnEndEditing = void Function(String rowId); + +class BoardCard extends StatefulWidget { + final String gridId; + final bool isEditing; + final CardDataController dataController; + final BoardCellBuilder cellBuilder; + final OnEndEditing onEditEditing; + final void Function(BuildContext) openCard; + + const BoardCard({ + required this.gridId, + required this.isEditing, + required this.dataController, + required this.cellBuilder, + required this.onEditEditing, + required this.openCard, + Key? key, + }) : super(key: key); + + @override + State createState() => _BoardCardState(); +} + +class _BoardCardState extends State { + late BoardCardBloc _cardBloc; + + @override + void initState() { + _cardBloc = BoardCardBloc( + gridId: widget.gridId, + dataController: widget.dataController, + ); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cardBloc, + child: BlocBuilder( + builder: (context, state) { + return BoardCardContainer( + accessoryBuilder: (context) { + return [const _CardMoreOption()]; + }, + onTap: (context) { + widget.openCard(context); + }, + child: Column( + children: _makeCells(context, state.gridCellMap), + ), + ); + }, + ), + ); + } + + List _makeCells(BuildContext context, GridCellMap cellMap) { + return cellMap.values.map( + (cellId) { + final child = widget.cellBuilder.buildCell(cellId); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: child, + ); + }, + ).toList(); + } +} + +class _CardMoreOption extends StatelessWidget with CardAccessory { + const _CardMoreOption({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return svgWidget('home/details', color: context.read().iconColor); + } + + @override + void onTap(BuildContext context) { + GridRowActionSheet( + rowData: context.read().rowInfo(), + ).show(context, direction: AnchorDirection.bottomWithCenterAligned); + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart new file mode 100644 index 0000000000..10ae0db680 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/card_cell_builder.dart @@ -0,0 +1,69 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:flutter/material.dart'; + +import 'board_checkbox_cell.dart'; +import 'board_date_cell.dart'; +import 'board_number_cell.dart'; +import 'board_select_option_cell.dart'; +import 'board_text_cell.dart'; +import 'board_url_cell.dart'; + +abstract class BoardCellBuilderDelegate + extends GridCellControllerBuilderDelegate { + GridCellCache get cellCache; +} + +class BoardCellBuilder { + final BoardCellBuilderDelegate delegate; + + BoardCellBuilder(this.delegate); + + Widget buildCell(GridCellIdentifier cellId) { + final cellControllerBuilder = GridCellControllerBuilder( + delegate: delegate, + cellId: cellId, + cellCache: delegate.cellCache, + ); + + final key = cellId.key(); + switch (cellId.fieldType) { + case FieldType.Checkbox: + return BoardCheckboxCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + case FieldType.DateTime: + return BoardDateCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + case FieldType.SingleSelect: + return BoardSelectOptionCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + case FieldType.MultiSelect: + return BoardSelectOptionCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + case FieldType.Number: + return BoardNumberCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + case FieldType.RichText: + return BoardTextCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + case FieldType.URL: + return BoardUrlCell( + cellControllerBuilder: cellControllerBuilder, + key: key, + ); + } + throw UnimplementedError; + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart new file mode 100644 index 0000000000..abca27e5c5 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart @@ -0,0 +1,142 @@ +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class BoardCardContainer extends StatelessWidget { + final Widget child; + final CardAccessoryBuilder? accessoryBuilder; + final void Function(BuildContext) onTap; + const BoardCardContainer({ + required this.child, + required this.onTap, + this.accessoryBuilder, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => _CardContainerNotifier(), + child: Consumer<_CardContainerNotifier>( + builder: (context, notifier, _) { + Widget container = Center(child: child); + if (accessoryBuilder != null) { + final accessories = accessoryBuilder!(context); + if (accessories.isNotEmpty) { + container = _CardEnterRegion( + child: container, + accessories: accessories, + ); + } + } + + return GestureDetector( + onTap: () => onTap(context), + child: Padding( + padding: const EdgeInsets.all(8), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: container, + ), + ), + ); + }, + ), + ); + } +} + +abstract class CardAccessory implements Widget { + void onTap(BuildContext context); +} + +typedef CardAccessoryBuilder = List Function( + BuildContext buildContext, +); + +class CardAccessoryContainer extends StatelessWidget { + final List accessories; + const CardAccessoryContainer({required this.accessories, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = context.read(); + final children = accessories.map((accessory) { + final hover = FlowyHover( + style: HoverStyle( + hoverColor: theme.hover, + backgroundColor: theme.surface, + ), + builder: (_, onHover) => Container( + width: 26, + height: 26, + padding: const EdgeInsets.all(3), + child: accessory, + ), + ); + return GestureDetector( + child: hover, + behavior: HitTestBehavior.opaque, + onTap: () => accessory.onTap(context), + ); + }).toList(); + + return Wrap(children: children, spacing: 6); + } +} + +class _CardEnterRegion extends StatelessWidget { + final Widget child; + final List accessories; + const _CardEnterRegion( + {required this.child, required this.accessories, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Selector<_CardContainerNotifier, bool>( + selector: (context, notifier) => notifier.onEnter, + builder: (context, onEnter, _) { + List children = [child]; + if (onEnter) { + children.add(CardAccessoryContainer(accessories: accessories) + .positioned(right: 0)); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (p) => + Provider.of<_CardContainerNotifier>(context, listen: false) + .onEnter = true, + onExit: (p) => + Provider.of<_CardContainerNotifier>(context, listen: false) + .onEnter = false, + child: IntrinsicHeight( + child: Stack( + alignment: AlignmentDirectional.center, + fit: StackFit.expand, + children: children, + )), + ); + }, + ); + } +} + +class _CardContainerNotifier extends ChangeNotifier { + bool _onEnter = false; + + _CardContainerNotifier(); + + set onEnter(bool value) { + if (_onEnter != value) { + _onEnter = value; + notifyListeners(); + } + } + + bool get onEnter => _onEnter; +} diff --git a/frontend/app_flowy/lib/plugins/doc/document.dart b/frontend/app_flowy/lib/plugins/doc/document.dart index 91bae222c1..ba209ddbb7 100644 --- a/frontend/app_flowy/lib/plugins/doc/document.dart +++ b/frontend/app_flowy/lib/plugins/doc/document.dart @@ -1,4 +1,4 @@ -library docuemnt_plugin; +library document_plugin; import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:app_flowy/startup/plugin/plugin.dart'; @@ -42,10 +42,10 @@ class DocumentPluginBuilder extends PluginBuilder { String get menuName => LocaleKeys.document_menuName.tr(); @override - PluginType get pluginType => DefaultPlugin.quill.type(); + PluginType get pluginType => PluginType.editor; @override - ViewDataType get dataType => ViewDataType.TextBlock; + ViewDataTypePB get dataType => ViewDataTypePB.Text; } class DocumentPlugin implements Plugin { diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart b/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart index 52c422b64c..bd4b651da8 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart @@ -11,7 +11,9 @@ import 'package:app_flowy/generated/locale_keys.g.dart'; class DocumentBanner extends StatelessWidget { final void Function() onRestore; final void Function() onDelete; - const DocumentBanner({required this.onRestore, required this.onDelete, Key? key}) : super(key: key); + const DocumentBanner( + {required this.onRestore, required this.onDelete, Key? key}) + : super(key: key); @override Widget build(BuildContext context) { @@ -26,7 +28,8 @@ class DocumentBanner extends StatelessWidget { fit: BoxFit.scaleDown, child: Row( children: [ - FlowyText.medium(LocaleKeys.deletePagePrompt_text.tr(), color: Colors.white), + FlowyText.medium(LocaleKeys.deletePagePrompt_text.tr(), + color: Colors.white), const HSpace(20), BaseStyledButton( minWidth: 160, @@ -37,7 +40,10 @@ class DocumentBanner extends StatelessWidget { downColor: theme.main1, outlineColor: Colors.white, borderRadius: Corners.s8Border, - child: FlowyText.medium(LocaleKeys.deletePagePrompt_restore.tr(), color: Colors.white, fontSize: 14), + child: FlowyText.medium( + LocaleKeys.deletePagePrompt_restore.tr(), + color: Colors.white, + fontSize: 14), onPressed: onRestore), const HSpace(20), BaseStyledButton( @@ -49,8 +55,10 @@ class DocumentBanner extends StatelessWidget { downColor: theme.main1, outlineColor: Colors.white, borderRadius: Corners.s8Border, - child: FlowyText.medium(LocaleKeys.deletePagePrompt_deletePermanent.tr(), - color: Colors.white, fontSize: 14), + child: FlowyText.medium( + LocaleKeys.deletePagePrompt_deletePermanent.tr(), + color: Colors.white, + fontSize: 14), onPressed: onDelete), ], ), diff --git a/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart index 5ccff7c838..b639700b5f 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/block/block_cache.dart @@ -1,19 +1,19 @@ import 'dart:async'; -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; +import '../field/field_cache.dart'; +import '../row/row_cache.dart'; import 'block_listener.dart'; /// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information class GridBlockCache { final String gridId; - final GridBlockPB block; + final BlockPB block; late GridRowCache _rowCache; late GridBlockListener _listener; - List get rows => _rowCache.rows; + List get rows => _rowCache.rows; GridRowCache get rowCache => _rowCache; GridBlockCache({ @@ -24,7 +24,7 @@ class GridBlockCache { _rowCache = GridRowCache( gridId: gridId, block: block, - notifier: GridRowCacheFieldNotifierImpl(fieldCache), + notifier: GridRowFieldNotifierImpl(fieldCache), ); _listener = GridBlockListener(blockId: block.id); @@ -42,7 +42,7 @@ class GridBlockCache { } void addListener({ - required void Function(GridRowChangeReason) onChangeReason, + required void Function(RowsChangedReason) onRowsChanged, bool Function()? listenWhen, }) { _rowCache.onRowsChanged((reason) { @@ -50,7 +50,7 @@ class GridBlockCache { return; } - onChangeReason(reason); + onRowsChanged(reason); }); } } diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart index 72f1bc787d..d4cca373bc 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_field_notifier.dart @@ -3,19 +3,22 @@ import 'package:flutter/foundation.dart'; import 'cell_service.dart'; -abstract class GridFieldChangedNotifier { - void onFieldChanged(void Function(GridFieldPB) callback); - void dispose(); +abstract class IGridCellFieldNotifier { + void onCellFieldChanged(void Function(FieldPB) callback); + void onCellDispose(); } /// GridPB's cell helper wrapper that enables each cell will get notified when the corresponding field was changed. /// You Register an onFieldChanged callback to listen to the cell changes, and unregister if you don't want to listen. class GridCellFieldNotifier { - /// fieldId: {objectId: callback} - final Map>> _fieldListenerByFieldId = {}; + final IGridCellFieldNotifier notifier; - GridCellFieldNotifier({required GridFieldChangedNotifier notifier}) { - notifier.onFieldChanged( + /// fieldId: {objectId: callback} + final Map>> _fieldListenerByFieldId = + {}; + + GridCellFieldNotifier({required this.notifier}) { + notifier.onCellFieldChanged( (field) { final map = _fieldListenerByFieldId[field.id]; if (map != null) { @@ -55,6 +58,7 @@ class GridCellFieldNotifier { } Future dispose() async { + notifier.onCellDispose(); _fieldListenerByFieldId.clear(); } } diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart index 2bef94c16a..bb750b4b89 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_service.dart @@ -1,7 +1,5 @@ import 'dart:async'; import 'dart:collection'; - -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; import 'package:dartz/dartz.dart'; import 'package:equatable/equatable.dart'; import 'package:flowy_sdk/dispatch/dispatch.dart'; @@ -18,7 +16,8 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_listener.dart'; import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; import 'dart:convert' show utf8; -import '../../field/type_option/type_option_service.dart'; +import '../../field/field_cache.dart'; +import '../../field/type_option/type_option_context.dart'; import 'cell_field_notifier.dart'; part 'cell_service.freezed.dart'; part 'cell_data_loader.dart'; @@ -61,7 +60,7 @@ class GridCellIdentifier with _$GridCellIdentifier { const factory GridCellIdentifier({ required String gridId, required String rowId, - required GridFieldPB field, + required FieldPB field, }) = _GridCellIdentifier; // ignore: unused_element diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart index 8f78793a2c..1068cbf36b 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/context_builder.dart @@ -1,29 +1,32 @@ part of 'cell_service.dart'; typedef GridCellController = IGridCellController; +typedef GridCheckboxCellController = IGridCellController; +typedef GridNumberCellController = IGridCellController; typedef GridSelectOptionCellController = IGridCellController; typedef GridDateCellController = IGridCellController; typedef GridURLCellController = IGridCellController; +abstract class GridCellControllerBuilderDelegate { + GridCellFieldNotifier buildFieldNotifier(); +} + class GridCellControllerBuilder { final GridCellIdentifier _cellId; final GridCellCache _cellCache; - final GridFieldCache _fieldCache; + final GridCellControllerBuilderDelegate delegate; GridCellControllerBuilder({ + required this.delegate, required GridCellIdentifier cellId, required GridCellCache cellCache, - required GridFieldCache fieldCache, }) : _cellCache = cellCache, - _fieldCache = fieldCache, _cellId = cellId; IGridCellController build() { - final cellFieldNotifier = GridCellFieldNotifier( - notifier: _GridFieldChangedNotifierImpl(_fieldCache)); - + final cellFieldNotifier = delegate.buildFieldNotifier(); switch (_cellId.fieldType) { case FieldType.Checkbox: final cellDataLoader = GridCellDataLoader( @@ -57,7 +60,7 @@ class GridCellControllerBuilder { parser: StringCellDataParser(), reloadOnFieldChanged: true, ); - return GridCellController( + return GridNumberCellController( cellId: _cellId, cellCache: _cellCache, cellDataLoader: cellDataLoader, @@ -126,7 +129,7 @@ class IGridCellController extends Equatable { final GridCellDataLoader _cellDataLoader; final IGridCellDataPersistence _cellDataPersistence; - late final CellListener _cellListener; + CellListener? _cellListener; ValueNotifier? _cellDataNotifier; bool isListening = false; @@ -165,7 +168,7 @@ class IGridCellController extends Equatable { String get fieldId => cellId.field.id; - GridFieldPB get field => cellId.field; + FieldPB get field => cellId.field; FieldType get fieldType => cellId.field.fieldType; @@ -185,7 +188,7 @@ class IGridCellController extends Equatable { /// For example: /// user input: 12 /// cell display: $12 - _cellListener.start(onCellChanged: (result) { + _cellListener?.start(onCellChanged: (result) { result.fold( (_) => _loadData(), (err) => Log.error(err), @@ -239,14 +242,14 @@ class IGridCellController extends Equatable { .getFieldTypeOptionData(fieldType: fieldType) .then((result) { return result.fold( - (data) => parser.fromBuffer(data.typeOptionData), + (data) => left(parser.fromBuffer(data.typeOptionData)), (err) => right(err), ); }); } /// Save the cell data to disk - /// You can set [dedeplicate] to true (default is false) to reduce the save operation. + /// You can set [deduplicate] to true (default is false) to reduce the save operation. /// It's useful when you call this method when user editing the [TextField]. /// The default debounce interval is 300 milliseconds. void saveCellData(D data, @@ -288,13 +291,14 @@ class IGridCellController extends Equatable { return; } _isDispose = true; - _cellListener.stop(); + _cellListener?.stop(); _loadDataOperation?.cancel(); _saveDataOperation?.cancel(); _cellDataNotifier = null; if (_onFieldChangedFn != null) { _fieldNotifier.unregister(_cacheKey, _onFieldChangedFn!); + _fieldNotifier.dispose(); _onFieldChangedFn = null; } } @@ -304,14 +308,14 @@ class IGridCellController extends Equatable { [_cellsCache.get(_cacheKey) ?? "", cellId.rowId + cellId.field.id]; } -class _GridFieldChangedNotifierImpl extends GridFieldChangedNotifier { +class GridCellFieldNotifierImpl extends IGridCellFieldNotifier { final GridFieldCache _cache; FieldChangesetCallback? _onChangesetFn; - _GridFieldChangedNotifierImpl(GridFieldCache cache) : _cache = cache; + GridCellFieldNotifierImpl(GridFieldCache cache) : _cache = cache; @override - void dispose() { + void onCellDispose() { if (_onChangesetFn != null) { _cache.removeListener(onChangesetListener: _onChangesetFn!); _onChangesetFn = null; @@ -319,8 +323,8 @@ class _GridFieldChangedNotifierImpl extends GridFieldChangedNotifier { } @override - void onFieldChanged(void Function(GridFieldPB p1) callback) { - _onChangesetFn = (GridFieldChangesetPB changeset) { + void onCellFieldChanged(void Function(FieldPB p1) callback) { + _onChangesetFn = (FieldChangesetPB changeset) { for (final updatedField in changeset.updatedFields) { callback(updatedField); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart index 041e687c9b..f5e7a451e2 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/checkbox_cell_bloc.dart @@ -6,13 +6,13 @@ import 'cell_service/cell_service.dart'; part 'checkbox_cell_bloc.freezed.dart'; class CheckboxCellBloc extends Bloc { - final GridCellController cellContext; + final GridCheckboxCellController cellController; void Function()? _onCellChangedFn; CheckboxCellBloc({ required CellService service, - required this.cellContext, - }) : super(CheckboxCellState.initial(cellContext)) { + required this.cellController, + }) : super(CheckboxCellState.initial(cellController)) { on( (event, emit) async { await event.when( @@ -33,16 +33,17 @@ class CheckboxCellBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening(onCellChanged: ((cellData) { + _onCellChangedFn = + cellController.startListening(onCellChanged: ((cellData) { if (!isClosed) { add(CheckboxCellEvent.didReceiveCellUpdate(cellData)); } @@ -50,7 +51,7 @@ class CheckboxCellBloc extends Bloc { } void _updateCellData() { - cellContext.saveCellData(!state.isSelected ? "Yes" : "No"); + cellController.saveCellData(!state.isSelected ? "Yes" : "No"); } } @@ -58,7 +59,8 @@ class CheckboxCellBloc extends Bloc { class CheckboxCellEvent with _$CheckboxCellEvent { const factory CheckboxCellEvent.initial() = _Initial; const factory CheckboxCellEvent.select() = _Selected; - const factory CheckboxCellEvent.didReceiveCellUpdate(String? cellData) = _DidReceiveCellUpdate; + const factory CheckboxCellEvent.didReceiveCellUpdate(String? cellData) = + _DidReceiveCellUpdate; } @freezed diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart index 85dbbb40be..c0584a084b 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/date_cal_bloc.dart @@ -18,14 +18,14 @@ import 'package:fixnum/fixnum.dart' as $fixnum; part 'date_cal_bloc.freezed.dart'; class DateCalBloc extends Bloc { - final GridDateCellController cellContext; + final GridDateCellController cellController; void Function()? _onCellChangedFn; DateCalBloc({ - required DateTypeOption dateTypeOption, + required DateTypeOptionPB dateTypeOptionPB, required DateCellDataPB? cellData, - required this.cellContext, - }) : super(DateCalState.initial(dateTypeOption, cellData)) { + required this.cellController, + }) : super(DateCalState.initial(dateTypeOptionPB, cellData)) { on( (event, emit) async { await event.when( @@ -102,7 +102,7 @@ class DateCalBloc extends Bloc { } } - cellContext.saveCellData(newCalData, resultCallback: (result) { + cellController.saveCellData(newCalData, resultCallback: (result) { result.fold( () => updateCalData(Some(newCalData), none()), (err) { @@ -120,7 +120,7 @@ class DateCalBloc extends Bloc { String timeFormatPrompt(FlowyError error) { String msg = LocaleKeys.grid_field_invalidTimeFormat.tr() + ". "; - switch (state.dateTypeOption.timeFormat) { + switch (state.dateTypeOptionPB.timeFormat) { case TimeFormat.TwelveHour: msg = msg + "e.g. 01: 00 AM"; break; @@ -136,15 +136,15 @@ class DateCalBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((cell) { if (!isClosed) { add(DateCalEvent.didReceiveCellUpdate(cell)); @@ -159,8 +159,8 @@ class DateCalBloc extends Bloc { TimeFormat? timeFormat, bool? includeTime, }) async { - state.dateTypeOption.freeze(); - final newDateTypeOption = state.dateTypeOption.rebuild((typeOption) { + state.dateTypeOptionPB.freeze(); + final newDateTypeOption = state.dateTypeOptionPB.rebuild((typeOption) { if (dateFormat != null) { typeOption.dateFormat = dateFormat; } @@ -175,14 +175,14 @@ class DateCalBloc extends Bloc { }); final result = await FieldService.updateFieldTypeOption( - gridId: cellContext.gridId, - fieldId: cellContext.field.id, + gridId: cellController.gridId, + fieldId: cellController.field.id, typeOptionData: newDateTypeOption.writeToBuffer(), ); result.fold( (l) => emit(state.copyWith( - dateTypeOption: newDateTypeOption, + dateTypeOptionPB: newDateTypeOption, timeHintText: _timeHintText(newDateTypeOption))), (err) => Log.error(err), ); @@ -210,7 +210,7 @@ class DateCalEvent with _$DateCalEvent { @freezed class DateCalState with _$DateCalState { const factory DateCalState({ - required DateTypeOption dateTypeOption, + required DateTypeOptionPB dateTypeOptionPB, required CalendarFormat format, required DateTime focusedDay, required Option timeFormatError, @@ -220,24 +220,24 @@ class DateCalState with _$DateCalState { }) = _DateCalState; factory DateCalState.initial( - DateTypeOption dateTypeOption, + DateTypeOptionPB dateTypeOptionPB, DateCellDataPB? cellData, ) { Option calData = calDataFromCellData(cellData); final time = calData.foldRight("", (dateData, previous) => dateData.time); return DateCalState( - dateTypeOption: dateTypeOption, + dateTypeOptionPB: dateTypeOptionPB, format: CalendarFormat.month, focusedDay: DateTime.now(), time: time, calData: calData, timeFormatError: none(), - timeHintText: _timeHintText(dateTypeOption), + timeHintText: _timeHintText(dateTypeOptionPB), ); } } -String _timeHintText(DateTypeOption typeOption) { +String _timeHintText(DateTypeOptionPB typeOption) { switch (typeOption.timeFormat) { case TimeFormat.TwelveHour: return LocaleKeys.document_date_timeHintTextInTwelveHour.tr(); diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart index c4f79f1f90..4150093275 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/date_cell_bloc.dart @@ -7,18 +7,21 @@ import 'cell_service/cell_service.dart'; part 'date_cell_bloc.freezed.dart'; class DateCellBloc extends Bloc { - final GridDateCellController cellContext; + final GridDateCellController cellController; void Function()? _onCellChangedFn; - DateCellBloc({required this.cellContext}) : super(DateCellState.initial(cellContext)) { + DateCellBloc({required this.cellController}) + : super(DateCellState.initial(cellController)) { on( (event, emit) async { event.when( initial: () => _startListening(), didReceiveCellUpdate: (DateCellDataPB? cellData) { - emit(state.copyWith(data: cellData, dateStr: _dateStrFromCellData(cellData))); + emit(state.copyWith( + data: cellData, dateStr: _dateStrFromCellData(cellData))); }, - didReceiveFieldUpdate: (GridFieldPB value) => emit(state.copyWith(field: value)), + didReceiveFieldUpdate: (FieldPB value) => + emit(state.copyWith(field: value)), ); }, ); @@ -27,15 +30,15 @@ class DateCellBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((data) { if (!isClosed) { add(DateCellEvent.didReceiveCellUpdate(data)); @@ -48,8 +51,10 @@ class DateCellBloc extends Bloc { @freezed class DateCellEvent with _$DateCellEvent { const factory DateCellEvent.initial() = _InitialCell; - const factory DateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = _DidReceiveCellUpdate; - const factory DateCellEvent.didReceiveFieldUpdate(GridFieldPB field) = _DidReceiveFieldUpdate; + const factory DateCellEvent.didReceiveCellUpdate(DateCellDataPB? data) = + _DidReceiveCellUpdate; + const factory DateCellEvent.didReceiveFieldUpdate(FieldPB field) = + _DidReceiveFieldUpdate; } @freezed @@ -57,7 +62,7 @@ class DateCellState with _$DateCellState { const factory DateCellState({ required DateCellDataPB? data, required String dateStr, - required GridFieldPB field, + required FieldPB field, }) = _DateCellState; factory DateCellState.initial(GridDateCellController context) { diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart index 65eec13e6c..2ca989289f 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/number_cell_bloc.dart @@ -8,12 +8,12 @@ import 'cell_service/cell_service.dart'; part 'number_cell_bloc.freezed.dart'; class NumberCellBloc extends Bloc { - final GridCellController cellContext; + final GridNumberCellController cellController; void Function()? _onCellChangedFn; NumberCellBloc({ - required this.cellContext, - }) : super(NumberCellState.initial(cellContext)) { + required this.cellController, + }) : super(NumberCellState.initial(cellController)) { on( (event, emit) async { event.when( @@ -24,11 +24,13 @@ class NumberCellBloc extends Bloc { emit(state.copyWith(content: content)); }, updateCell: (text) { - cellContext.saveCellData(text, resultCallback: (result) { + cellController.saveCellData(text, resultCallback: (result) { result.fold( () => null, (err) { - if (!isClosed) add(NumberCellEvent.didReceiveCellUpdate(right(err))); + if (!isClosed) { + add(NumberCellEvent.didReceiveCellUpdate(right(err))); + } }, ); }); @@ -41,15 +43,15 @@ class NumberCellBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((cellContent) { if (!isClosed) { add(NumberCellEvent.didReceiveCellUpdate(left(cellContent ?? ""))); @@ -63,7 +65,8 @@ class NumberCellBloc extends Bloc { class NumberCellEvent with _$NumberCellEvent { const factory NumberCellEvent.initial() = _Initial; const factory NumberCellEvent.updateCell(String text) = _UpdateCell; - const factory NumberCellEvent.didReceiveCellUpdate(Either cellContent) = _DidReceiveCellUpdate; + const factory NumberCellEvent.didReceiveCellUpdate( + Either cellContent) = _DidReceiveCellUpdate; } @freezed diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart index d823568e17..fca90c9903 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_cell_bloc.dart @@ -8,12 +8,12 @@ part 'select_option_cell_bloc.freezed.dart'; class SelectOptionCellBloc extends Bloc { - final GridSelectOptionCellController cellContext; + final GridSelectOptionCellController cellController; void Function()? _onCellChangedFn; SelectOptionCellBloc({ - required this.cellContext, - }) : super(SelectOptionCellState.initial(cellContext)) { + required this.cellController, + }) : super(SelectOptionCellState.initial(cellController)) { on( (event, emit) async { await event.map( @@ -33,15 +33,15 @@ class SelectOptionCellBloc @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((selectOptionContext) { if (!isClosed) { add(SelectOptionCellEvent.didReceiveOptions( diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart index 6ee7fe9f32..8d52252e2a 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_editor_bloc.dart @@ -111,7 +111,7 @@ class SelectOptionCellEditorBloc void _loadOptions() { _delayOperation?.cancel(); _delayOperation = Timer(const Duration(milliseconds: 10), () { - _selectOptionService.getOpitonContext().then((result) { + _selectOptionService.getOptionContext().then((result) { if (isClosed) { return; } diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart index 9172450ef0..44d4bdd4be 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/select_option_service.dart @@ -15,7 +15,7 @@ class SelectOptionService { String get rowId => cellId.rowId; Future> create({required String name}) { - return TypeOptionService(gridId: gridId, fieldId: fieldId) + return TypeOptionFFIService(gridId: gridId, fieldId: fieldId) .newOption(name: name) .then( (result) { @@ -55,7 +55,7 @@ class SelectOptionService { return GridEventUpdateSelectOption(payload).send(); } - Future> getOpitonContext() { + Future> getOptionContext() { final payload = GridCellIdPB.create() ..gridId = gridId ..fieldId = fieldId diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart index 783564b5fa..3fa55b744f 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/text_cell_bloc.dart @@ -6,11 +6,11 @@ import 'cell_service/cell_service.dart'; part 'text_cell_bloc.freezed.dart'; class TextCellBloc extends Bloc { - final GridCellController cellContext; + final GridCellController cellController; void Function()? _onCellChangedFn; TextCellBloc({ - required this.cellContext, - }) : super(TextCellState.initial(cellContext)) { + required this.cellController, + }) : super(TextCellState.initial(cellController)) { on( (event, emit) async { await event.when( @@ -18,7 +18,7 @@ class TextCellBloc extends Bloc { _startListening(); }, updateText: (text) { - cellContext.saveCellData(text); + cellController.saveCellData(text); emit(state.copyWith(content: text)); }, didReceiveCellUpdate: (content) { @@ -32,15 +32,15 @@ class TextCellBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((cellContent) { if (!isClosed) { add(TextCellEvent.didReceiveCellUpdate(cellContent ?? "")); @@ -53,7 +53,8 @@ class TextCellBloc extends Bloc { @freezed class TextCellEvent with _$TextCellEvent { const factory TextCellEvent.initial() = _InitialCell; - const factory TextCellEvent.didReceiveCellUpdate(String cellContent) = _DidReceiveCellUpdate; + const factory TextCellEvent.didReceiveCellUpdate(String cellContent) = + _DidReceiveCellUpdate; const factory TextCellEvent.updateText(String text) = _UpdateText; } diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart index e43f561542..824900f173 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_bloc.dart @@ -7,11 +7,11 @@ import 'cell_service/cell_service.dart'; part 'url_cell_bloc.freezed.dart'; class URLCellBloc extends Bloc { - final GridURLCellController cellContext; + final GridURLCellController cellController; void Function()? _onCellChangedFn; URLCellBloc({ - required this.cellContext, - }) : super(URLCellState.initial(cellContext)) { + required this.cellController, + }) : super(URLCellState.initial(cellController)) { on( (event, emit) async { event.when( @@ -25,7 +25,7 @@ class URLCellBloc extends Bloc { )); }, updateURL: (String url) { - cellContext.saveCellData(url, deduplicate: true); + cellController.saveCellData(url, deduplicate: true); }, ); }, @@ -35,15 +35,15 @@ class URLCellBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((cellData) { if (!isClosed) { add(URLCellEvent.didReceiveCellUpdate(cellData)); @@ -57,7 +57,8 @@ class URLCellBloc extends Bloc { class URLCellEvent with _$URLCellEvent { const factory URLCellEvent.initial() = _InitialCell; const factory URLCellEvent.updateURL(String url) = _UpdateURL; - const factory URLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) = _DidReceiveCellUpdate; + const factory URLCellEvent.didReceiveCellUpdate(URLCellDataPB? cell) = + _DidReceiveCellUpdate; } @freezed diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart index 067be84b7b..8e82c27f42 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/url_cell_editor_bloc.dart @@ -7,11 +7,11 @@ import 'cell_service/cell_service.dart'; part 'url_cell_editor_bloc.freezed.dart'; class URLCellEditorBloc extends Bloc { - final GridURLCellController cellContext; + final GridURLCellController cellController; void Function()? _onCellChangedFn; URLCellEditorBloc({ - required this.cellContext, - }) : super(URLCellEditorState.initial(cellContext)) { + required this.cellController, + }) : super(URLCellEditorState.initial(cellController)) { on( (event, emit) async { event.when( @@ -19,7 +19,7 @@ class URLCellEditorBloc extends Bloc { _startListening(); }, updateText: (text) { - cellContext.saveCellData(text, deduplicate: true); + cellController.saveCellData(text, deduplicate: true); emit(state.copyWith(content: text)); }, didReceiveCellUpdate: (cellData) { @@ -33,15 +33,15 @@ class URLCellEditorBloc extends Bloc { @override Future close() async { if (_onCellChangedFn != null) { - cellContext.removeListener(_onCellChangedFn!); + cellController.removeListener(_onCellChangedFn!); _onCellChangedFn = null; } - cellContext.dispose(); + cellController.dispose(); return super.close(); } void _startListening() { - _onCellChangedFn = cellContext.startListening( + _onCellChangedFn = cellController.startListening( onCellChanged: ((cellData) { if (!isClosed) { add(URLCellEditorEvent.didReceiveCellUpdate(cellData)); @@ -54,7 +54,8 @@ class URLCellEditorBloc extends Bloc { @freezed class URLCellEditorEvent with _$URLCellEditorEvent { const factory URLCellEditorEvent.initial() = _InitialCell; - const factory URLCellEditorEvent.didReceiveCellUpdate(URLCellDataPB? cell) = _DidReceiveCellUpdate; + const factory URLCellEditorEvent.didReceiveCellUpdate(URLCellDataPB? cell) = + _DidReceiveCellUpdate; const factory URLCellEditorEvent.updateText(String text) = _UpdateText; } diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart index 3caef12f73..52b3d3368e 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_action_sheet_bloc.dart @@ -7,11 +7,13 @@ import 'field_service.dart'; part 'field_action_sheet_bloc.freezed.dart'; -class FieldActionSheetBloc extends Bloc { +class FieldActionSheetBloc + extends Bloc { final FieldService fieldService; - FieldActionSheetBloc({required GridFieldPB field, required this.fieldService}) - : super(FieldActionSheetState.initial(FieldTypeOptionDataPB.create()..field_2 = field)) { + FieldActionSheetBloc({required FieldPB field, required this.fieldService}) + : super(FieldActionSheetState.initial( + FieldTypeOptionDataPB.create()..field_2 = field)) { on( (event, emit) async { await event.map( @@ -57,7 +59,8 @@ class FieldActionSheetBloc extends Bloc FieldActionSheetState( + factory FieldActionSheetState.initial(FieldTypeOptionDataPB data) => + FieldActionSheetState( fieldTypeOptionData: data, errorText: '', fieldName: data.field_2.name, diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_cache.dart new file mode 100644 index 0000000000..e521f097ee --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_cache.dart @@ -0,0 +1,192 @@ +import 'dart:collection'; + +import 'package:app_flowy/plugins/grid/application/field/grid_listener.dart'; +import 'package:flowy_sdk/log.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:flutter/foundation.dart'; + +import '../row/row_cache.dart'; + +class FieldsNotifier extends ChangeNotifier { + List _fields = []; + + set fields(List fields) { + _fields = fields; + notifyListeners(); + } + + List get fields => _fields; +} + +typedef FieldChangesetCallback = void Function(FieldChangesetPB); +typedef FieldsCallback = void Function(List); + +class GridFieldCache { + final String gridId; + final GridFieldsListener _fieldListener; + FieldsNotifier? _fieldNotifier = FieldsNotifier(); + final Map _fieldsCallbackMap = {}; + final Map + _changesetCallbackMap = {}; + + GridFieldCache({required this.gridId}) + : _fieldListener = GridFieldsListener(gridId: gridId) { + _fieldListener.start(onFieldsChanged: (result) { + result.fold( + (changeset) { + _deleteFields(changeset.deletedFields); + _insertFields(changeset.insertedFields); + _updateFields(changeset.updatedFields); + for (final listener in _changesetCallbackMap.values) { + listener(changeset); + } + }, + (err) => Log.error(err), + ); + }); + } + + Future dispose() async { + await _fieldListener.stop(); + _fieldNotifier?.dispose(); + _fieldNotifier = null; + } + + UnmodifiableListView get unmodifiableFields => + UnmodifiableListView(_fieldNotifier?.fields ?? []); + + List get fields => [..._fieldNotifier?.fields ?? []]; + + set fields(List fields) { + _fieldNotifier?.fields = [...fields]; + } + + void addListener({ + FieldsCallback? onFields, + FieldChangesetCallback? onChangeset, + bool Function()? listenWhen, + }) { + if (onChangeset != null) { + fn(c) { + if (listenWhen != null && listenWhen() == false) { + return; + } + onChangeset(c); + } + + _changesetCallbackMap[onChangeset] = fn; + } + + if (onFields != null) { + fn() { + if (listenWhen != null && listenWhen() == false) { + return; + } + onFields(fields); + } + + _fieldsCallbackMap[onFields] = fn; + _fieldNotifier?.addListener(fn); + } + } + + void removeListener({ + FieldsCallback? onFieldsListener, + FieldChangesetCallback? onChangesetListener, + }) { + if (onFieldsListener != null) { + final fn = _fieldsCallbackMap.remove(onFieldsListener); + if (fn != null) { + _fieldNotifier?.removeListener(fn); + } + } + + if (onChangesetListener != null) { + _changesetCallbackMap.remove(onChangesetListener); + } + } + + void _deleteFields(List deletedFields) { + if (deletedFields.isEmpty) { + return; + } + final List newFields = fields; + final Map deletedFieldMap = { + for (var fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder + }; + + newFields.retainWhere((field) => (deletedFieldMap[field.id] == null)); + _fieldNotifier?.fields = newFields; + } + + void _insertFields(List insertedFields) { + if (insertedFields.isEmpty) { + return; + } + final List newFields = fields; + for (final indexField in insertedFields) { + if (newFields.length > indexField.index) { + newFields.insert(indexField.index, indexField.field_1); + } else { + newFields.add(indexField.field_1); + } + } + _fieldNotifier?.fields = newFields; + } + + void _updateFields(List updatedFields) { + if (updatedFields.isEmpty) { + return; + } + final List newFields = fields; + for (final updatedField in updatedFields) { + final index = + newFields.indexWhere((field) => field.id == updatedField.id); + if (index != -1) { + newFields.removeAt(index); + newFields.insert(index, updatedField); + } + } + _fieldNotifier?.fields = newFields; + } +} + +class GridRowFieldNotifierImpl extends IGridRowFieldNotifier { + final GridFieldCache _cache; + FieldChangesetCallback? _onChangesetFn; + FieldsCallback? _onFieldFn; + GridRowFieldNotifierImpl(GridFieldCache cache) : _cache = cache; + + @override + UnmodifiableListView get fields => _cache.unmodifiableFields; + + @override + void onRowFieldsChanged(VoidCallback callback) { + _onFieldFn = (_) => callback(); + _cache.addListener(onFields: _onFieldFn); + } + + @override + void onRowFieldChanged(void Function(FieldPB) callback) { + _onChangesetFn = (FieldChangesetPB changeset) { + for (final updatedField in changeset.updatedFields) { + callback(updatedField); + } + }; + + _cache.addListener(onChangeset: _onChangesetFn); + } + + @override + void onRowDispose() { + if (_onFieldFn != null) { + _cache.removeListener(onFieldsListener: _onFieldFn!); + _onFieldFn = null; + } + + if (_onChangesetFn != null) { + _cache.removeListener(onChangesetListener: _onChangesetFn!); + _onChangesetFn = null; + } + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart index ce09ba20bf..43114036c1 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_cell_bloc.dart @@ -63,7 +63,7 @@ class FieldCellBloc extends Bloc { @freezed class FieldCellEvent with _$FieldCellEvent { const factory FieldCellEvent.initial() = _InitialCell; - const factory FieldCellEvent.didReceiveFieldUpdate(GridFieldPB field) = + const factory FieldCellEvent.didReceiveFieldUpdate(FieldPB field) = _DidReceiveFieldUpdate; const factory FieldCellEvent.startUpdateWidth(double offset) = _StartUpdateWidth; @@ -74,7 +74,7 @@ class FieldCellEvent with _$FieldCellEvent { class FieldCellState with _$FieldCellState { const factory FieldCellState({ required String gridId, - required GridFieldPB field, + required FieldPB field, required double width, }) = _FieldCellState; 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 8d44edf1ff..aa40e98e92 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 @@ -1,9 +1,12 @@ import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; -import 'field_service.dart'; import 'package:dartz/dartz.dart'; +import 'type_option/type_option_context.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'type_option/type_option_data_controller.dart'; + part 'field_editor_bloc.freezed.dart'; class FieldEditorBloc extends Bloc { @@ -13,7 +16,8 @@ class FieldEditorBloc extends Bloc { required String gridId, required String fieldName, required IFieldTypeOptionLoader loader, - }) : dataController = TypeOptionDataController(gridId: gridId, loader: loader), + }) : dataController = + TypeOptionDataController(gridId: gridId, loader: loader), super(FieldEditorState.initial(gridId, fieldName)) { on( (event, emit) async { @@ -24,13 +28,13 @@ class FieldEditorBloc extends Bloc { add(FieldEditorEvent.didReceiveFieldChanged(field)); } }); - await dataController.loadData(); + await dataController.loadTypeOptionData(); }, updateName: (name) { dataController.fieldName = name; emit(state.copyWith(name: name)); }, - didReceiveFieldChanged: (GridFieldPB field) { + didReceiveFieldChanged: (FieldPB field) { emit(state.copyWith(field: Some(field))); }, ); @@ -48,7 +52,8 @@ class FieldEditorBloc extends Bloc { class FieldEditorEvent with _$FieldEditorEvent { const factory FieldEditorEvent.initial() = _InitialField; const factory FieldEditorEvent.updateName(String name) = _UpdateName; - const factory FieldEditorEvent.didReceiveFieldChanged(GridFieldPB field) = _DidReceiveFieldChanged; + const factory FieldEditorEvent.didReceiveFieldChanged(FieldPB field) = + _DidReceiveFieldChanged; } @freezed @@ -57,7 +62,7 @@ class FieldEditorState with _$FieldEditorState { required String gridId, required String errorText, required String name, - required Option field, + required Option field, }) = _FieldEditorState; factory FieldEditorState.initial( diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart index d3b35e2bfc..b1bb885ae2 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_listener.dart @@ -7,16 +7,18 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -typedef UpdateFieldNotifiedValue = Either; +typedef UpdateFieldNotifiedValue = Either; class SingleFieldListener { final String fieldId; - PublishNotifier? _updateFieldNotifier = PublishNotifier(); + PublishNotifier? _updateFieldNotifier = + PublishNotifier(); GridNotificationListener? _listener; SingleFieldListener({required this.fieldId}); - void start({required void Function(UpdateFieldNotifiedValue) onFieldChanged}) { + void start( + {required void Function(UpdateFieldNotifiedValue) onFieldChanged}) { _updateFieldNotifier?.addPublishListener(onFieldChanged); _listener = GridNotificationListener( objectId: fieldId, @@ -31,7 +33,8 @@ class SingleFieldListener { switch (ty) { case GridNotification.DidUpdateField: result.fold( - (payload) => _updateFieldNotifier?.value = left(GridFieldPB.fromBuffer(payload)), + (payload) => + _updateFieldNotifier?.value = left(FieldPB.fromBuffer(payload)), (error) => _updateFieldNotifier?.value = right(error), ); break; diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart index 9274770b21..816f32fe16 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_service.dart @@ -1,13 +1,10 @@ import 'package:dartz/dartz.dart'; -import 'package:flowy_infra/notifier.dart'; import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:protobuf/protobuf.dart'; part 'field_service.freezed.dart'; /// FieldService consists of lots of event functions. We define the events in the backend(Rust), @@ -21,14 +18,13 @@ class FieldService { FieldService({required this.gridId, required this.fieldId}); Future> moveField(int fromIndex, int toIndex) { - final payload = MoveItemPayloadPB.create() + final payload = MoveFieldPayloadPB.create() ..gridId = gridId - ..itemId = fieldId - ..ty = MoveItemTypePB.MoveField + ..fieldId = fieldId ..fromIndex = fromIndex ..toIndex = toIndex; - return GridEventMoveItem(payload).send(); + return GridEventMoveField(payload).send(); } Future> updateField({ @@ -73,7 +69,7 @@ class FieldService { // Create the field if it does not exist. Otherwise, update the field. static Future> insertField({ required String gridId, - required GridFieldPB field, + required FieldPB field, List? typeOptionData, String? startFieldId, }) { @@ -121,7 +117,7 @@ class FieldService { Future> getFieldTypeOptionData({ required FieldType fieldType, }) { - final payload = GridFieldTypeOptionIdPB.create() + final payload = FieldTypeOptionIdPB.create() ..gridId = gridId ..fieldId = fieldId ..fieldType = fieldType; @@ -138,158 +134,6 @@ class FieldService { class GridFieldCellContext with _$GridFieldCellContext { const factory GridFieldCellContext({ required String gridId, - required GridFieldPB field, + required FieldPB field, }) = _GridFieldCellContext; } - -abstract class IFieldTypeOptionLoader { - String get gridId; - Future> load(); - - Future> switchToField(String fieldId, FieldType fieldType) { - final payload = EditFieldPayloadPB.create() - ..gridId = gridId - ..fieldId = fieldId - ..fieldType = fieldType; - - return GridEventSwitchToField(payload).send(); - } -} - -class NewFieldTypeOptionLoader extends IFieldTypeOptionLoader { - @override - final String gridId; - NewFieldTypeOptionLoader({ - required this.gridId, - }); - - @override - Future> load() { - final payload = CreateFieldPayloadPB.create() - ..gridId = gridId - ..fieldType = FieldType.RichText; - - return GridEventCreateFieldTypeOption(payload).send(); - } -} - -class FieldTypeOptionLoader extends IFieldTypeOptionLoader { - @override - final String gridId; - final GridFieldPB field; - - FieldTypeOptionLoader({ - required this.gridId, - required this.field, - }); - - @override - Future> load() { - final payload = GridFieldTypeOptionIdPB.create() - ..gridId = gridId - ..fieldId = field.id - ..fieldType = field.fieldType; - - return GridEventGetFieldTypeOption(payload).send(); - } -} - -class TypeOptionDataController { - final String gridId; - final IFieldTypeOptionLoader _loader; - - late FieldTypeOptionDataPB _data; - final PublishNotifier _fieldNotifier = PublishNotifier(); - - TypeOptionDataController({ - required this.gridId, - required IFieldTypeOptionLoader loader, - }) : _loader = loader; - - Future> loadData() async { - final result = await _loader.load(); - return result.fold( - (data) { - data.freeze(); - _data = data; - _fieldNotifier.value = data.field_2; - return left(unit); - }, - (err) { - Log.error(err); - return right(err); - }, - ); - } - - GridFieldPB get field => _data.field_2; - - set field(GridFieldPB field) { - _updateData(newField: field); - } - - List get typeOptionData => _data.typeOptionData; - - set fieldName(String name) { - _updateData(newName: name); - } - - set typeOptionData(List typeOptionData) { - _updateData(newTypeOptionData: typeOptionData); - } - - void _updateData({String? newName, GridFieldPB? newField, List? newTypeOptionData}) { - _data = _data.rebuild((rebuildData) { - if (newName != null) { - rebuildData.field_2 = rebuildData.field_2.rebuild((rebuildField) { - rebuildField.name = newName; - }); - } - - if (newField != null) { - rebuildData.field_2 = newField; - } - - if (newTypeOptionData != null) { - rebuildData.typeOptionData = newTypeOptionData; - } - }); - - _fieldNotifier.value = _data.field_2; - - FieldService.insertField( - gridId: gridId, - field: field, - typeOptionData: typeOptionData, - ); - } - - Future switchToField(FieldType newFieldType) { - return _loader.switchToField(field.id, newFieldType).then((result) { - return result.fold( - (fieldTypeOptionData) { - _updateData( - newField: fieldTypeOptionData.field_2, - newTypeOptionData: fieldTypeOptionData.typeOptionData, - ); - }, - (err) { - Log.error(err); - }, - ); - }); - } - - void Function() addFieldListener(void Function(GridFieldPB) callback) { - listener() { - callback(field); - } - - _fieldNotifier.addListener(listener); - return listener; - } - - void removeFieldListener(void Function() listener) { - _fieldNotifier.removeListener(listener); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart index e098f87d86..254a371654 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/field_type_option_edit_bloc.dart @@ -3,11 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; -import 'field_service.dart'; - +import 'type_option/type_option_data_controller.dart'; part 'field_type_option_edit_bloc.freezed.dart'; -class FieldTypeOptionEditBloc extends Bloc { +class FieldTypeOptionEditBloc + extends Bloc { final TypeOptionDataController _dataController; void Function()? _fieldListenFn; @@ -42,16 +42,19 @@ class FieldTypeOptionEditBloc extends Bloc FieldTypeOptionEditState( + factory FieldTypeOptionEditState.initial( + TypeOptionDataController fieldContext) => + FieldTypeOptionEditState( field: fieldContext.field, ); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart index 67bec17be7..61d931e43e 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/grid_listener.dart @@ -7,15 +7,17 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -typedef UpdateFieldNotifiedValue = Either; +typedef UpdateFieldNotifiedValue = Either; class GridFieldsListener { final String gridId; - PublishNotifier? updateFieldsNotifier = PublishNotifier(); + PublishNotifier? updateFieldsNotifier = + PublishNotifier(); GridNotificationListener? _listener; GridFieldsListener({required this.gridId}); - void start({required void Function(UpdateFieldNotifiedValue) onFieldsChanged}) { + void start( + {required void Function(UpdateFieldNotifiedValue) onFieldsChanged}) { updateFieldsNotifier?.addPublishListener(onFieldsChanged); _listener = GridNotificationListener( objectId: gridId, @@ -27,7 +29,8 @@ class GridFieldsListener { switch (ty) { case GridNotification.DidUpdateGridField: result.fold( - (payload) => updateFieldsNotifier?.value = left(GridFieldChangesetPB.fromBuffer(payload)), + (payload) => updateFieldsNotifier?.value = + left(FieldChangesetPB.fromBuffer(payload)), (error) => updateFieldsNotifier?.value = right(error), ); break; diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart index f995a0bad0..e45ed58a2c 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/date_bloc.dart @@ -1,21 +1,13 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_service.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; import 'package:protobuf/protobuf.dart'; + +import 'type_option_context.dart'; part 'date_bloc.freezed.dart'; -typedef DateTypeOptionContext = TypeOptionWidgetContext; - -class DateTypeOptionDataParser extends TypeOptionDataParser { - @override - DateTypeOption fromBuffer(List buffer) { - return DateTypeOption.fromBuffer(buffer); - } -} - class DateTypeOptionBloc extends Bloc { DateTypeOptionBloc({required DateTypeOptionContext typeOptionContext}) @@ -40,7 +32,7 @@ class DateTypeOptionBloc ); } - DateTypeOption _updateTypeOption({ + DateTypeOptionPB _updateTypeOption({ DateFormat? dateFormat, TimeFormat? timeFormat, bool? includeTime, @@ -80,9 +72,9 @@ class DateTypeOptionEvent with _$DateTypeOptionEvent { @freezed class DateTypeOptionState with _$DateTypeOptionState { const factory DateTypeOptionState({ - required DateTypeOption typeOption, + required DateTypeOptionPB typeOption, }) = _DateTypeOptionState; - factory DateTypeOptionState.initial(DateTypeOption typeOption) => + factory DateTypeOptionState.initial(DateTypeOptionPB typeOption) => DateTypeOptionState(typeOption: typeOption); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart index ebc88aaf95..7a54c43c3d 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/multi_select_type_option.dart @@ -1,25 +1,32 @@ -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/multi_select_type_option.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/select_option.pb.dart'; import 'dart:async'; -import 'package:protobuf/protobuf.dart'; import 'select_option_type_option_bloc.dart'; +import 'type_option_context.dart'; import 'type_option_service.dart'; +import 'package:protobuf/protobuf.dart'; -class MultiSelectTypeOptionContext - extends TypeOptionWidgetContext - with SelectOptionTypeOptionAction { - final TypeOptionService service; +class MultiSelectAction with ISelectOptionAction { + final String gridId; + final String fieldId; + final TypeOptionFFIService service; + final MultiSelectTypeOptionContext typeOptionContext; - MultiSelectTypeOptionContext({ - required MultiSelectTypeOptionWidgetDataParser dataBuilder, - required TypeOptionDataController dataController, - }) : service = TypeOptionService( - gridId: dataController.gridId, - fieldId: dataController.field.id, - ), - super(dataParser: dataBuilder, dataController: dataController); + MultiSelectAction({ + required this.gridId, + required this.fieldId, + required this.typeOptionContext, + }) : service = TypeOptionFFIService( + gridId: gridId, + fieldId: fieldId, + ); + + MultiSelectTypeOptionPB get typeOption => typeOptionContext.typeOption; + + set typeOption(MultiSelectTypeOptionPB newTypeOption) { + typeOptionContext.typeOption = newTypeOption; + } @override List Function(SelectOptionPB) get deleteOption { @@ -59,7 +66,7 @@ class MultiSelectTypeOptionContext } @override - List Function(SelectOptionPB) get udpateOption { + List Function(SelectOptionPB) get updateOption { return (SelectOptionPB option) { typeOption.freeze(); typeOption = typeOption.rebuild((typeOption) { @@ -73,11 +80,3 @@ class MultiSelectTypeOptionContext }; } } - -class MultiSelectTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - MultiSelectTypeOption fromBuffer(List buffer) { - return MultiSelectTypeOption.fromBuffer(buffer); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart index 7228adf90e..a475652095 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/number_bloc.dart @@ -1,23 +1,13 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_service.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/format.pbenum.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/number_type_option.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; import 'package:protobuf/protobuf.dart'; +import 'type_option_context.dart'; part 'number_bloc.freezed.dart'; -typedef NumberTypeOptionContext = TypeOptionWidgetContext; - -class NumberTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - NumberTypeOption fromBuffer(List buffer) { - return NumberTypeOption.fromBuffer(buffer); - } -} - class NumberTypeOptionBloc extends Bloc { NumberTypeOptionBloc({required NumberTypeOptionContext typeOptionContext}) @@ -33,7 +23,7 @@ class NumberTypeOptionBloc ); } - NumberTypeOption _updateNumberFormat(NumberFormat format) { + NumberTypeOptionPB _updateNumberFormat(NumberFormat format) { state.typeOption.freeze(); return state.typeOption.rebuild((typeOption) { typeOption.format = format; @@ -55,10 +45,10 @@ class NumberTypeOptionEvent with _$NumberTypeOptionEvent { @freezed class NumberTypeOptionState with _$NumberTypeOptionState { const factory NumberTypeOptionState({ - required NumberTypeOption typeOption, + required NumberTypeOptionPB typeOption, }) = _NumberTypeOptionState; - factory NumberTypeOptionState.initial(NumberTypeOption typeOption) => + factory NumberTypeOptionState.initial(NumberTypeOptionPB typeOption) => NumberTypeOptionState( typeOption: typeOption, ); diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart index b77b9b86cd..d1cdb3b2ec 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/select_option_type_option_bloc.dart @@ -5,16 +5,17 @@ import 'dart:async'; import 'package:dartz/dartz.dart'; part 'select_option_type_option_bloc.freezed.dart'; -abstract class SelectOptionTypeOptionAction { +abstract class ISelectOptionAction { Future> Function(String) get insertOption; List Function(SelectOptionPB) get deleteOption; - List Function(SelectOptionPB) get udpateOption; + List Function(SelectOptionPB) get updateOption; } -class SelectOptionTypeOptionBloc extends Bloc { - final SelectOptionTypeOptionAction typeOptionAction; +class SelectOptionTypeOptionBloc + extends Bloc { + final ISelectOptionAction typeOptionAction; SelectOptionTypeOptionBloc({ required List options, @@ -24,7 +25,8 @@ class SelectOptionTypeOptionBloc extends Bloc options = await typeOptionAction.insertOption(optionName); + final List options = + await typeOptionAction.insertOption(optionName); emit(state.copyWith(options: options)); }, addingOption: () { @@ -34,11 +36,13 @@ class SelectOptionTypeOptionBloc extends Bloc options = typeOptionAction.udpateOption(option); + final List options = + typeOptionAction.updateOption(option); emit(state.copyWith(options: options)); }, deleteOption: (option) { - final List options = typeOptionAction.deleteOption(option); + final List options = + typeOptionAction.deleteOption(option); emit(state.copyWith(options: options)); }, ); @@ -54,11 +58,15 @@ class SelectOptionTypeOptionBloc extends Bloc options, required bool isEditingOption, required Option newOptionName, - }) = _SelectOptionTyepOptionState; + }) = _SelectOptionTypeOptionState; - factory SelectOptionTypeOptionState.initial(List options) => SelectOptionTypeOptionState( + factory SelectOptionTypeOptionState.initial(List options) => + SelectOptionTypeOptionState( options: options, isEditingOption: false, newOptionName: none(), diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart index bdf89b5b78..8ba03c0bc7 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/single_select_type_option.dart @@ -1,25 +1,29 @@ -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/select_option.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/single_select_type_option.pb.dart'; import 'dart:async'; import 'package:protobuf/protobuf.dart'; import 'select_option_type_option_bloc.dart'; +import 'type_option_context.dart'; import 'type_option_service.dart'; -class SingleSelectTypeOptionContext - extends TypeOptionWidgetContext - with SelectOptionTypeOptionAction { - final TypeOptionService service; +class SingleSelectAction with ISelectOptionAction { + final String gridId; + final String fieldId; + final SingleSelectTypeOptionContext typeOptionContext; + final TypeOptionFFIService service; - SingleSelectTypeOptionContext({ - required SingleSelectTypeOptionWidgetDataParser dataBuilder, - required TypeOptionDataController fieldContext, - }) : service = TypeOptionService( - gridId: fieldContext.gridId, - fieldId: fieldContext.field.id, - ), - super(dataParser: dataBuilder, dataController: fieldContext); + SingleSelectAction({ + required this.gridId, + required this.fieldId, + required this.typeOptionContext, + }) : service = TypeOptionFFIService(gridId: gridId, fieldId: fieldId); + + SingleSelectTypeOptionPB get typeOption => typeOptionContext.typeOption; + + set typeOption(SingleSelectTypeOptionPB newTypeOption) { + typeOptionContext.typeOption = newTypeOption; + } @override List Function(SelectOptionPB) get deleteOption { @@ -59,7 +63,7 @@ class SingleSelectTypeOptionContext } @override - List Function(SelectOptionPB) get udpateOption { + List Function(SelectOptionPB) get updateOption { return (SelectOptionPB option) { typeOption.freeze(); typeOption = typeOption.rebuild((typeOption) { @@ -73,11 +77,3 @@ class SingleSelectTypeOptionContext }; } } - -class SingleSelectTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - SingleSelectTypeOptionPB fromBuffer(List buffer) { - return SingleSelectTypeOptionPB.fromBuffer(buffer); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_context.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_context.dart new file mode 100644 index 0000000000..632e333911 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_context.dart @@ -0,0 +1,195 @@ +import 'package:flowy_sdk/dispatch/dispatch.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/multi_select_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/number_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/single_select_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/text_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option.pb.dart'; +import 'package:protobuf/protobuf.dart'; + +import 'type_option_data_controller.dart'; + +abstract class TypeOptionDataParser { + T fromBuffer(List buffer); +} + +// Number +typedef NumberTypeOptionContext = TypeOptionContext; + +class NumberTypeOptionWidgetDataParser + extends TypeOptionDataParser { + @override + NumberTypeOptionPB fromBuffer(List buffer) { + return NumberTypeOptionPB.fromBuffer(buffer); + } +} + +// RichText +typedef RichTextTypeOptionContext = TypeOptionContext; + +class RichTextTypeOptionWidgetDataParser + extends TypeOptionDataParser { + @override + RichTextTypeOptionPB fromBuffer(List buffer) { + return RichTextTypeOptionPB.fromBuffer(buffer); + } +} + +// Checkbox +typedef CheckboxTypeOptionContext = TypeOptionContext; + +class CheckboxTypeOptionWidgetDataParser + extends TypeOptionDataParser { + @override + CheckboxTypeOptionPB fromBuffer(List buffer) { + return CheckboxTypeOptionPB.fromBuffer(buffer); + } +} + +// URL +typedef URLTypeOptionContext = TypeOptionContext; + +class URLTypeOptionWidgetDataParser + extends TypeOptionDataParser { + @override + URLTypeOptionPB fromBuffer(List buffer) { + return URLTypeOptionPB.fromBuffer(buffer); + } +} + +// Date +typedef DateTypeOptionContext = TypeOptionContext; + +class DateTypeOptionDataParser extends TypeOptionDataParser { + @override + DateTypeOptionPB fromBuffer(List buffer) { + return DateTypeOptionPB.fromBuffer(buffer); + } +} + +// SingleSelect +typedef SingleSelectTypeOptionContext + = TypeOptionContext; + +class SingleSelectTypeOptionWidgetDataParser + extends TypeOptionDataParser { + @override + SingleSelectTypeOptionPB fromBuffer(List buffer) { + return SingleSelectTypeOptionPB.fromBuffer(buffer); + } +} + +// Multi-select +typedef MultiSelectTypeOptionContext + = TypeOptionContext; + +class MultiSelectTypeOptionWidgetDataParser + extends TypeOptionDataParser { + @override + MultiSelectTypeOptionPB fromBuffer(List buffer) { + return MultiSelectTypeOptionPB.fromBuffer(buffer); + } +} + +class TypeOptionContext { + T? _typeOptionObject; + final TypeOptionDataParser dataParser; + final TypeOptionDataController _dataController; + + TypeOptionContext({ + required this.dataParser, + required TypeOptionDataController dataController, + }) : _dataController = dataController; + + String get gridId => _dataController.gridId; + + String get fieldId => _dataController.field.id; + + Future loadTypeOptionData({ + required void Function(T) onCompleted, + required void Function(FlowyError) onError, + }) async { + await _dataController.loadTypeOptionData().then((result) { + result.fold((l) => null, (err) => onError(err)); + }); + + onCompleted(typeOption); + } + + T get typeOption { + if (_typeOptionObject != null) { + return _typeOptionObject!; + } + + final T object = _dataController.getTypeOption(dataParser); + _typeOptionObject = object; + return object; + } + + set typeOption(T typeOption) { + _dataController.typeOptionData = typeOption.writeToBuffer(); + _typeOptionObject = typeOption; + } +} + +abstract class TypeOptionFieldDelegate { + void onFieldChanged(void Function(String) callback); + void dispose(); +} + +abstract class IFieldTypeOptionLoader { + String get gridId; + Future> load(); + + Future> switchToField( + String fieldId, FieldType fieldType) { + final payload = EditFieldPayloadPB.create() + ..gridId = gridId + ..fieldId = fieldId + ..fieldType = fieldType; + + return GridEventSwitchToField(payload).send(); + } +} + +class NewFieldTypeOptionLoader extends IFieldTypeOptionLoader { + @override + final String gridId; + NewFieldTypeOptionLoader({ + required this.gridId, + }); + + @override + Future> load() { + final payload = CreateFieldPayloadPB.create() + ..gridId = gridId + ..fieldType = FieldType.RichText; + + return GridEventCreateFieldTypeOption(payload).send(); + } +} + +class FieldTypeOptionLoader extends IFieldTypeOptionLoader { + @override + final String gridId; + final FieldPB field; + + FieldTypeOptionLoader({ + required this.gridId, + required this.field, + }); + + @override + Future> load() { + final payload = FieldTypeOptionIdPB.create() + ..gridId = gridId + ..fieldId = field.id + ..fieldType = field.fieldType; + + return GridEventGetFieldTypeOption(payload).send(); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_data_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_data_controller.dart new file mode 100644 index 0000000000..66f4c35c20 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_data_controller.dart @@ -0,0 +1,123 @@ +import 'package:flowy_infra/notifier.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; +import 'package:dartz/dartz.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:flowy_sdk/log.dart'; + +import 'type_option_context.dart'; + +class TypeOptionDataController { + final String gridId; + final IFieldTypeOptionLoader loader; + late FieldTypeOptionDataPB _data; + final PublishNotifier _fieldNotifier = PublishNotifier(); + + TypeOptionDataController({ + required this.gridId, + required this.loader, + FieldPB? field, + }) { + if (field != null) { + _data = FieldTypeOptionDataPB.create() + ..gridId = gridId + ..field_2 = field; + } + } + + Future> loadTypeOptionData() async { + final result = await loader.load(); + return result.fold( + (data) { + data.freeze(); + _data = data; + _fieldNotifier.value = data.field_2; + return left(unit); + }, + (err) { + Log.error(err); + return right(err); + }, + ); + } + + FieldPB get field { + return _data.field_2; + } + + set field(FieldPB field) { + _updateData(newField: field); + } + + T getTypeOption(TypeOptionDataParser parser) { + return parser.fromBuffer(_data.typeOptionData); + } + + set fieldName(String name) { + _updateData(newName: name); + } + + set typeOptionData(List typeOptionData) { + _updateData(newTypeOptionData: typeOptionData); + } + + void _updateData({ + String? newName, + FieldPB? newField, + List? newTypeOptionData, + }) { + _data = _data.rebuild((rebuildData) { + if (newName != null) { + rebuildData.field_2 = rebuildData.field_2.rebuild((rebuildField) { + rebuildField.name = newName; + }); + } + + if (newField != null) { + rebuildData.field_2 = newField; + } + + if (newTypeOptionData != null) { + rebuildData.typeOptionData = newTypeOptionData; + } + }); + + _fieldNotifier.value = _data.field_2; + + FieldService.insertField( + gridId: gridId, + field: field, + typeOptionData: _data.typeOptionData, + ); + } + + Future switchToField(FieldType newFieldType) { + return loader.switchToField(field.id, newFieldType).then((result) { + return result.fold( + (fieldTypeOptionData) { + _updateData( + newField: fieldTypeOptionData.field_2, + newTypeOptionData: fieldTypeOptionData.typeOptionData, + ); + }, + (err) { + Log.error(err); + }, + ); + }); + } + + void Function() addFieldListener(void Function(FieldPB) callback) { + listener() { + callback(field); + } + + _fieldNotifier.addListener(listener); + return listener; + } + + void removeFieldListener(void Function() listener) { + _fieldNotifier.removeListener(listener); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart index d7873e0c86..5407515f62 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/field/type_option/type_option_service.dart @@ -1,19 +1,14 @@ -import 'dart:typed_data'; - -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/dispatch/dispatch.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/cell_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/select_option.pb.dart'; -import 'package:protobuf/protobuf.dart'; -class TypeOptionService { +class TypeOptionFFIService { final String gridId; final String fieldId; - TypeOptionService({ + TypeOptionFFIService({ required this.gridId, required this.fieldId, }); @@ -29,79 +24,3 @@ class TypeOptionService { return GridEventNewSelectOption(payload).send(); } } - -abstract class TypeOptionDataParser { - T fromBuffer(List buffer); -} - -class TypeOptionWidgetContext { - T? _typeOptionObject; - final TypeOptionDataController _dataController; - final TypeOptionDataParser dataParser; - - TypeOptionWidgetContext({ - required this.dataParser, - required TypeOptionDataController dataController, - }) : _dataController = dataController; - - String get gridId => _dataController.gridId; - - GridFieldPB get field => _dataController.field; - - T get typeOption { - if (_typeOptionObject != null) { - return _typeOptionObject!; - } - - final T object = dataParser.fromBuffer(_dataController.typeOptionData); - _typeOptionObject = object; - return object; - } - - set typeOption(T typeOption) { - _dataController.typeOptionData = typeOption.writeToBuffer(); - _typeOptionObject = typeOption; - } -} - -abstract class TypeOptionFieldDelegate { - void onFieldChanged(void Function(String) callback); - void dispose(); -} - -class TypeOptionContext2 { - final String gridId; - final GridFieldPB field; - final FieldService _fieldService; - T? _data; - final TypeOptionDataParser dataBuilder; - - TypeOptionContext2({ - required this.gridId, - required this.field, - required this.dataBuilder, - Uint8List? data, - }) : _fieldService = FieldService(gridId: gridId, fieldId: field.id) { - if (data != null) { - _data = dataBuilder.fromBuffer(data); - } - } - - Future> typeOptionData() { - if (_data != null) { - return Future(() => left(_data!)); - } - - return _fieldService - .getFieldTypeOptionData(fieldType: field.fieldType) - .then((result) { - return result.fold( - (data) { - _data = dataBuilder.fromBuffer(data.typeOptionData); - return left(_data!); - }, - (err) => right(err), - ); - }); - } -} diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart index 19c4049224..74f23a1b3e 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/grid_bloc.dart @@ -1,40 +1,23 @@ import 'dart:async'; import 'package:dartz/dartz.dart'; import 'package:equatable/equatable.dart'; -import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'block/block_cache.dart'; -import 'grid_service.dart'; -import 'row/row_service.dart'; +import 'grid_data_controller.dart'; +import 'row/row_cache.dart'; import 'dart:collection'; part 'grid_bloc.freezed.dart'; class GridBloc extends Bloc { - final String gridId; - final GridService _gridService; - final GridFieldCache fieldCache; - - // key: the block id - final LinkedHashMap _blocks; - - List get rowInfos { - final List rows = []; - for (var block in _blocks.values) { - rows.addAll(block.rows); - } - return rows; - } + final GridDataController dataController; GridBloc({required ViewPB view}) - : gridId = view.id, - _blocks = LinkedHashMap.identity(), - _gridService = GridService(gridId: view.id), - fieldCache = GridFieldCache(gridId: view.id), + : dataController = GridDataController(view: view), super(GridState.initial(view.id)) { on( (event, emit) async { @@ -44,13 +27,21 @@ class GridBloc extends Bloc { await _loadGrid(emit); }, createRow: () { - _gridService.createRow(); + dataController.createRow(); }, - didReceiveRowUpdate: (newRowInfos, reason) { - emit(state.copyWith(rowInfos: newRowInfos, reason: reason)); + didReceiveGridUpdate: (grid) { + emit(state.copyWith(grid: Some(grid))); }, didReceiveFieldUpdate: (fields) { - emit(state.copyWith(rowInfos: rowInfos, fields: GridFieldEquatable(fields))); + emit(state.copyWith( + fields: GridFieldEquatable(fields), + )); + }, + didReceiveRowUpdate: (newRowInfos, reason) { + emit(state.copyWith( + rowInfos: newRowInfos, + reason: reason, + )); }, ); }, @@ -59,89 +50,63 @@ class GridBloc extends Bloc { @override Future close() async { - await _gridService.closeGrid(); - await fieldCache.dispose(); - - for (final blockCache in _blocks.values) { - blockCache.dispose(); - } + await dataController.dispose(); return super.close(); } GridRowCache? getRowCache(String blockId, String rowId) { - final GridBlockCache? blockCache = _blocks[blockId]; + final GridBlockCache? blockCache = dataController.blocks[blockId]; return blockCache?.rowCache; } void _startListening() { - fieldCache.addListener( - listenWhen: () => !isClosed, - onFields: (fields) => add(GridEvent.didReceiveFieldUpdate(fields)), + dataController.addListener( + onGridChanged: (grid) { + if (!isClosed) { + add(GridEvent.didReceiveGridUpdate(grid)); + } + }, + onRowsChanged: (rowInfos, reason) { + if (!isClosed) { + add(GridEvent.didReceiveRowUpdate(rowInfos, reason)); + } + }, + onFieldsChanged: (fields) { + if (!isClosed) { + add(GridEvent.didReceiveFieldUpdate(fields)); + } + }, ); } Future _loadGrid(Emitter emit) async { - final result = await _gridService.loadGrid(); - return Future( - () => result.fold( - (grid) async { - _initialBlocks(grid.blocks); - await _loadFields(grid, emit); - }, - (err) => emit(state.copyWith(loadingState: GridLoadingState.finish(right(err)))), + final result = await dataController.loadData(); + result.fold( + (grid) => emit( + state.copyWith(loadingState: GridLoadingState.finish(left(unit))), + ), + (err) => emit( + state.copyWith(loadingState: GridLoadingState.finish(right(err))), ), ); } - - Future _loadFields(GridPB grid, Emitter emit) async { - final result = await _gridService.getFields(fieldIds: grid.fields); - return Future( - () => result.fold( - (fields) { - fieldCache.fields = fields.items; - - emit(state.copyWith( - grid: Some(grid), - fields: GridFieldEquatable(fieldCache.fields), - rowInfos: rowInfos, - loadingState: GridLoadingState.finish(left(unit)), - )); - }, - (err) => emit(state.copyWith(loadingState: GridLoadingState.finish(right(err)))), - ), - ); - } - - void _initialBlocks(List blocks) { - for (final block in blocks) { - if (_blocks[block.id] != null) { - Log.warn("Intial duplicate block's cache: ${block.id}"); - return; - } - - final cache = GridBlockCache( - gridId: gridId, - block: block, - fieldCache: fieldCache, - ); - - cache.addListener( - listenWhen: () => !isClosed, - onChangeReason: (reason) => add(GridEvent.didReceiveRowUpdate(rowInfos, reason)), - ); - - _blocks[block.id] = cache; - } - } } @freezed class GridEvent with _$GridEvent { const factory GridEvent.initial() = InitialGrid; const factory GridEvent.createRow() = _CreateRow; - const factory GridEvent.didReceiveRowUpdate(List rows, GridRowChangeReason listState) = - _DidReceiveRowUpdate; - const factory GridEvent.didReceiveFieldUpdate(List fields) = _DidReceiveFieldUpdate; + const factory GridEvent.didReceiveRowUpdate( + List rows, + RowsChangedReason listState, + ) = _DidReceiveRowUpdate; + const factory GridEvent.didReceiveFieldUpdate( + UnmodifiableListView fields, + ) = _DidReceiveFieldUpdate; + + const factory GridEvent.didReceiveGridUpdate( + GridPB grid, + ) = _DidReceiveGridUpdate; } @freezed @@ -150,13 +115,13 @@ class GridState with _$GridState { required String gridId, required Option grid, required GridFieldEquatable fields, - required List rowInfos, + required List rowInfos, required GridLoadingState loadingState, - required GridRowChangeReason reason, + required RowsChangedReason reason, }) = _GridState; factory GridState.initial(String gridId) => GridState( - fields: const GridFieldEquatable([]), + fields: GridFieldEquatable(UnmodifiableListView([])), rowInfos: [], grid: none(), gridId: gridId, @@ -168,20 +133,29 @@ class GridState with _$GridState { @freezed class GridLoadingState with _$GridLoadingState { const factory GridLoadingState.loading() = _Loading; - const factory GridLoadingState.finish(Either successOrFail) = _Finish; + const factory GridLoadingState.finish( + Either successOrFail) = _Finish; } class GridFieldEquatable extends Equatable { - final List _fields; - const GridFieldEquatable(List fields) : _fields = fields; + final UnmodifiableListView _fields; + const GridFieldEquatable( + UnmodifiableListView fields, + ) : _fields = fields; @override List get props { + if (_fields.isEmpty) { + return []; + } + return [ _fields.length, - _fields.map((field) => field.width).reduce((value, element) => value + element), + _fields + .map((field) => field.width) + .reduce((value, element) => value + element), ]; } - UnmodifiableListView get value => UnmodifiableListView(_fields); + UnmodifiableListView get value => UnmodifiableListView(_fields); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart new file mode 100644 index 0000000000..f11db25167 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart @@ -0,0 +1,130 @@ +import 'dart:collection'; + +import 'package:flowy_sdk/log.dart'; +import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; +import 'dart:async'; +import 'package:dartz/dartz.dart'; +import 'block/block_cache.dart'; +import 'field/field_cache.dart'; +import 'prelude.dart'; +import 'row/row_cache.dart'; + +typedef OnFieldsChanged = void Function(UnmodifiableListView); +typedef OnGridChanged = void Function(GridPB); + +typedef OnRowsChanged = void Function( + List rowInfos, + RowsChangedReason, +); +typedef ListenOnRowChangedCondition = bool Function(); + +class GridDataController { + final String gridId; + final GridFFIService _gridFFIService; + final GridFieldCache fieldCache; + + // key: the block id + final LinkedHashMap _blocks; + UnmodifiableMapView get blocks => + UnmodifiableMapView(_blocks); + + OnRowsChanged? _onRowChanged; + OnFieldsChanged? _onFieldsChanged; + OnGridChanged? _onGridChanged; + + List get rowInfos { + final List rows = []; + for (var block in _blocks.values) { + rows.addAll(block.rows); + } + return rows; + } + + GridDataController({required ViewPB view}) + : gridId = view.id, + _blocks = LinkedHashMap.new(), + _gridFFIService = GridFFIService(gridId: view.id), + fieldCache = GridFieldCache(gridId: view.id); + + void addListener({ + required OnGridChanged onGridChanged, + required OnRowsChanged onRowsChanged, + required OnFieldsChanged onFieldsChanged, + }) { + _onGridChanged = onGridChanged; + _onRowChanged = onRowsChanged; + _onFieldsChanged = onFieldsChanged; + + fieldCache.addListener(onFields: (fields) { + _onFieldsChanged?.call(UnmodifiableListView(fields)); + }); + } + + Future> loadData() async { + final result = await _gridFFIService.loadGrid(); + return Future( + () => result.fold( + (grid) async { + _initialBlocks(grid.blocks); + _onGridChanged?.call(grid); + return await _loadFields(grid); + }, + (err) => right(err), + ), + ); + } + + void createRow() { + _gridFFIService.createRow(); + } + + Future dispose() async { + await _gridFFIService.closeGrid(); + await fieldCache.dispose(); + + for (final blockCache in _blocks.values) { + blockCache.dispose(); + } + } + + void _initialBlocks(List blocks) { + for (final block in blocks) { + if (_blocks[block.id] != null) { + Log.warn("Initial duplicate block's cache: ${block.id}"); + return; + } + + final cache = GridBlockCache( + gridId: gridId, + block: block, + fieldCache: fieldCache, + ); + + cache.addListener( + onRowsChanged: (reason) { + _onRowChanged?.call(rowInfos, reason); + }, + ); + + _blocks[block.id] = cache; + } + } + + Future> _loadFields(GridPB grid) async { + final result = await _gridFFIService.getFields(fieldIds: grid.fields); + return Future( + () => result.fold( + (fields) { + fieldCache.fields = fields.items; + _onFieldsChanged?.call(UnmodifiableListView(fieldCache.fields)); + return left(unit); + }, + (err) => right(err), + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart index 06a6b791d8..125bd8d652 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/grid_header_bloc.dart @@ -4,7 +4,8 @@ import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; -import 'grid_service.dart'; + +import 'field/field_cache.dart'; part 'grid_header_bloc.freezed.dart'; @@ -35,7 +36,7 @@ class GridHeaderBloc extends Bloc { Future _moveField( _MoveField value, Emitter emit) async { - final fields = List.from(state.fields); + final fields = List.from(state.fields); fields.insert(value.toIndex, fields.removeAt(value.fromIndex)); emit(state.copyWith(fields: fields)); @@ -63,19 +64,19 @@ class GridHeaderBloc extends Bloc { @freezed class GridHeaderEvent with _$GridHeaderEvent { const factory GridHeaderEvent.initial() = _InitialHeader; - const factory GridHeaderEvent.didReceiveFieldUpdate( - List fields) = _DidReceiveFieldUpdate; + const factory GridHeaderEvent.didReceiveFieldUpdate(List fields) = + _DidReceiveFieldUpdate; const factory GridHeaderEvent.moveField( - GridFieldPB field, int fromIndex, int toIndex) = _MoveField; + FieldPB field, int fromIndex, int toIndex) = _MoveField; } @freezed class GridHeaderState with _$GridHeaderState { - const factory GridHeaderState({required List fields}) = + const factory GridHeaderState({required List fields}) = _GridHeaderState; - factory GridHeaderState.initial(List fields) { - // final List newFields = List.from(fields); + factory GridHeaderState.initial(List fields) { + // final List newFields = List.from(fields); // newFields.retainWhere((field) => field.visibility); return GridHeaderState(fields: fields); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart b/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart index dff03f636e..8c46ce18b2 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart @@ -1,21 +1,17 @@ -import 'dart:collection'; - -import 'package:app_flowy/plugins/grid/application/field/grid_listener.dart'; import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/board_card.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/group.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; -import 'package:flutter/foundation.dart'; -import 'row/row_service.dart'; -class GridService { +class GridFFIService { final String gridId; - GridService({ + GridFFIService({ required this.gridId, }); @@ -26,18 +22,24 @@ class GridService { return GridEventGetGrid(payload).send(); } - Future> createRow( - {Option? startRowId}) { - CreateRowPayloadPB payload = CreateRowPayloadPB.create()..gridId = gridId; + Future> createRow({Option? startRowId}) { + var payload = CreateTableRowPayloadPB.create()..gridId = gridId; startRowId?.fold(() => null, (id) => payload.startRowId = id); - return GridEventCreateRow(payload).send(); + return GridEventCreateTableRow(payload).send(); } - Future> getFields( - {required List fieldIds}) { + Future> createBoardCard(String groupId) { + CreateBoardCardPayloadPB payload = CreateBoardCardPayloadPB.create() + ..gridId = gridId + ..groupId = groupId; + return GridEventCreateBoardCard(payload).send(); + } + + Future> getFields( + {required List fieldIds}) { final payload = QueryFieldPayloadPB.create() ..gridId = gridId - ..fieldIds = RepeatedGridFieldIdPB(items: fieldIds); + ..fieldIds = RepeatedFieldIdPB(items: fieldIds); return GridEventGetFields(payload).send(); } @@ -45,188 +47,9 @@ class GridService { final request = ViewIdPB(value: gridId); return FolderEventCloseView(request).send(); } -} -class FieldsNotifier extends ChangeNotifier { - List _fields = []; - - set fields(List fields) { - _fields = fields; - notifyListeners(); - } - - List get fields => _fields; -} - -typedef FieldChangesetCallback = void Function(GridFieldChangesetPB); -typedef FieldsCallback = void Function(List); - -class GridFieldCache { - final String gridId; - final GridFieldsListener _fieldListener; - FieldsNotifier? _fieldNotifier = FieldsNotifier(); - final Map _fieldsCallbackMap = {}; - final Map - _changesetCallbackMap = {}; - - GridFieldCache({required this.gridId}) - : _fieldListener = GridFieldsListener(gridId: gridId) { - _fieldListener.start(onFieldsChanged: (result) { - result.fold( - (changeset) { - _deleteFields(changeset.deletedFields); - _insertFields(changeset.insertedFields); - _updateFields(changeset.updatedFields); - for (final listener in _changesetCallbackMap.values) { - listener(changeset); - } - }, - (err) => Log.error(err), - ); - }); - } - - Future dispose() async { - await _fieldListener.stop(); - _fieldNotifier?.dispose(); - _fieldNotifier = null; - } - - UnmodifiableListView get unmodifiableFields => - UnmodifiableListView(_fieldNotifier?.fields ?? []); - - List get fields => [..._fieldNotifier?.fields ?? []]; - - set fields(List fields) { - _fieldNotifier?.fields = [...fields]; - } - - void addListener({ - FieldsCallback? onFields, - FieldChangesetCallback? onChangeset, - bool Function()? listenWhen, - }) { - if (onChangeset != null) { - fn(c) { - if (listenWhen != null && listenWhen() == false) { - return; - } - onChangeset(c); - } - - _changesetCallbackMap[onChangeset] = fn; - } - - if (onFields != null) { - fn() { - if (listenWhen != null && listenWhen() == false) { - return; - } - onFields(fields); - } - - _fieldsCallbackMap[onFields] = fn; - _fieldNotifier?.addListener(fn); - } - } - - void removeListener({ - FieldsCallback? onFieldsListener, - FieldChangesetCallback? onChangesetListener, - }) { - if (onFieldsListener != null) { - final fn = _fieldsCallbackMap.remove(onFieldsListener); - if (fn != null) { - _fieldNotifier?.removeListener(fn); - } - } - - if (onChangesetListener != null) { - _changesetCallbackMap.remove(onChangesetListener); - } - } - - void _deleteFields(List deletedFields) { - if (deletedFields.isEmpty) { - return; - } - final List newFields = fields; - final Map deletedFieldMap = { - for (var fieldOrder in deletedFields) fieldOrder.fieldId: fieldOrder - }; - - newFields.retainWhere((field) => (deletedFieldMap[field.id] == null)); - _fieldNotifier?.fields = newFields; - } - - void _insertFields(List insertedFields) { - if (insertedFields.isEmpty) { - return; - } - final List newFields = fields; - for (final indexField in insertedFields) { - if (newFields.length > indexField.index) { - newFields.insert(indexField.index, indexField.field_1); - } else { - newFields.add(indexField.field_1); - } - } - _fieldNotifier?.fields = newFields; - } - - void _updateFields(List updatedFields) { - if (updatedFields.isEmpty) { - return; - } - final List newFields = fields; - for (final updatedField in updatedFields) { - final index = - newFields.indexWhere((field) => field.id == updatedField.id); - if (index != -1) { - newFields.removeAt(index); - newFields.insert(index, updatedField); - } - } - _fieldNotifier?.fields = newFields; - } -} - -class GridRowCacheFieldNotifierImpl extends GridRowCacheFieldNotifier { - final GridFieldCache _cache; - FieldChangesetCallback? _onChangesetFn; - FieldsCallback? _onFieldFn; - GridRowCacheFieldNotifierImpl(GridFieldCache cache) : _cache = cache; - - @override - UnmodifiableListView get fields => _cache.unmodifiableFields; - - @override - void onFieldsChanged(VoidCallback callback) { - _onFieldFn = (_) => callback(); - _cache.addListener(onFields: _onFieldFn); - } - - @override - void onFieldChanged(void Function(GridFieldPB) callback) { - _onChangesetFn = (GridFieldChangesetPB changeset) { - for (final updatedField in changeset.updatedFields) { - callback(updatedField); - } - }; - - _cache.addListener(onChangeset: _onChangesetFn); - } - - @override - void dispose() { - if (_onFieldFn != null) { - _cache.removeListener(onFieldsListener: _onFieldFn!); - _onFieldFn = null; - } - - if (_onChangesetFn != null) { - _cache.removeListener(onChangesetListener: _onChangesetFn!); - _onChangesetFn = null; - } + Future> loadGroups() { + final payload = GridIdPB(value: gridId); + return GridEventGetGroup(payload).send(); } } diff --git a/frontend/app_flowy/lib/plugins/grid/application/prelude.dart b/frontend/app_flowy/lib/plugins/grid/application/prelude.dart index 2a19ca1134..7585c55e49 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/prelude.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/prelude.dart @@ -4,13 +4,13 @@ export 'row/row_service.dart'; export 'grid_service.dart'; export 'grid_header_bloc.dart'; -// GridFieldPB +// FieldPB export 'field/field_service.dart'; export 'field/field_action_sheet_bloc.dart'; export 'field/field_editor_bloc.dart'; export 'field/field_type_option_edit_bloc.dart'; -// GridFieldPB Type Option +// FieldPB Type Option export 'field/type_option/date_bloc.dart'; export 'field/type_option/number_bloc.dart'; export 'field/type_option/single_select_type_option.dart'; diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart index 7881e485cb..fa81e6cb23 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_action_sheet_bloc.dart @@ -6,28 +6,30 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; import 'package:dartz/dartz.dart'; +import 'row_cache.dart'; + part 'row_action_sheet_bloc.freezed.dart'; class RowActionSheetBloc extends Bloc { - final RowService _rowService; + final RowFFIService _rowService; - RowActionSheetBloc({required GridRowInfo rowData}) - : _rowService = RowService( - gridId: rowData.gridId, - blockId: rowData.blockId, - rowId: rowData.id, + RowActionSheetBloc({required RowInfo rowInfo}) + : _rowService = RowFFIService( + gridId: rowInfo.gridId, + blockId: rowInfo.rowPB.blockId, ), - super(RowActionSheetState.initial(rowData)) { + super(RowActionSheetState.initial(rowInfo)) { on( (event, emit) async { await event.map( deleteRow: (_DeleteRow value) async { - final result = await _rowService.deleteRow(); + final result = await _rowService.deleteRow(state.rowData.rowPB.id); logResult(result); }, duplicateRow: (_DuplicateRow value) async { - final result = await _rowService.duplicateRow(); + final result = + await _rowService.duplicateRow(state.rowData.rowPB.id); logResult(result); }, ); @@ -54,11 +56,10 @@ class RowActionSheetEvent with _$RowActionSheetEvent { @freezed class RowActionSheetState with _$RowActionSheetState { const factory RowActionSheetState({ - required GridRowInfo rowData, + required RowInfo rowData, }) = _RowActionSheetState; - factory RowActionSheetState.initial(GridRowInfo rowData) => - RowActionSheetState( + factory RowActionSheetState.initial(RowInfo rowData) => RowActionSheetState( rowData: rowData, ); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart index 3b755d1524..bea25d8008 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_bloc.dart @@ -5,25 +5,25 @@ import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; +import 'row_cache.dart'; +import 'row_data_controller.dart'; import 'row_service.dart'; part 'row_bloc.freezed.dart'; class RowBloc extends Bloc { - final RowService _rowService; - final GridRowCache _rowCache; - void Function()? _rowListenFn; + final RowFFIService _rowService; + final GridRowDataController _dataController; RowBloc({ - required GridRowInfo rowInfo, - required GridRowCache rowCache, - }) : _rowService = RowService( + required RowInfo rowInfo, + required GridRowDataController dataController, + }) : _rowService = RowFFIService( gridId: rowInfo.gridId, - blockId: rowInfo.blockId, - rowId: rowInfo.id, + blockId: rowInfo.rowPB.blockId, ), - _rowCache = rowCache, - super(RowState.initial(rowInfo, rowCache.loadGridCells(rowInfo.id))) { + _dataController = dataController, + super(RowState.initial(rowInfo, dataController.loadData())) { on( (event, emit) async { await event.map( @@ -31,16 +31,15 @@ class RowBloc extends Bloc { await _startListening(); }, createRow: (_CreateRow value) { - _rowService.createRow(); + _rowService.createRow(rowInfo.rowPB.id); }, - didReceiveCellDatas: (_DidReceiveCellDatas value) async { - final fields = value.gridCellMap.values + didReceiveCells: (_DidReceiveCells value) async { + final cells = value.gridCellMap.values .map((e) => GridCellEquatable(e.field)) .toList(); - final snapshots = UnmodifiableListView(fields); emit(state.copyWith( gridCellMap: value.gridCellMap, - snapshots: snapshots, + cells: UnmodifiableListView(cells), changeReason: value.reason, )); }, @@ -51,19 +50,17 @@ class RowBloc extends Bloc { @override Future close() async { - if (_rowListenFn != null) { - _rowCache.removeRowListener(_rowListenFn!); - } - + _dataController.dispose(); return super.close(); } Future _startListening() async { - _rowListenFn = _rowCache.addListener( - rowId: state.rowInfo.id, - onCellUpdated: (cellDatas, reason) => - add(RowEvent.didReceiveCellDatas(cellDatas, reason)), - listenWhen: () => !isClosed, + _dataController.addListener( + onRowChanged: (cells, reason) { + if (!isClosed) { + add(RowEvent.didReceiveCells(cells, reason)); + } + }, ); } } @@ -72,33 +69,33 @@ class RowBloc extends Bloc { class RowEvent with _$RowEvent { const factory RowEvent.initial() = _InitialRow; const factory RowEvent.createRow() = _CreateRow; - const factory RowEvent.didReceiveCellDatas( - GridCellMap gridCellMap, GridRowChangeReason reason) = - _DidReceiveCellDatas; + const factory RowEvent.didReceiveCells( + GridCellMap gridCellMap, RowsChangedReason reason) = _DidReceiveCells; } @freezed class RowState with _$RowState { const factory RowState({ - required GridRowInfo rowInfo, + required RowInfo rowInfo, required GridCellMap gridCellMap, - required UnmodifiableListView snapshots, - GridRowChangeReason? changeReason, + required UnmodifiableListView cells, + RowsChangedReason? changeReason, }) = _RowState; - factory RowState.initial(GridRowInfo rowInfo, GridCellMap cellDataMap) => + factory RowState.initial(RowInfo rowInfo, GridCellMap cellDataMap) => RowState( rowInfo: rowInfo, gridCellMap: cellDataMap, - snapshots: UnmodifiableListView( - cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList()), + cells: UnmodifiableListView( + cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList(), + ), ); } class GridCellEquatable extends Equatable { - final GridFieldPB _field; + final FieldPB _field; - const GridCellEquatable(GridFieldPB field) : _field = field; + const GridCellEquatable(FieldPB field) : _field = field; @override List get props => [ diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart new file mode 100644 index 0000000000..68c8b6f519 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart @@ -0,0 +1,328 @@ +import 'dart:collection'; +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flowy_sdk/dispatch/dispatch.dart'; +import 'package:flowy_sdk/log.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'row_cache.freezed.dart'; + +typedef RowUpdateCallback = void Function(); + +abstract class IGridRowFieldNotifier { + UnmodifiableListView get fields; + void onRowFieldsChanged(VoidCallback callback); + void onRowFieldChanged(void Function(FieldPB) callback); + void onRowDispose(); +} + +/// Cache the rows in memory +/// Insert / delete / update row +/// +/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information. + +class GridRowCache { + final String gridId; + final BlockPB block; + + /// _rows containers the current block's rows + /// Use List to reverse the order of the GridRow. + List _rowInfos = []; + + /// Use Map for faster access the raw row data. + final HashMap _rowByRowId; + + final GridCellCache _cellCache; + final IGridRowFieldNotifier _fieldNotifier; + final _RowChangesetNotifier _rowChangeReasonNotifier; + + UnmodifiableListView get rows => UnmodifiableListView(_rowInfos); + GridCellCache get cellCache => _cellCache; + + GridRowCache({ + required this.gridId, + required this.block, + required IGridRowFieldNotifier notifier, + }) : _cellCache = GridCellCache(gridId: gridId), + _rowByRowId = HashMap(), + _rowChangeReasonNotifier = _RowChangesetNotifier(), + _fieldNotifier = notifier { + // + notifier.onRowFieldsChanged(() => _rowChangeReasonNotifier + .receive(const RowsChangedReason.fieldDidChange())); + notifier.onRowFieldChanged((field) => _cellCache.remove(field.id)); + _rowInfos = block.rows.map((rowPB) => buildGridRow(rowPB)).toList(); + } + + Future dispose() async { + _fieldNotifier.onRowDispose(); + _rowChangeReasonNotifier.dispose(); + await _cellCache.dispose(); + } + + void applyChangesets(List changesets) { + for (final changeset in changesets) { + _deleteRows(changeset.deletedRows); + _insertRows(changeset.insertedRows); + _updateRows(changeset.updatedRows); + _hideRows(changeset.hideRows); + _showRows(changeset.visibleRows); + } + } + + void _deleteRows(List deletedRows) { + if (deletedRows.isEmpty) { + return; + } + + final List newRows = []; + final DeletedIndexs deletedIndex = []; + final Map deletedRowByRowId = { + for (var rowId in deletedRows) rowId: rowId + }; + + _rowInfos.asMap().forEach((index, RowInfo rowInfo) { + if (deletedRowByRowId[rowInfo.rowPB.id] == null) { + newRows.add(rowInfo); + } else { + _rowByRowId.remove(rowInfo.rowPB.id); + deletedIndex.add(DeletedIndex(index: index, row: rowInfo)); + } + }); + _rowInfos = newRows; + _rowChangeReasonNotifier.receive(RowsChangedReason.delete(deletedIndex)); + } + + void _insertRows(List insertRows) { + if (insertRows.isEmpty) { + return; + } + + InsertedIndexs insertIndexs = []; + for (final InsertedRowPB insertRow in insertRows) { + final insertIndex = InsertedIndex( + index: insertRow.index, + rowId: insertRow.row.id, + ); + insertIndexs.add(insertIndex); + _rowInfos.insert( + insertRow.index, + (buildGridRow(insertRow.row)), + ); + } + + _rowChangeReasonNotifier.receive(RowsChangedReason.insert(insertIndexs)); + } + + void _updateRows(List updatedRows) { + if (updatedRows.isEmpty) { + return; + } + + final UpdatedIndexs updatedIndexs = UpdatedIndexs(); + for (final RowPB updatedRow in updatedRows) { + final rowId = updatedRow.id; + final index = _rowInfos.indexWhere( + (rowInfo) => rowInfo.rowPB.id == rowId, + ); + if (index != -1) { + _rowByRowId[rowId] = updatedRow; + + _rowInfos.removeAt(index); + _rowInfos.insert(index, buildGridRow(updatedRow)); + updatedIndexs[rowId] = UpdatedIndex(index: index, rowId: rowId); + } + } + + _rowChangeReasonNotifier.receive(RowsChangedReason.update(updatedIndexs)); + } + + void _hideRows(List hideRows) {} + + void _showRows(List visibleRows) {} + + void onRowsChanged( + void Function(RowsChangedReason) onRowChanged, + ) { + _rowChangeReasonNotifier.addListener(() { + onRowChanged(_rowChangeReasonNotifier.reason); + }); + } + + RowUpdateCallback addListener({ + required String rowId, + void Function(GridCellMap, RowsChangedReason)? onCellUpdated, + bool Function()? listenWhen, + }) { + listenerHandler() async { + if (listenWhen != null && listenWhen() == false) { + return; + } + + notifyUpdate() { + if (onCellUpdated != null) { + final row = _rowByRowId[rowId]; + if (row != null) { + final GridCellMap cellDataMap = _makeGridCells(rowId, row); + onCellUpdated(cellDataMap, _rowChangeReasonNotifier.reason); + } + } + } + + _rowChangeReasonNotifier.reason.whenOrNull( + update: (indexs) { + if (indexs[rowId] != null) notifyUpdate(); + }, + fieldDidChange: () => notifyUpdate(), + ); + } + + _rowChangeReasonNotifier.addListener(listenerHandler); + return listenerHandler; + } + + void removeRowListener(VoidCallback callback) { + _rowChangeReasonNotifier.removeListener(callback); + } + + GridCellMap loadGridCells(String rowId) { + final RowPB? data = _rowByRowId[rowId]; + if (data == null) { + _loadRow(rowId); + } + return _makeGridCells(rowId, data); + } + + Future _loadRow(String rowId) async { + final payload = RowIdPB.create() + ..gridId = gridId + ..blockId = block.id + ..rowId = rowId; + + final result = await GridEventGetRow(payload).send(); + result.fold( + (optionRow) => _refreshRow(optionRow), + (err) => Log.error(err), + ); + } + + GridCellMap _makeGridCells(String rowId, RowPB? row) { + var cellDataMap = GridCellMap.new(); + for (final field in _fieldNotifier.fields) { + if (field.visibility) { + cellDataMap[field.id] = GridCellIdentifier( + rowId: rowId, + gridId: gridId, + field: field, + ); + } + } + return cellDataMap; + } + + void _refreshRow(OptionalRowPB optionRow) { + if (!optionRow.hasRow()) { + return; + } + final updatedRow = optionRow.row; + updatedRow.freeze(); + + _rowByRowId[updatedRow.id] = updatedRow; + final index = + _rowInfos.indexWhere((rowInfo) => rowInfo.rowPB.id == updatedRow.id); + if (index != -1) { + // update the corresponding row in _rows if they are not the same + if (_rowInfos[index].rowPB != updatedRow) { + final rowInfo = _rowInfos.removeAt(index).copyWith(rowPB: updatedRow); + _rowInfos.insert(index, rowInfo); + + // Calculate the update index + final UpdatedIndexs updatedIndexs = UpdatedIndexs(); + updatedIndexs[rowInfo.rowPB.id] = UpdatedIndex( + index: index, + rowId: rowInfo.rowPB.id, + ); + + // + _rowChangeReasonNotifier + .receive(RowsChangedReason.update(updatedIndexs)); + } + } + } + + RowInfo buildGridRow(RowPB rowPB) { + return RowInfo( + gridId: gridId, + fields: _fieldNotifier.fields, + rowPB: rowPB, + ); + } +} + +class _RowChangesetNotifier extends ChangeNotifier { + RowsChangedReason reason = const InitialListState(); + + _RowChangesetNotifier(); + + void receive(RowsChangedReason newReason) { + reason = newReason; + reason.map( + insert: (_) => notifyListeners(), + delete: (_) => notifyListeners(), + update: (_) => notifyListeners(), + fieldDidChange: (_) => notifyListeners(), + initial: (_) {}, + ); + } +} + +@freezed +class RowInfo with _$RowInfo { + const factory RowInfo({ + required String gridId, + required UnmodifiableListView fields, + required RowPB rowPB, + }) = _RowInfo; +} + +typedef InsertedIndexs = List; +typedef DeletedIndexs = List; +typedef UpdatedIndexs = LinkedHashMap; + +@freezed +class RowsChangedReason with _$RowsChangedReason { + const factory RowsChangedReason.insert(InsertedIndexs items) = _Insert; + const factory RowsChangedReason.delete(DeletedIndexs items) = _Delete; + const factory RowsChangedReason.update(UpdatedIndexs indexs) = _Update; + const factory RowsChangedReason.fieldDidChange() = _FieldDidChange; + const factory RowsChangedReason.initial() = InitialListState; +} + +class InsertedIndex { + final int index; + final String rowId; + InsertedIndex({ + required this.index, + required this.rowId, + }); +} + +class DeletedIndex { + final int index; + final RowInfo row; + DeletedIndex({ + required this.index, + required this.row, + }); +} + +class UpdatedIndex { + final int index; + final String rowId; + UpdatedIndex({ + required this.index, + required this.rowId, + }); +} diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart new file mode 100644 index 0000000000..b4618b397a --- /dev/null +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_data_controller.dart @@ -0,0 +1,49 @@ +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_field_notifier.dart'; +import 'package:flutter/material.dart'; +import '../../presentation/widgets/cell/cell_builder.dart'; +import '../cell/cell_service/cell_service.dart'; +import '../field/field_cache.dart'; +import 'row_cache.dart'; + +typedef OnRowChanged = void Function(GridCellMap, RowsChangedReason); + +class GridRowDataController extends GridCellBuilderDelegate { + final RowInfo rowInfo; + final List _onRowChangedListeners = []; + final GridFieldCache _fieldCache; + final GridRowCache _rowCache; + + GridRowDataController({ + required this.rowInfo, + required GridFieldCache fieldCache, + required GridRowCache rowCache, + }) : _fieldCache = fieldCache, + _rowCache = rowCache; + + GridCellMap loadData() { + return _rowCache.loadGridCells(rowInfo.rowPB.id); + } + + void addListener({OnRowChanged? onRowChanged}) { + _onRowChangedListeners.add(_rowCache.addListener( + rowId: rowInfo.rowPB.id, + onCellUpdated: onRowChanged, + )); + } + + void dispose() { + for (final fn in _onRowChangedListeners) { + _rowCache.removeRowListener(fn); + } + } + + // GridCellBuilderDelegate implementation + @override + GridCellFieldNotifier buildFieldNotifier() { + return GridCellFieldNotifier( + notifier: GridCellFieldNotifierImpl(_fieldCache)); + } + + @override + GridCellCache get cellCache => _rowCache.cellCache; +} 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 900b56f9a5..6f59682f4d 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 @@ -2,26 +2,24 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_servic import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; -import 'row_service.dart'; - +import 'row_data_controller.dart'; part 'row_detail_bloc.freezed.dart'; class RowDetailBloc extends Bloc { - final GridRowInfo rowInfo; - final GridRowCache _rowCache; - void Function()? _rowListenFn; + final GridRowDataController dataController; RowDetailBloc({ - required this.rowInfo, - required GridRowCache rowCache, - }) : _rowCache = rowCache, - super(RowDetailState.initial()) { + required this.dataController, + }) : super(RowDetailState.initial()) { on( (event, emit) async { await event.map( initial: (_Initial value) async { await _startListening(); - _loadCellData(); + final cells = dataController.loadData(); + if (!isClosed) { + add(RowDetailEvent.didReceiveCellDatas(cells.values.toList())); + } }, didReceiveCellDatas: (_DidReceiveCellDatas value) { emit(state.copyWith(gridCells: value.gridCells)); @@ -33,27 +31,19 @@ class RowDetailBloc extends Bloc { @override Future close() async { - if (_rowListenFn != null) { - _rowCache.removeRowListener(_rowListenFn!); - } + dataController.dispose(); return super.close(); } Future _startListening() async { - _rowListenFn = _rowCache.addListener( - rowId: rowInfo.id, - onCellUpdated: (cellDatas, reason) => - add(RowDetailEvent.didReceiveCellDatas(cellDatas.values.toList())), - listenWhen: () => !isClosed, + dataController.addListener( + onRowChanged: (cells, reason) { + if (!isClosed) { + add(RowDetailEvent.didReceiveCellDatas(cells.values.toList())); + } + }, ); } - - Future _loadCellData() async { - final cellDataMap = _rowCache.loadGridCells(rowInfo.id); - if (!isClosed) { - add(RowDetailEvent.didReceiveCellDatas(cellDataMap.values.toList())); - } - } } @freezed diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart index 9aa829d617..1df24c50c2 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_listener.dart @@ -8,12 +8,13 @@ import 'dart:typed_data'; import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; -typedef UpdateRowNotifiedValue = Either; -typedef UpdateFieldNotifiedValue = Either, FlowyError>; +typedef UpdateRowNotifiedValue = Either; +typedef UpdateFieldNotifiedValue = Either, FlowyError>; class RowListener { final String rowId; - PublishNotifier? updateRowNotifier = PublishNotifier(); + PublishNotifier? updateRowNotifier = + PublishNotifier(); GridNotificationListener? _listener; RowListener({required this.rowId}); @@ -26,7 +27,8 @@ class RowListener { switch (ty) { case GridNotification.DidUpdateRow: result.fold( - (payload) => updateRowNotifier?.value = left(GridRowPB.fromBuffer(payload)), + (payload) => + updateRowNotifier?.value = left(RowPB.fromBuffer(payload)), (error) => updateRowNotifier?.value = right(error), ); break; diff --git a/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart b/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart index 4fd18a1fdd..a18c0c8e75 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_service.dart @@ -1,314 +1,29 @@ -import 'dart:collection'; -import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/dispatch/dispatch.dart'; -import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/block_entities.pb.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/grid_entities.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/row_entities.pb.dart'; -import 'package:flutter/foundation.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -part 'row_service.freezed.dart'; -typedef RowUpdateCallback = void Function(); - -abstract class GridRowCacheFieldNotifier { - UnmodifiableListView get fields; - void onFieldsChanged(VoidCallback callback); - void onFieldChanged(void Function(GridFieldPB) callback); - void dispose(); -} - -/// Cache the rows in memory -/// Insert / delete / update row -/// -/// Read https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/frontend/grid for more information. - -class GridRowCache { - final String gridId; - final GridBlockPB block; - - /// _rows containers the current block's rows - /// Use List to reverse the order of the GridRow. - List _rowInfos = []; - - /// Use Map for faster access the raw row data. - final HashMap _rowByRowId; - - final GridCellCache _cellCache; - final GridRowCacheFieldNotifier _fieldNotifier; - final _GridRowChangesetNotifier _rowChangeReasonNotifier; - - UnmodifiableListView get rows => UnmodifiableListView(_rowInfos); - GridCellCache get cellCache => _cellCache; - - GridRowCache({ - required this.gridId, - required this.block, - required GridRowCacheFieldNotifier notifier, - }) : _cellCache = GridCellCache(gridId: gridId), - _rowByRowId = HashMap(), - _rowChangeReasonNotifier = _GridRowChangesetNotifier(), - _fieldNotifier = notifier { - // - notifier.onFieldsChanged(() => _rowChangeReasonNotifier - .receive(const GridRowChangeReason.fieldDidChange())); - notifier.onFieldChanged((field) => _cellCache.remove(field.id)); - _rowInfos = block.rows - .map((rowInfo) => buildGridRow(rowInfo.id, rowInfo.height.toDouble())) - .toList(); - } - - Future dispose() async { - _fieldNotifier.dispose(); - _rowChangeReasonNotifier.dispose(); - await _cellCache.dispose(); - } - - void applyChangesets(List changesets) { - for (final changeset in changesets) { - _deleteRows(changeset.deletedRows); - _insertRows(changeset.insertedRows); - _updateRows(changeset.updatedRows); - _hideRows(changeset.hideRows); - _showRows(changeset.visibleRows); - } - } - - void _deleteRows(List deletedRows) { - if (deletedRows.isEmpty) { - return; - } - - final List newRows = []; - final DeletedIndexs deletedIndex = []; - final Map deletedRowByRowId = { - for (var rowId in deletedRows) rowId: rowId - }; - - _rowInfos.asMap().forEach((index, row) { - if (deletedRowByRowId[row.id] == null) { - newRows.add(row); - } else { - _rowByRowId.remove(row.id); - deletedIndex.add(DeletedIndex(index: index, row: row)); - } - }); - _rowInfos = newRows; - _rowChangeReasonNotifier.receive(GridRowChangeReason.delete(deletedIndex)); - } - - void _insertRows(List insertRows) { - if (insertRows.isEmpty) { - return; - } - - InsertedIndexs insertIndexs = []; - for (final insertRow in insertRows) { - final insertIndex = InsertedIndex( - index: insertRow.index, - rowId: insertRow.rowId, - ); - insertIndexs.add(insertIndex); - _rowInfos.insert(insertRow.index, - (buildGridRow(insertRow.rowId, insertRow.height.toDouble()))); - } - - _rowChangeReasonNotifier.receive(GridRowChangeReason.insert(insertIndexs)); - } - - void _updateRows(List updatedRows) { - if (updatedRows.isEmpty) { - return; - } - - final UpdatedIndexs updatedIndexs = UpdatedIndexs(); - for (final updatedRow in updatedRows) { - final rowId = updatedRow.rowId; - final index = _rowInfos.indexWhere((row) => row.id == rowId); - if (index != -1) { - _rowByRowId[rowId] = updatedRow.row; - - _rowInfos.removeAt(index); - _rowInfos.insert( - index, buildGridRow(rowId, updatedRow.row.height.toDouble())); - updatedIndexs[rowId] = UpdatedIndex(index: index, rowId: rowId); - } - } - - _rowChangeReasonNotifier.receive(GridRowChangeReason.update(updatedIndexs)); - } - - void _hideRows(List hideRows) {} - - void _showRows(List visibleRows) {} - - void onRowsChanged( - void Function(GridRowChangeReason) onRowChanged, - ) { - _rowChangeReasonNotifier.addListener(() { - onRowChanged(_rowChangeReasonNotifier.reason); - }); - } - - RowUpdateCallback addListener({ - required String rowId, - void Function(GridCellMap, GridRowChangeReason)? onCellUpdated, - bool Function()? listenWhen, - }) { - listenrHandler() async { - if (listenWhen != null && listenWhen() == false) { - return; - } - - notifyUpdate() { - if (onCellUpdated != null) { - final row = _rowByRowId[rowId]; - if (row != null) { - final GridCellMap cellDataMap = _makeGridCells(rowId, row); - onCellUpdated(cellDataMap, _rowChangeReasonNotifier.reason); - } - } - } - - _rowChangeReasonNotifier.reason.whenOrNull( - update: (indexs) { - if (indexs[rowId] != null) notifyUpdate(); - }, - fieldDidChange: () => notifyUpdate(), - ); - } - - _rowChangeReasonNotifier.addListener(listenrHandler); - return listenrHandler; - } - - void removeRowListener(VoidCallback callback) { - _rowChangeReasonNotifier.removeListener(callback); - } - - GridCellMap loadGridCells(String rowId) { - final GridRowPB? data = _rowByRowId[rowId]; - if (data == null) { - _loadRow(rowId); - } - return _makeGridCells(rowId, data); - } - - Future _loadRow(String rowId) async { - final payload = GridRowIdPB.create() - ..gridId = gridId - ..blockId = block.id - ..rowId = rowId; - - final result = await GridEventGetRow(payload).send(); - result.fold( - (optionRow) => _refreshRow(optionRow), - (err) => Log.error(err), - ); - } - - GridCellMap _makeGridCells(String rowId, GridRowPB? row) { - var cellDataMap = GridCellMap.new(); - for (final field in _fieldNotifier.fields) { - if (field.visibility) { - cellDataMap[field.id] = GridCellIdentifier( - rowId: rowId, - gridId: gridId, - field: field, - ); - } - } - return cellDataMap; - } - - void _refreshRow(OptionalRowPB optionRow) { - if (!optionRow.hasRow()) { - return; - } - final updatedRow = optionRow.row; - updatedRow.freeze(); - - _rowByRowId[updatedRow.id] = updatedRow; - final index = - _rowInfos.indexWhere((gridRow) => gridRow.id == updatedRow.id); - if (index != -1) { - // update the corresponding row in _rows if they are not the same - if (_rowInfos[index].rawRow != updatedRow) { - final row = _rowInfos.removeAt(index).copyWith(rawRow: updatedRow); - _rowInfos.insert(index, row); - - // Calculate the update index - final UpdatedIndexs updatedIndexs = UpdatedIndexs(); - updatedIndexs[row.id] = UpdatedIndex(index: index, rowId: row.id); - - // - _rowChangeReasonNotifier - .receive(GridRowChangeReason.update(updatedIndexs)); - } - } - } - - GridRowInfo buildGridRow(String rowId, double rowHeight) { - return GridRowInfo( - gridId: gridId, - blockId: block.id, - fields: _fieldNotifier.fields, - id: rowId, - height: rowHeight, - ); - } -} - -class _GridRowChangesetNotifier extends ChangeNotifier { - GridRowChangeReason reason = const InitialListState(); - - _GridRowChangesetNotifier(); - - void receive(GridRowChangeReason newReason) { - reason = newReason; - reason.map( - insert: (_) => notifyListeners(), - delete: (_) => notifyListeners(), - update: (_) => notifyListeners(), - fieldDidChange: (_) => notifyListeners(), - initial: (_) {}, - ); - } -} - -class RowService { +class RowFFIService { final String gridId; final String blockId; - final String rowId; - RowService( - {required this.gridId, required this.blockId, required this.rowId}); + RowFFIService({ + required this.gridId, + required this.blockId, + }); - Future> createRow() { - CreateRowPayloadPB payload = CreateRowPayloadPB.create() + Future> createRow(String rowId) { + final payload = CreateTableRowPayloadPB.create() ..gridId = gridId ..startRowId = rowId; - return GridEventCreateRow(payload).send(); + return GridEventCreateTableRow(payload).send(); } - Future> moveRow( - String rowId, int fromIndex, int toIndex) { - final payload = MoveItemPayloadPB.create() - ..gridId = gridId - ..itemId = rowId - ..ty = MoveItemTypePB.MoveRow - ..fromIndex = fromIndex - ..toIndex = toIndex; - - return GridEventMoveItem(payload).send(); - } - - Future> getRow() { - final payload = GridRowIdPB.create() + Future> getRow(String rowId) { + final payload = RowIdPB.create() ..gridId = gridId ..blockId = blockId ..rowId = rowId; @@ -316,8 +31,8 @@ class RowService { return GridEventGetRow(payload).send(); } - Future> deleteRow() { - final payload = GridRowIdPB.create() + Future> deleteRow(String rowId) { + final payload = RowIdPB.create() ..gridId = gridId ..blockId = blockId ..rowId = rowId; @@ -325,8 +40,8 @@ class RowService { return GridEventDeleteRow(payload).send(); } - Future> duplicateRow() { - final payload = GridRowIdPB.create() + Future> duplicateRow(String rowId) { + final payload = RowIdPB.create() ..gridId = gridId ..blockId = blockId ..rowId = rowId; @@ -335,54 +50,22 @@ class RowService { } } -@freezed -class GridRowInfo with _$GridRowInfo { - const factory GridRowInfo({ - required String gridId, - required String blockId, - required String id, - required UnmodifiableListView fields, - required double height, - GridRowPB? rawRow, - }) = _GridRowInfo; -} +class MoveRowFFIService { + final String gridId; -typedef InsertedIndexs = List; -typedef DeletedIndexs = List; -typedef UpdatedIndexs = LinkedHashMap; - -@freezed -class GridRowChangeReason with _$GridRowChangeReason { - const factory GridRowChangeReason.insert(InsertedIndexs items) = _Insert; - const factory GridRowChangeReason.delete(DeletedIndexs items) = _Delete; - const factory GridRowChangeReason.update(UpdatedIndexs indexs) = _Update; - const factory GridRowChangeReason.fieldDidChange() = _FieldDidChange; - const factory GridRowChangeReason.initial() = InitialListState; -} - -class InsertedIndex { - final int index; - final String rowId; - InsertedIndex({ - required this.index, - required this.rowId, + MoveRowFFIService({ + required this.gridId, }); -} -class DeletedIndex { - final int index; - final GridRowInfo row; - DeletedIndex({ - required this.index, - required this.row, - }); -} + Future> moveRow({ + required String fromRowId, + required String toRowId, + }) { + var payload = MoveRowPayloadPB.create() + ..viewId = gridId + ..fromRowId = fromRowId + ..toRowId = toRowId; -class UpdatedIndex { - final int index; - final String rowId; - UpdatedIndex({ - required this.index, - required this.rowId, - }); + return GridEventMoveRow(payload).send(); + } } diff --git a/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart b/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart index 7c185279ae..fc0c2b4b12 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/setting/property_bloc.dart @@ -1,16 +1,17 @@ import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; +import '../field/field_cache.dart'; + part 'property_bloc.freezed.dart'; class GridPropertyBloc extends Bloc { final GridFieldCache _fieldCache; - Function(List)? _onFieldsFn; + Function(List)? _onFieldsFn; GridPropertyBloc({required String gridId, required GridFieldCache fieldCache}) : _fieldCache = fieldCache, @@ -66,8 +67,8 @@ class GridPropertyEvent with _$GridPropertyEvent { const factory GridPropertyEvent.initial() = _Initial; const factory GridPropertyEvent.setFieldVisibility( String fieldId, bool visibility) = _SetFieldVisibility; - const factory GridPropertyEvent.didReceiveFieldUpdate( - List fields) = _DidReceiveFieldUpdate; + const factory GridPropertyEvent.didReceiveFieldUpdate(List fields) = + _DidReceiveFieldUpdate; const factory GridPropertyEvent.moveField(int fromIndex, int toIndex) = _MoveField; } @@ -76,10 +77,10 @@ class GridPropertyEvent with _$GridPropertyEvent { class GridPropertyState with _$GridPropertyState { const factory GridPropertyState({ required String gridId, - required List fields, + required List fields, }) = _GridPropertyState; - factory GridPropertyState.initial(String gridId, List fields) => + factory GridPropertyState.initial(String gridId, List fields) => GridPropertyState( gridId: gridId, fields: fields, diff --git a/frontend/app_flowy/lib/plugins/grid/grid.dart b/frontend/app_flowy/lib/plugins/grid/grid.dart index c0c6d78642..f7d23d3891 100644 --- a/frontend/app_flowy/lib/plugins/grid/grid.dart +++ b/frontend/app_flowy/lib/plugins/grid/grid.dart @@ -22,10 +22,13 @@ class GridPluginBuilder implements PluginBuilder { String get menuName => LocaleKeys.grid_menuName.tr(); @override - PluginType get pluginType => DefaultPlugin.grid.type(); + PluginType get pluginType => PluginType.grid; @override - ViewDataType get dataType => ViewDataType.Grid; + ViewDataTypePB get dataType => ViewDataTypePB.Database; + + @override + ViewLayoutTypePB? get subDataType => ViewLayoutTypePB.Grid; } class GridPluginConfig implements PluginConfig { diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart b/frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart index dddd93d175..72b5152aea 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/controller/grid_scroll.dart @@ -2,19 +2,20 @@ import 'package:flutter/material.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; class GridScrollController { - final LinkedScrollControllerGroup _scrollGroupContorller; + final LinkedScrollControllerGroup _scrollGroupController; final ScrollController verticalController; final ScrollController horizontalController; final List _linkHorizontalControllers = []; - GridScrollController({required LinkedScrollControllerGroup scrollGroupContorller}) - : _scrollGroupContorller = scrollGroupContorller, + GridScrollController( + {required LinkedScrollControllerGroup scrollGroupController}) + : _scrollGroupController = scrollGroupController, verticalController = ScrollController(), - horizontalController = scrollGroupContorller.addAndGet(); + horizontalController = scrollGroupController.addAndGet(); ScrollController linkHorizontalController() { - final controller = _scrollGroupContorller.addAndGet(); + final controller = _scrollGroupController.addAndGet(); _linkHorizontalControllers.add(controller); return controller; } 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 ed6306a972..2c6c1e2180 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/grid_page.dart @@ -1,6 +1,7 @@ +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/plugins/grid/application/grid_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; @@ -11,12 +12,15 @@ import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter/material.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; +import '../application/row/row_cache.dart'; import 'controller/grid_scroll.dart'; import 'layout/layout.dart'; import 'layout/sizes.dart'; +import 'widgets/cell/cell_builder.dart'; import 'widgets/row/grid_row.dart'; import 'widgets/footer/grid_footer.dart'; import 'widgets/header/grid_header.dart'; +import 'widgets/row/row_detail.dart'; import 'widgets/shortcuts.dart'; import 'widgets/toolbar/grid_toolbar.dart'; @@ -79,7 +83,7 @@ class FlowyGrid extends StatefulWidget { class _FlowyGridState extends State { final _scrollController = GridScrollController( - scrollGroupContorller: LinkedScrollControllerGroup()); + scrollGroupController: LinkedScrollControllerGroup()); late ScrollController headerScrollController; @override @@ -153,7 +157,7 @@ class _FlowyGridState extends State { } Widget _gridHeader(BuildContext context, String gridId) { - final fieldCache = context.read().fieldCache; + final fieldCache = context.read().dataController.fieldCache; return GridHeaderSliverAdaptor( gridId: gridId, fieldCache: fieldCache, @@ -169,7 +173,7 @@ class _GridToolbarAdaptor extends StatelessWidget { Widget build(BuildContext context) { return BlocSelector( selector: (state) { - final fieldCache = context.read().fieldCache; + final fieldCache = context.read().dataController.fieldCache; return GridToolbarContext( gridId: state.gridId, fieldCache: fieldCache, @@ -221,7 +225,7 @@ class _GridRowsState extends State<_GridRows> { initialItemCount: context.read().state.rowInfos.length, itemBuilder: (BuildContext context, int index, Animation animation) { - final GridRowInfo rowInfo = + final RowInfo rowInfo = context.read().state.rowInfos[index]; return _renderRow(context, rowInfo, animation); }, @@ -232,25 +236,61 @@ class _GridRowsState extends State<_GridRows> { Widget _renderRow( BuildContext context, - GridRowInfo rowInfo, + RowInfo rowInfo, Animation animation, ) { - final rowCache = - context.read().getRowCache(rowInfo.blockId, rowInfo.id); - final fieldCache = context.read().fieldCache; - if (rowCache != null) { - return SizeTransition( - sizeFactor: animation, - child: GridRowWidget( - rowData: rowInfo, - rowCache: rowCache, - fieldCache: fieldCache, - key: ValueKey(rowInfo.id), - ), - ); - } else { - return const SizedBox(); - } + final rowCache = context.read().getRowCache( + rowInfo.rowPB.blockId, + rowInfo.rowPB.id, + ); + + /// Return placeholder widget if the rowCache is null. + if (rowCache == null) return const SizedBox(); + + final fieldCache = context.read().dataController.fieldCache; + final dataController = GridRowDataController( + rowInfo: rowInfo, + fieldCache: fieldCache, + rowCache: rowCache, + ); + + return SizeTransition( + sizeFactor: animation, + child: GridRowWidget( + rowInfo: rowInfo, + dataController: dataController, + cellBuilder: GridCellBuilder(delegate: dataController), + openDetailPage: (context, cellBuilder) { + _openRowDetailPage( + context, + rowInfo, + fieldCache, + rowCache, + cellBuilder, + ); + }, + key: ValueKey(rowInfo.rowPB.id), + ), + ); + } + + void _openRowDetailPage( + BuildContext context, + RowInfo rowInfo, + GridFieldCache fieldCache, + GridRowCache rowCache, + GridCellBuilder cellBuilder, + ) { + final dataController = GridRowDataController( + rowInfo: rowInfo, + fieldCache: fieldCache, + rowCache: rowCache, + ); + + RowDetailPage( + cellBuilder: cellBuilder, + dataController: dataController, + ).show(context); } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart b/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart index 0b289ecd4b..e47b47a267 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/layout/layout.dart @@ -2,11 +2,15 @@ import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'sizes.dart'; class GridLayout { - static double headerWidth(List fields) { + static double headerWidth(List fields) { if (fields.isEmpty) return 0; - final fieldsWidth = fields.map((field) => field.width.toDouble()).reduce((value, element) => value + element); + final fieldsWidth = fields + .map((field) => field.width.toDouble()) + .reduce((value, element) => value + element); - return fieldsWidth + GridSize.leadingHeaderPadding + GridSize.trailHeaderPadding; + return fieldsWidth + + GridSize.leadingHeaderPadding + + GridSize.trailHeaderPadding; } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart index e41f9bc9ac..9b3f281130 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_accessory.dart @@ -8,6 +8,8 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'cell_builder.dart'; + class GridCellAccessoryBuildContext { final BuildContext anchorContext; final bool isCellEditing; @@ -57,18 +59,6 @@ class PrimaryCellAccessory extends StatelessWidget with GridCellAccessory { bool enable() => !isCellEditing; } -typedef AccessoryBuilder = List Function( - GridCellAccessoryBuildContext buildContext); - -abstract class CellAccessory extends Widget { - const CellAccessory({Key? key}) : super(key: key); - - // The hover will show if the isHover's value is true - ValueNotifier? get onAccessoryHover; - - AccessoryBuilder? get accessoryBuilder; -} - class AccessoryHover extends StatefulWidget { final CellAccessory child; final EdgeInsets contentPadding; diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart index 1913aac786..0a7c3a48a7 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_builder.dart @@ -1,5 +1,4 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -13,53 +12,66 @@ import 'select_option_cell/select_option_cell.dart'; import 'text_cell.dart'; import 'url_cell/url_cell.dart'; +abstract class GridCellBuilderDelegate + extends GridCellControllerBuilderDelegate { + GridCellCache get cellCache; +} + class GridCellBuilder { - final GridCellCache cellCache; - final GridFieldCache fieldCache; + final GridCellBuilderDelegate delegate; GridCellBuilder({ - required this.cellCache, - required this.fieldCache, + required this.delegate, }); - GridCellWidget build(GridCellIdentifier cell, {GridCellStyle? style}) { + GridCellWidget build(GridCellIdentifier cellId, {GridCellStyle? style}) { final cellControllerBuilder = GridCellControllerBuilder( - cellId: cell, - cellCache: cellCache, - fieldCache: fieldCache, + cellId: cellId, + cellCache: delegate.cellCache, + delegate: delegate, ); - final key = cell.key(); - switch (cell.fieldType) { + + final key = cellId.key(); + switch (cellId.fieldType) { case FieldType.Checkbox: return GridCheckboxCell( - cellControllerBuilder: cellControllerBuilder, key: key); + cellControllerBuilder: cellControllerBuilder, + key: key, + ); case FieldType.DateTime: return GridDateCell( - cellControllerBuilder: cellControllerBuilder, - key: key, - style: style); + cellControllerBuilder: cellControllerBuilder, + key: key, + style: style, + ); case FieldType.SingleSelect: return GridSingleSelectCell( - cellContorllerBuilder: cellControllerBuilder, - style: style, - key: key); + cellControllerBuilder: cellControllerBuilder, + style: style, + key: key, + ); case FieldType.MultiSelect: return GridMultiSelectCell( - cellContorllerBuilder: cellControllerBuilder, - style: style, - key: key); + cellControllerBuilder: cellControllerBuilder, + style: style, + key: key, + ); case FieldType.Number: return GridNumberCell( - cellContorllerBuilder: cellControllerBuilder, key: key); + cellControllerBuilder: cellControllerBuilder, + key: key, + ); case FieldType.RichText: return GridTextCell( - cellContorllerBuilder: cellControllerBuilder, - style: style, - key: key); + cellControllerBuilder: cellControllerBuilder, + style: style, + key: key, + ); case FieldType.URL: return GridURLCell( - cellContorllerBuilder: cellControllerBuilder, - style: style, - key: key); + cellControllerBuilder: cellControllerBuilder, + style: style, + key: key, + ); } throw UnimplementedError; } @@ -82,6 +94,18 @@ abstract class CellEditable { ValueNotifier get onCellEditing; } +typedef AccessoryBuilder = List Function( + GridCellAccessoryBuildContext buildContext); + +abstract class CellAccessory extends Widget { + const CellAccessory({Key? key}) : super(key: key); + + // The hover will show if the isHover's value is true + ValueNotifier? get onAccessoryHover; + + AccessoryBuilder? get accessoryBuilder; +} + abstract class GridCellWidget extends StatefulWidget implements CellAccessory, CellEditable, CellShortcuts { GridCellWidget({Key? key}) : super(key: key) { @@ -93,7 +117,7 @@ abstract class GridCellWidget extends StatefulWidget @override final ValueNotifier onCellFocus = ValueNotifier(false); - // When the cell is focused, we assume that the accessory alse be hovered. + // When the cell is focused, we assume that the accessory also be hovered. @override ValueNotifier get onAccessoryHover => onCellFocus; @@ -150,7 +174,7 @@ abstract class GridCellState extends State { abstract class GridFocusNodeCellState extends GridCellState { - SingleListenrFocusNode focusNode = SingleListenrFocusNode(); + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void initState() { @@ -219,7 +243,7 @@ class GridCellFocusListener extends ChangeNotifier { abstract class GridCellStyle {} -class SingleListenrFocusNode extends FocusNode { +class SingleListenerFocusNode extends FocusNode { VoidCallback? _listener; void setListener(VoidCallback listener) { diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_cotainer.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart similarity index 81% rename from frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_cotainer.dart rename to frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart index b2d174e3e2..ed09ec3f36 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_cotainer.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart @@ -25,24 +25,28 @@ class CellContainer extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProxyProvider( - create: (_) => CellContainerNotifier(child), + _CellContainerNotifier>( + create: (_) => _CellContainerNotifier(child), update: (_, rowStateNotifier, cellStateNotifier) => cellStateNotifier!..onEnter = rowStateNotifier.onEnter, - child: Selector( + child: Selector<_CellContainerNotifier, bool>( selector: (context, notifier) => notifier.isFocus, builder: (context, isFocus, _) { Widget container = Center(child: GridCellShortcuts(child: child)); if (accessoryBuilder != null) { - final accessories = accessoryBuilder!(GridCellAccessoryBuildContext( - anchorContext: context, - isCellEditing: isFocus, - )); + final accessories = accessoryBuilder!( + GridCellAccessoryBuildContext( + anchorContext: context, + isCellEditing: isFocus, + ), + ); if (accessories.isNotEmpty) { - container = - CellEnterRegion(child: container, accessories: accessories); + container = _GridCellEnterRegion( + child: container, + accessories: accessories, + ); } } @@ -74,16 +78,16 @@ class CellContainer extends StatelessWidget { } } -class CellEnterRegion extends StatelessWidget { +class _GridCellEnterRegion extends StatelessWidget { final Widget child; final List accessories; - const CellEnterRegion( + const _GridCellEnterRegion( {required this.child, required this.accessories, Key? key}) : super(key: key); @override Widget build(BuildContext context) { - return Selector( + return Selector<_CellContainerNotifier, bool>( selector: (context, notifier) => notifier.onEnter, builder: (context, onEnter, _) { List children = [child]; @@ -95,10 +99,10 @@ class CellEnterRegion extends StatelessWidget { return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (p) => - Provider.of(context, listen: false) + Provider.of<_CellContainerNotifier>(context, listen: false) .onEnter = true, onExit: (p) => - Provider.of(context, listen: false) + Provider.of<_CellContainerNotifier>(context, listen: false) .onEnter = false, child: Stack( alignment: AlignmentDirectional.center, @@ -111,13 +115,13 @@ class CellEnterRegion extends StatelessWidget { } } -class CellContainerNotifier extends ChangeNotifier { +class _CellContainerNotifier extends ChangeNotifier { final CellEditable cellEditable; VoidCallback? _onCellFocusListener; bool _isFocus = false; bool _onEnter = false; - CellContainerNotifier(this.cellEditable) { + _CellContainerNotifier(this.cellEditable) { _onCellFocusListener = () => isFocus = cellEditable.onCellFocus.value; cellEditable.onCellFocus.addListener(_onCellFocusListener!); } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart index f4f222f219..f44a4b5663 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_shortcuts.dart @@ -24,14 +24,16 @@ class GridCellShortcuts extends StatelessWidget { return Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.enter): const GridCellEnterIdent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyC): const GridCellCopyIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyV): const GridCellInsertIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyC): + const GridCellCopyIntent(), + LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyV): + const GridCellPasteIntent(), }, child: Actions( actions: { GridCellEnterIdent: GridCellEnterAction(child: child), GridCellCopyIntent: GridCellCopyAction(child: child), - GridCellInsertIntent: GridCellInsertAction(child: child), + GridCellPasteIntent: GridCellPasteAction(child: child), }, child: child, ), @@ -78,16 +80,16 @@ class GridCellCopyAction extends Action { } } -class GridCellInsertIntent extends Intent { - const GridCellInsertIntent(); +class GridCellPasteIntent extends Intent { + const GridCellPasteIntent(); } -class GridCellInsertAction extends Action { +class GridCellPasteAction extends Action { final CellShortcuts child; - GridCellInsertAction({required this.child}); + GridCellPasteAction({required this.child}); @override - void invoke(covariant GridCellInsertIntent intent) { + void invoke(covariant GridCellPasteIntent intent) { final callback = child.shortcutHandlers[CellKeyboardKey.onInsert]; if (callback != null) { callback(); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart index d334cc38af..adffa3ef16 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/checkbox_cell.dart @@ -22,8 +22,9 @@ class _CheckboxCellState extends GridCellState { @override void initState() { - final cellContext = widget.cellControllerBuilder.build(); - _cellBloc = getIt(param1: cellContext) + final cellController = + widget.cellControllerBuilder.build() as GridCheckboxCellController; + _cellBloc = getIt(param1: cellController) ..add(const CheckboxCellEvent.initial()); super.initState(); } 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 3254f58e6c..fa5be5f9bd 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 @@ -43,8 +43,8 @@ class _DateCellState extends GridCellState { @override void initState() { - final cellContext = widget.cellControllerBuilder.build(); - _cellBloc = getIt(param1: cellContext) + final cellController = widget.cellControllerBuilder.build(); + _cellBloc = getIt(param1: cellController) ..add(const DateCellEvent.initial()); super.initState(); } @@ -84,7 +84,7 @@ class _DateCellState extends GridCellState { DateCellEditor(onDismissed: () => widget.onCellEditing.value = false); calendar.show( context, - cellController: bloc.cellContext.clone(), + cellController: bloc.cellController.clone(), ); } 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 a439506815..f6ddf42fba 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 @@ -1,5 +1,6 @@ import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:app_flowy/plugins/grid/application/cell/date_cal_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; @@ -14,7 +15,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:app_flowy/plugins/grid/application/prelude.dart'; - import '../../../layout/sizes.dart'; import '../../header/type_option/date.dart'; @@ -38,11 +38,12 @@ class DateCellEditor with FlowyOverlayDelegate { final result = await cellController.getFieldTypeOption(DateTypeOptionDataParser()); + result.fold( - (dateTypeOption) { + (dateTypeOptionPB) { final calendar = _CellCalendarWidget( cellContext: cellController, - dateTypeOption: dateTypeOption, + dateTypeOptionPB: dateTypeOptionPB, ); FlowyOverlay.of(context).insertWithAnchor( @@ -78,11 +79,11 @@ class DateCellEditor with FlowyOverlayDelegate { class _CellCalendarWidget extends StatelessWidget { final GridDateCellController cellContext; - final DateTypeOption dateTypeOption; + final DateTypeOptionPB dateTypeOptionPB; const _CellCalendarWidget({ required this.cellContext, - required this.dateTypeOption, + required this.dateTypeOptionPB, Key? key, }) : super(key: key); @@ -92,9 +93,9 @@ class _CellCalendarWidget extends StatelessWidget { return BlocProvider( create: (context) { return DateCalBloc( - dateTypeOption: dateTypeOption, + dateTypeOptionPB: dateTypeOptionPB, cellData: cellContext.getCellData(), - cellContext: cellContext, + cellController: cellContext, )..add(const DateCalEvent.initial()); }, child: BlocBuilder( @@ -196,7 +197,7 @@ class _IncludeTimeButton extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); return BlocSelector( - selector: (state) => state.dateTypeOption.includeTime, + selector: (state) => state.dateTypeOptionPB.includeTime, builder: (context, includeTime) { return SizedBox( height: 50, @@ -243,7 +244,7 @@ class _TimeTextFieldState extends State<_TimeTextField> { void initState() { _focusNode = FocusNode(); _controller = TextEditingController(text: widget.bloc.state.time); - if (widget.bloc.state.dateTypeOption.includeTime) { + if (widget.bloc.state.dateTypeOptionPB.includeTime) { _focusNode.addListener(() { if (mounted) { _CalDateTimeSetting.hide(context); @@ -264,7 +265,7 @@ class _TimeTextFieldState extends State<_TimeTextField> { }, listenWhen: (p, c) => p.time != c.time, builder: (context, state) { - if (state.dateTypeOption.includeTime) { + if (state.dateTypeOptionPB.includeTime) { return Padding( padding: kMargin, child: RoundedInputField( @@ -306,23 +307,24 @@ class _DateTypeOptionButton extends StatelessWidget { final title = LocaleKeys.grid_field_dateFormat.tr() + " &" + LocaleKeys.grid_field_timeFormat.tr(); - return BlocSelector( - selector: (state) => state.dateTypeOption, - builder: (context, dateTypeOption) { + return BlocSelector( + selector: (state) => state.dateTypeOptionPB, + builder: (context, dateTypeOptionPB) { return FlowyButton( text: FlowyText.medium(title, fontSize: 12), hoverColor: theme.hover, margin: kMargin, - onTap: () => _showTimeSetting(dateTypeOption, context), + onTap: () => _showTimeSetting(dateTypeOptionPB, context), rightIcon: svgWidget("grid/more", color: theme.iconColor), ); }, ); } - void _showTimeSetting(DateTypeOption dateTypeOption, BuildContext context) { + void _showTimeSetting( + DateTypeOptionPB dateTypeOptionPB, BuildContext context) { final setting = _CalDateTimeSetting( - dateTypeOption: dateTypeOption, + dateTypeOptionPB: dateTypeOptionPB, onEvent: (event) => context.read().add(event), ); setting.show(context); @@ -330,10 +332,10 @@ class _DateTypeOptionButton extends StatelessWidget { } class _CalDateTimeSetting extends StatefulWidget { - final DateTypeOption dateTypeOption; + final DateTypeOptionPB dateTypeOptionPB; final Function(DateCalEvent) onEvent; const _CalDateTimeSetting( - {required this.dateTypeOption, required this.onEvent, Key? key}) + {required this.dateTypeOptionPB, required this.onEvent, Key? key}) : super(key: key); @override @@ -370,17 +372,17 @@ class _CalDateTimeSettingState extends State<_CalDateTimeSetting> { List children = [ DateFormatButton(onTap: () { final list = DateFormatList( - selectedFormat: widget.dateTypeOption.dateFormat, + selectedFormat: widget.dateTypeOptionPB.dateFormat, onSelected: (format) => widget.onEvent(DateCalEvent.setDateFormat(format)), ); _showOverlay(context, list); }), TimeFormatButton( - timeFormat: widget.dateTypeOption.timeFormat, + timeFormat: widget.dateTypeOptionPB.timeFormat, onTap: () { final list = TimeFormatList( - selectedFormat: widget.dateTypeOption.timeFormat, + selectedFormat: widget.dateTypeOptionPB.timeFormat, onSelected: (format) => widget.onEvent(DateCalEvent.setTimeFormat(format)), ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart index 8c6db0afe9..cd5151d750 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/number_cell.dart @@ -7,10 +7,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'cell_builder.dart'; class GridNumberCell extends GridCellWidget { - final GridCellControllerBuilder cellContorllerBuilder; + final GridCellControllerBuilder cellControllerBuilder; GridNumberCell({ - required this.cellContorllerBuilder, + required this.cellControllerBuilder, Key? key, }) : super(key: key); @@ -25,8 +25,8 @@ class _NumberCellState extends GridFocusNodeCellState { @override void initState() { - final cellContext = widget.cellContorllerBuilder.build(); - _cellBloc = getIt(param1: cellContext) + final cellController = widget.cellControllerBuilder.build(); + _cellBloc = getIt(param1: cellController) ..add(const NumberCellEvent.initial()); _controller = TextEditingController(text: contentFromState(_cellBloc.state)); @@ -49,8 +49,10 @@ class _NumberCellState extends GridFocusNodeCellState { controller: _controller, focusNode: focusNode, onEditingComplete: () => focusNode.unfocus(), - maxLines: null, + onSubmitted: (_) => focusNode.unfocus(), + maxLines: 1, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + textInputAction: TextInputAction.done, decoration: const InputDecoration( contentPadding: EdgeInsets.zero, border: InputBorder.none, @@ -63,7 +65,7 @@ class _NumberCellState extends GridFocusNodeCellState { @override Future dispose() async { - _delayOperation?.cancel(); + _delayOperation = null; _cellBloc.close(); super.dispose(); } @@ -72,7 +74,7 @@ class _NumberCellState extends GridFocusNodeCellState { Future focusChanged() async { if (mounted) { _delayOperation?.cancel(); - _delayOperation = Timer(const Duration(milliseconds: 300), () { + _delayOperation = Timer(const Duration(milliseconds: 30), () { if (_cellBloc.isClosed == false && _controller.text != contentFromState(_cellBloc.state)) { _cellBloc.add(NumberCellEvent.updateCell(_controller.text)); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart index 6946993bae..6fdd8bf6f8 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/extension.dart @@ -73,7 +73,7 @@ class SelectOptionTag extends StatelessWidget { Key? key, }) : super(key: key); - factory SelectOptionTag.fromSelectOption({ + factory SelectOptionTag.fromOption({ required BuildContext context, required SelectOptionPB option, VoidCallback? onSelected, @@ -91,7 +91,8 @@ class SelectOptionTag extends StatelessWidget { Widget build(BuildContext context) { return ChoiceChip( pressElevation: 1, - label: FlowyText.medium(name, fontSize: 12, overflow: TextOverflow.ellipsis), + label: + FlowyText.medium(name, fontSize: 12, overflow: TextOverflow.ellipsis), selectedColor: color, backgroundColor: color, labelPadding: const EdgeInsets.symmetric(horizontal: 6), @@ -133,7 +134,7 @@ class SelectOptionTagCell extends StatelessWidget { Flexible( fit: FlexFit.loose, flex: 2, - child: SelectOptionTag.fromSelectOption( + child: SelectOptionTag.fromOption( context: context, option: option, onSelected: () => onSelected(option), 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 53c77c6016..a8d3993a2f 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 @@ -22,11 +22,11 @@ class SelectOptionCellStyle extends GridCellStyle { } class GridSingleSelectCell extends GridCellWidget { - final GridCellControllerBuilder cellContorllerBuilder; + final GridCellControllerBuilder cellControllerBuilder; late final SelectOptionCellStyle? cellStyle; GridSingleSelectCell({ - required this.cellContorllerBuilder, + required this.cellControllerBuilder, GridCellStyle? style, Key? key, }) : super(key: key) { @@ -46,9 +46,9 @@ class _SingleSelectCellState extends State { @override void initState() { - final cellContext = - widget.cellContorllerBuilder.build() as GridSelectOptionCellController; - _cellBloc = getIt(param1: cellContext) + final cellController = + widget.cellControllerBuilder.build() as GridSelectOptionCellController; + _cellBloc = getIt(param1: cellController) ..add(const SelectOptionCellEvent.initial()); super.initState(); } @@ -59,11 +59,11 @@ class _SingleSelectCellState extends State { value: _cellBloc, child: BlocBuilder( builder: (context, state) { - return _SelectOptionCell( + return SelectOptionWrap( selectOptions: state.selectedOptions, cellStyle: widget.cellStyle, onFocus: (value) => widget.onCellEditing.value = value, - cellContorllerBuilder: widget.cellContorllerBuilder); + cellControllerBuilder: widget.cellControllerBuilder); }, ), ); @@ -78,11 +78,11 @@ class _SingleSelectCellState extends State { //---------------------------------------------------------------- class GridMultiSelectCell extends GridCellWidget { - final GridCellControllerBuilder cellContorllerBuilder; + final GridCellControllerBuilder cellControllerBuilder; late final SelectOptionCellStyle? cellStyle; GridMultiSelectCell({ - required this.cellContorllerBuilder, + required this.cellControllerBuilder, GridCellStyle? style, Key? key, }) : super(key: key) { @@ -102,9 +102,9 @@ class _MultiSelectCellState extends State { @override void initState() { - final cellContext = - widget.cellContorllerBuilder.build() as GridSelectOptionCellController; - _cellBloc = getIt(param1: cellContext) + final cellController = + widget.cellControllerBuilder.build() as GridSelectOptionCellController; + _cellBloc = getIt(param1: cellController) ..add(const SelectOptionCellEvent.initial()); super.initState(); } @@ -115,11 +115,12 @@ class _MultiSelectCellState extends State { value: _cellBloc, child: BlocBuilder( builder: (context, state) { - return _SelectOptionCell( - selectOptions: state.selectedOptions, - cellStyle: widget.cellStyle, - onFocus: (value) => widget.onCellEditing.value = value, - cellContorllerBuilder: widget.cellContorllerBuilder); + return SelectOptionWrap( + selectOptions: state.selectedOptions, + cellStyle: widget.cellStyle, + onFocus: (value) => widget.onCellEditing.value = value, + cellControllerBuilder: widget.cellControllerBuilder, + ); }, ), ); @@ -132,16 +133,16 @@ class _MultiSelectCellState extends State { } } -class _SelectOptionCell extends StatelessWidget { +class SelectOptionWrap extends StatelessWidget { final List selectOptions; - final void Function(bool) onFocus; + final void Function(bool)? onFocus; final SelectOptionCellStyle? cellStyle; - final GridCellControllerBuilder cellContorllerBuilder; - const _SelectOptionCell({ + final GridCellControllerBuilder cellControllerBuilder; + const SelectOptionWrap({ required this.selectOptions, - required this.onFocus, - required this.cellStyle, - required this.cellContorllerBuilder, + required this.cellControllerBuilder, + this.onFocus, + this.cellStyle, Key? key, }) : super(key: key); @@ -152,21 +153,25 @@ class _SelectOptionCell extends StatelessWidget { if (selectOptions.isEmpty && cellStyle != null) { child = Align( alignment: Alignment.centerLeft, - child: FlowyText.medium(cellStyle!.placeholder, - fontSize: 14, color: theme.shader3), + child: FlowyText.medium( + cellStyle!.placeholder, + fontSize: 14, + color: theme.shader3, + ), ); } else { - final tags = selectOptions - .map( - (option) => SelectOptionTag.fromSelectOption( - context: context, - option: option, - ), - ) - .toList(); child = Align( alignment: Alignment.centerLeft, - child: Wrap(children: tags, spacing: 4, runSpacing: 2), + child: Wrap( + children: selectOptions + .map((option) => SelectOptionTag.fromOption( + context: context, + option: option, + )) + .toList(), + spacing: 4, + runSpacing: 2, + ), ); } @@ -175,15 +180,14 @@ class _SelectOptionCell extends StatelessWidget { fit: StackFit.expand, children: [ child, - InkWell( - onTap: () { - onFocus(true); - final cellContext = - cellContorllerBuilder.build() as GridSelectOptionCellController; - SelectOptionCellEditor.show( - context, cellContext, () => onFocus(false)); - }, - ), + InkWell(onTap: () { + onFocus?.call(true); + SelectOptionCellEditor.show( + context, + cellControllerBuilder.build() as GridSelectOptionCellController, + () => onFocus?.call(false), + ); + }), ], ); } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart index 10b04cfb58..5482a403cc 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/text_field.dart @@ -49,7 +49,8 @@ class SelectOptionTextField extends StatelessWidget { initialTags: selectedOptionMap.keys.toList(), focusNode: _focusNode, textSeparators: const [' ', ','], - inputfieldBuilder: (BuildContext context, editController, focusNode, error, onChanged, onSubmitted) { + inputfieldBuilder: (BuildContext context, editController, focusNode, + error, onChanged, onSubmitted) { return ((context, sc, tags, onTagDelegate) { return TextField( autofocus: true, @@ -99,7 +100,8 @@ class SelectOptionTextField extends StatelessWidget { } final children = selectedOptionMap.values - .map((option) => SelectOptionTag.fromSelectOption(context: context, option: option)) + .map((option) => + SelectOptionTag.fromOption(context: context, option: option)) .toList(); return Padding( padding: const EdgeInsets.all(8.0), diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart index 29471c13b6..0052ad5e70 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/text_cell.dart @@ -14,10 +14,10 @@ class GridTextCellStyle extends GridCellStyle { } class GridTextCell extends GridCellWidget { - final GridCellControllerBuilder cellContorllerBuilder; + final GridCellControllerBuilder cellControllerBuilder; late final GridTextCellStyle? cellStyle; GridTextCell({ - required this.cellContorllerBuilder, + required this.cellControllerBuilder, GridCellStyle? style, Key? key, }) : super(key: key) { @@ -39,8 +39,8 @@ class _GridTextCellState extends GridFocusNodeCellState { @override void initState() { - final cellContext = widget.cellContorllerBuilder.build(); - _cellBloc = getIt(param1: cellContext); + final cellController = widget.cellControllerBuilder.build(); + _cellBloc = getIt(param1: cellController); _cellBloc.add(const TextCellEvent.initial()); _controller = TextEditingController(text: _cellBloc.state.content); super.initState(); @@ -76,7 +76,7 @@ class _GridTextCellState extends GridFocusNodeCellState { @override Future dispose() async { - _delayOperation?.cancel(); + _delayOperation = null; _cellBloc.close(); super.dispose(); } @@ -85,7 +85,7 @@ class _GridTextCellState extends GridFocusNodeCellState { Future focusChanged() async { if (mounted) { _delayOperation?.cancel(); - _delayOperation = Timer(const Duration(milliseconds: 300), () { + _delayOperation = Timer(const Duration(milliseconds: 30), () { if (_cellBloc.isClosed == false && _controller.text != _cellBloc.state.content) { _cellBloc.add(TextCellEvent.updateText(_controller.text)); 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 9095d73fa6..e68ac720a3 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 @@ -64,7 +64,7 @@ class _URLCellEditorState extends State { @override void initState() { - _cellBloc = URLCellEditorBloc(cellContext: widget.cellController); + _cellBloc = URLCellEditorBloc(cellController: widget.cellController); _cellBloc.add(const URLCellEditorEvent.initial()); _controller = TextEditingController(text: _cellBloc.state.content); 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 bb39b8f276..102595e166 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 @@ -31,10 +31,10 @@ enum GridURLCellAccessoryType { } class GridURLCell extends GridCellWidget { - final GridCellControllerBuilder cellContorllerBuilder; + final GridCellControllerBuilder cellControllerBuilder; late final GridURLCellStyle? cellStyle; GridURLCell({ - required this.cellContorllerBuilder, + required this.cellControllerBuilder, GridCellStyle? style, Key? key, }) : super(key: key) { @@ -52,15 +52,15 @@ class GridURLCell extends GridCellWidget { GridURLCellAccessoryType ty, GridCellAccessoryBuildContext buildContext) { switch (ty) { case GridURLCellAccessoryType.edit: - final cellContext = - cellContorllerBuilder.build() as GridURLCellController; + final cellController = + cellControllerBuilder.build() as GridURLCellController; return _EditURLAccessory( - cellContext: cellContext, + cellContext: cellController, anchorContext: buildContext.anchorContext); case GridURLCellAccessoryType.copyURL: final cellContext = - cellContorllerBuilder.build() as GridURLCellController; + cellControllerBuilder.build() as GridURLCellController; return _CopyURLAccessory(cellContext: cellContext); } } @@ -90,9 +90,9 @@ class _GridURLCellState extends GridCellState { @override void initState() { - final cellContext = - widget.cellContorllerBuilder.build() as GridURLCellController; - _cellBloc = URLCellBloc(cellContext: cellContext); + final cellController = + widget.cellControllerBuilder.build() as GridURLCellController; + _cellBloc = URLCellBloc(cellController: cellController); _cellBloc.add(const URLCellEvent.initial()); super.initState(); } @@ -141,7 +141,7 @@ class _GridURLCellState extends GridCellState { await launchUrl(uri); } else { final cellContext = - widget.cellContorllerBuilder.build() as GridURLCellController; + widget.cellControllerBuilder.build() as GridURLCellController; widget.onCellEditing.value = true; URLCellEditor.show(context, cellContext, () { widget.onCellEditing.value = false; diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart index f7b5ab350a..fba4215891 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/footer/grid_footer.dart @@ -14,7 +14,7 @@ class GridAddRowButton extends StatelessWidget { final theme = context.watch(); return FlowyButton( text: const FlowyText.medium('New row', fontSize: 12), - hoverColor: theme.hover, + hoverColor: theme.shader6, onTap: () => context.read().add(const GridEvent.createRow()), leftIcon: svgWidget("home/add"), ); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart index aec88f5eaf..c1de0eca31 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/field_cell.dart @@ -1,5 +1,6 @@ import 'package:app_flowy/plugins/grid/application/field/field_cell_bloc.dart'; import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; @@ -143,7 +144,7 @@ class _DragToExpandLine extends StatelessWidget { class FieldCellButton extends StatelessWidget { final VoidCallback onTap; - final GridFieldPB field; + final FieldPB field; const FieldCellButton({ required this.field, required this.onTap, 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 30a76a34bb..efa70bb81a 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 @@ -1,5 +1,5 @@ import 'package:app_flowy/plugins/grid/application/field/field_editor_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; 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 c2a12bb7ad..20440235cb 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 @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_data_controller.dart'; import 'package:dartz/dartz.dart' show Either; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; @@ -15,7 +16,7 @@ import 'field_type_extension.dart'; import 'field_type_list.dart'; import 'type_option/builder.dart'; -typedef UpdateFieldCallback = void Function(GridFieldPB, Uint8List); +typedef UpdateFieldCallback = void Function(FieldPB, Uint8List); typedef SwitchToFieldCallback = Future> Function( String fieldId, @@ -63,7 +64,7 @@ class _FieldTypeOptionEditorState extends State { ); } - Widget _switchFieldTypeButton(BuildContext context, GridFieldPB field) { + Widget _switchFieldTypeButton(BuildContext context, FieldPB field) { final theme = context.watch(); return SizedBox( height: GridSize.typeOptionItemHeight, @@ -94,8 +95,8 @@ class _FieldTypeOptionEditorState extends State { return makeTypeOptionWidget( context: context, - dataController: widget.dataController, overlayDelegate: overlayDelegate, + dataController: widget.dataController, ); } 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 df7be4ee2b..9d33768e65 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 @@ -1,3 +1,5 @@ +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/plugins/grid/application/prelude.dart'; import 'package:flowy_infra/image.dart'; @@ -154,7 +156,7 @@ class CreateFieldButton extends StatelessWidget { return FlowyButton( text: const FlowyText.medium('New column', fontSize: 12), - hoverColor: theme.hover, + hoverColor: theme.shader6, onTap: () => FieldEditor( gridId: gridId, fieldName: "", @@ -168,7 +170,7 @@ class CreateFieldButton extends StatelessWidget { class SliverHeaderDelegateImplementation extends SliverPersistentHeaderDelegate { final String gridId; - final List fields; + final List fields; SliverHeaderDelegateImplementation( {required this.gridId, required this.fields}); 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 4471e672a3..2ea19eb1e8 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 @@ -1,7 +1,15 @@ import 'dart:typed_data'; -import 'package:app_flowy/plugins/grid/application/field/type_option/multi_select_type_option.dart'; -import 'package:app_flowy/plugins/grid/application/prelude.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_data_controller.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/date_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/multi_select_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/number_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/single_select_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/text_type_option.pb.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option.pb.dart'; +import 'package:protobuf/protobuf.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter/material.dart'; import 'checkbox.dart'; @@ -39,70 +47,147 @@ Widget? makeTypeOptionWidget({ required TypeOptionDataController dataController, required TypeOptionOverlayDelegate overlayDelegate, }) { - final builder = makeTypeOptionWidgetBuilder(dataController, overlayDelegate); + final builder = makeTypeOptionWidgetBuilder( + dataController: dataController, + overlayDelegate: overlayDelegate, + ); return builder.build(context); } -TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder( - TypeOptionDataController dataController, - TypeOptionOverlayDelegate overlayDelegate, -) { +TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder({ + required TypeOptionDataController dataController, + required TypeOptionOverlayDelegate overlayDelegate, +}) { + final gridId = dataController.gridId; + final fieldType = dataController.field.fieldType; + switch (dataController.field.fieldType) { case FieldType.Checkbox: - final context = CheckboxTypeOptionContext( - dataController: dataController, - dataParser: CheckboxTypeOptionWidgetDataParser(), + return CheckboxTypeOptionWidgetBuilder( + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), ); - return CheckboxTypeOptionWidgetBuilder(context); case FieldType.DateTime: - final context = DateTypeOptionContext( - dataController: dataController, - dataParser: DateTypeOptionDataParser(), - ); return DateTypeOptionWidgetBuilder( - context, + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), overlayDelegate, ); case FieldType.SingleSelect: - final context = SingleSelectTypeOptionContext( - fieldContext: dataController, - dataBuilder: SingleSelectTypeOptionWidgetDataParser(), - ); return SingleSelectTypeOptionWidgetBuilder( - context, + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), overlayDelegate, ); case FieldType.MultiSelect: - final context = MultiSelectTypeOptionContext( - dataController: dataController, - dataBuilder: MultiSelectTypeOptionWidgetDataParser(), - ); return MultiSelectTypeOptionWidgetBuilder( - context, + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), overlayDelegate, ); case FieldType.Number: - final context = NumberTypeOptionContext( - dataController: dataController, - dataParser: NumberTypeOptionWidgetDataParser(), - ); return NumberTypeOptionWidgetBuilder( - context, + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), overlayDelegate, ); case FieldType.RichText: - final context = RichTextTypeOptionContext( - dataController: dataController, - dataParser: RichTextTypeOptionWidgetDataParser(), + return RichTextTypeOptionWidgetBuilder( + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), ); - return RichTextTypeOptionWidgetBuilder(context); case FieldType.URL: - final context = URLTypeOptionContext( - dataController: dataController, - dataParser: URLTypeOptionWidgetDataParser(), + return URLTypeOptionWidgetBuilder( + makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: fieldType, + dataController: dataController, + ), ); - return URLTypeOptionWidgetBuilder(context); } throw UnimplementedError; } + +TypeOptionContext makeTypeOptionContext({ + required String gridId, + required FieldPB field, +}) { + final loader = FieldTypeOptionLoader(gridId: gridId, field: field); + final dataController = TypeOptionDataController( + gridId: gridId, + loader: loader, + field: field, + ); + return makeTypeOptionContextWithDataController( + gridId: gridId, + fieldType: field.fieldType, + dataController: dataController, + ); +} + +TypeOptionContext + makeTypeOptionContextWithDataController({ + required String gridId, + required FieldType fieldType, + required TypeOptionDataController dataController, +}) { + switch (fieldType) { + case FieldType.Checkbox: + return CheckboxTypeOptionContext( + dataController: dataController, + dataParser: CheckboxTypeOptionWidgetDataParser(), + ) as TypeOptionContext; + case FieldType.DateTime: + return DateTypeOptionContext( + dataController: dataController, + dataParser: DateTypeOptionDataParser(), + ) as TypeOptionContext; + case FieldType.SingleSelect: + return SingleSelectTypeOptionContext( + dataController: dataController, + dataParser: SingleSelectTypeOptionWidgetDataParser(), + ) as TypeOptionContext; + case FieldType.MultiSelect: + return MultiSelectTypeOptionContext( + dataController: dataController, + dataParser: MultiSelectTypeOptionWidgetDataParser(), + ) as TypeOptionContext; + case FieldType.Number: + return NumberTypeOptionContext( + dataController: dataController, + dataParser: NumberTypeOptionWidgetDataParser(), + ) as TypeOptionContext; + case FieldType.RichText: + return RichTextTypeOptionContext( + dataController: dataController, + dataParser: RichTextTypeOptionWidgetDataParser(), + ) as TypeOptionContext; + + case FieldType.URL: + return URLTypeOptionContext( + dataController: dataController, + dataParser: URLTypeOptionWidgetDataParser(), + ) as TypeOptionContext; + } + + throw UnimplementedError; +} diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart index fdc5719181..92511a888a 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/checkbox.dart @@ -1,18 +1,7 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/checkbox_type_option.pb.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; -typedef CheckboxTypeOptionContext = TypeOptionWidgetContext; - -class CheckboxTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - CheckboxTypeOption fromBuffer(List buffer) { - return CheckboxTypeOption.fromBuffer(buffer); - } -} - class CheckboxTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { CheckboxTypeOptionWidgetBuilder(CheckboxTypeOptionContext typeOptionContext); 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 c162595b88..51433037a3 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 @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/field/type_option/date_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:easy_localization/easy_localization.dart' hide DateFormat; import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:flowy_infra/image.dart'; 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 e1b97fe087..ab8a628146 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 @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/field/type_option/multi_select_type_option.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flutter/material.dart'; import '../field_type_option_editor.dart'; @@ -12,7 +13,11 @@ class MultiSelectTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { MultiSelectTypeOptionContext typeOptionContext, TypeOptionOverlayDelegate overlayDelegate, ) : _widget = MultiSelectTypeOptionWidget( - typeOptionContext: typeOptionContext, + selectOptionAction: MultiSelectAction( + fieldId: typeOptionContext.fieldId, + gridId: typeOptionContext.gridId, + typeOptionContext: typeOptionContext, + ), overlayDelegate: overlayDelegate, ); @@ -21,11 +26,11 @@ class MultiSelectTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { } class MultiSelectTypeOptionWidget extends TypeOptionWidget { - final MultiSelectTypeOptionContext typeOptionContext; + final MultiSelectAction selectOptionAction; final TypeOptionOverlayDelegate overlayDelegate; const MultiSelectTypeOptionWidget({ - required this.typeOptionContext, + required this.selectOptionAction, required this.overlayDelegate, Key? key, }) : super(key: key); @@ -33,10 +38,10 @@ class MultiSelectTypeOptionWidget extends TypeOptionWidget { @override Widget build(BuildContext context) { return SelectOptionTypeOptionWidget( - options: typeOptionContext.typeOption.options, + options: selectOptionAction.typeOption.options, beginEdit: () => overlayDelegate.hideOverlay(context), overlayDelegate: overlayDelegate, - typeOptionAction: typeOptionContext, + typeOptionAction: selectOptionAction, // key: ValueKey(state.typeOption.hashCode), ); } 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 8e4e53b40c..d15be4a6a0 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 @@ -1,5 +1,6 @@ import 'package:app_flowy/plugins/grid/application/field/type_option/number_bloc.dart'; import 'package:app_flowy/plugins/grid/application/field/type_option/number_format_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart index a4291f91ce..1ca0386c31 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/rich_text.dart @@ -1,18 +1,7 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/text_type_option.pb.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; -typedef RichTextTypeOptionContext = TypeOptionWidgetContext; - -class RichTextTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - RichTextTypeOption fromBuffer(List buffer) { - return RichTextTypeOption.fromBuffer(buffer); - } -} - class RichTextTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { RichTextTypeOptionWidgetBuilder(RichTextTypeOptionContext typeOptionContext); 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 5d6304ea7a..d36df1fea7 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 @@ -20,7 +20,7 @@ class SelectOptionTypeOptionWidget extends StatelessWidget { final List options; final VoidCallback beginEdit; final TypeOptionOverlayDelegate overlayDelegate; - final SelectOptionTypeOptionAction typeOptionAction; + final ISelectOptionAction typeOptionAction; const SelectOptionTypeOptionWidget({ required this.options, @@ -34,7 +34,9 @@ class SelectOptionTypeOptionWidget extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => SelectOptionTypeOptionBloc( - options: options, typeOptionAction: typeOptionAction), + options: options, + typeOptionAction: typeOptionAction, + ), child: BlocBuilder( builder: (context, state) { 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 57940ecbee..d9d699fdff 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 @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/field/type_option/single_select_type_option.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flutter/material.dart'; import '../field_type_option_editor.dart'; import 'builder.dart'; @@ -8,10 +9,14 @@ class SingleSelectTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { final SingleSelectTypeOptionWidget _widget; SingleSelectTypeOptionWidgetBuilder( - SingleSelectTypeOptionContext typeOptionContext, + SingleSelectTypeOptionContext singleSelectTypeOption, TypeOptionOverlayDelegate overlayDelegate, ) : _widget = SingleSelectTypeOptionWidget( - typeOptionContext: typeOptionContext, + selectOptionAction: SingleSelectAction( + fieldId: singleSelectTypeOption.fieldId, + gridId: singleSelectTypeOption.gridId, + typeOptionContext: singleSelectTypeOption, + ), overlayDelegate: overlayDelegate, ); @@ -20,11 +25,11 @@ class SingleSelectTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { } class SingleSelectTypeOptionWidget extends TypeOptionWidget { - final SingleSelectTypeOptionContext typeOptionContext; + final SingleSelectAction selectOptionAction; final TypeOptionOverlayDelegate overlayDelegate; const SingleSelectTypeOptionWidget({ - required this.typeOptionContext, + required this.selectOptionAction, required this.overlayDelegate, Key? key, }) : super(key: key); @@ -32,10 +37,10 @@ class SingleSelectTypeOptionWidget extends TypeOptionWidget { @override Widget build(BuildContext context) { return SelectOptionTypeOptionWidget( - options: typeOptionContext.typeOption.options, + options: selectOptionAction.typeOption.options, beginEdit: () => overlayDelegate.hideOverlay(context), overlayDelegate: overlayDelegate, - typeOptionAction: typeOptionContext, + typeOptionAction: selectOptionAction, // key: ValueKey(state.typeOption.hashCode), ); } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart index 894a1b1e5c..9997837d63 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/header/type_option/url.dart @@ -1,18 +1,7 @@ -import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_service.dart'; -import 'package:flowy_sdk/protobuf/flowy-grid/url_type_option.pb.dart'; +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:flutter/material.dart'; import 'builder.dart'; -typedef URLTypeOptionContext = TypeOptionWidgetContext; - -class URLTypeOptionWidgetDataParser - extends TypeOptionDataParser { - @override - URLTypeOption fromBuffer(List buffer) { - return URLTypeOption.fromBuffer(buffer); - } -} - class URLTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder { URLTypeOptionWidgetBuilder(URLTypeOptionContext typeOptionContext); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart index effc1b188c..a4bf813fe5 100755 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/row/grid_row.dart @@ -1,4 +1,6 @@ import 'package:app_flowy/plugins/grid/application/prelude.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_cache.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; @@ -9,26 +11,23 @@ import 'package:provider/provider.dart'; import '../../layout/sizes.dart'; import '../cell/cell_accessory.dart'; -import '../cell/cell_cotainer.dart'; +import '../cell/cell_container.dart'; import '../cell/prelude.dart'; import 'row_action_sheet.dart'; -import 'row_detail.dart'; class GridRowWidget extends StatefulWidget { - final GridRowInfo rowData; - final GridRowCache rowCache; + final RowInfo rowInfo; + final GridRowDataController dataController; final GridCellBuilder cellBuilder; + final void Function(BuildContext, GridCellBuilder) openDetailPage; - GridRowWidget({ - required this.rowData, - required this.rowCache, - required GridFieldCache fieldCache, + const GridRowWidget({ + required this.rowInfo, + required this.dataController, + required this.cellBuilder, + required this.openDetailPage, Key? key, - }) : cellBuilder = GridCellBuilder( - cellCache: rowCache.cellCache, - fieldCache: fieldCache, - ), - super(key: key); + }) : super(key: key); @override State createState() => _GridRowWidgetState(); @@ -40,8 +39,8 @@ class _GridRowWidgetState extends State { @override void initState() { _rowBloc = RowBloc( - rowInfo: widget.rowData, - rowCache: widget.rowCache, + rowInfo: widget.rowInfo, + dataController: widget.dataController, ); _rowBloc.add(const RowEvent.initial()); super.initState(); @@ -53,19 +52,22 @@ class _GridRowWidgetState extends State { value: _rowBloc, child: _RowEnterRegion( child: BlocBuilder( - buildWhen: (p, c) => p.rowInfo.height != c.rowInfo.height, + buildWhen: (p, c) => p.rowInfo.rowPB.height != c.rowInfo.rowPB.height, builder: (context, state) { - return Row( - children: [ - const _RowLeading(), - Expanded( - child: _RowCells( + final children = [ + const _RowLeading(), + Expanded( + child: RowContent( builder: widget.cellBuilder, - onExpand: () => _expandRow(context), - )), - const _RowTrailing(), - ], - ); + onExpand: () => widget.openDetailPage( + context, + widget.cellBuilder, + ), + ), + ), + const _RowTrailing(), + ]; + return Row(children: children); }, ), ), @@ -77,15 +79,6 @@ class _GridRowWidgetState extends State { _rowBloc.close(); super.dispose(); } - - void _expandRow(BuildContext context) { - final page = RowDetailPage( - rowInfo: widget.rowData, - rowCache: widget.rowCache, - cellBuilder: widget.cellBuilder, - ); - page.show(context); - } } class _RowLeading extends StatelessWidget { @@ -158,10 +151,10 @@ class _DeleteRowButton extends StatelessWidget { } } -class _RowCells extends StatelessWidget { +class RowContent extends StatelessWidget { final VoidCallback onExpand; final GridCellBuilder builder; - const _RowCells({ + const RowContent({ required this.builder, required this.onExpand, Key? key, @@ -171,7 +164,7 @@ class _RowCells extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => - !listEquals(previous.snapshots, current.snapshots), + !listEquals(previous.cells, current.cells), builder: (context, state) { return IntrinsicHeight( child: Row( @@ -188,28 +181,27 @@ class _RowCells extends StatelessWidget { return gridCellMap.values.map( (cellId) { final GridCellWidget child = builder.build(cellId); - accessoryBuilder(GridCellAccessoryBuildContext buildContext) { - final builder = child.accessoryBuilder; - List accessories = []; - if (cellId.field.isPrimary) { - accessories.add(PrimaryCellAccessory( - onTapCallback: onExpand, - isCellEditing: buildContext.isCellEditing, - )); - } - - if (builder != null) { - accessories.addAll(builder(buildContext)); - } - return accessories; - } return CellContainer( width: cellId.field.width.toDouble(), child: child, rowStateNotifier: Provider.of(context, listen: false), - accessoryBuilder: accessoryBuilder, + accessoryBuilder: (buildContext) { + final builder = child.accessoryBuilder; + List accessories = []; + if (cellId.field.isPrimary) { + accessories.add(PrimaryCellAccessory( + onTapCallback: onExpand, + isCellEditing: buildContext.isCellEditing, + )); + } + + if (builder != null) { + accessories.addAll(builder(buildContext)); + } + return accessories; + }, ); }, ).toList(); 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 67c7b4893b..b4390d098f 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,5 +1,4 @@ import 'package:app_flowy/plugins/grid/application/row/row_action_sheet_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:flowy_infra/image.dart'; @@ -12,16 +11,17 @@ import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../application/row/row_cache.dart'; import '../../layout/sizes.dart'; class GridRowActionSheet extends StatelessWidget { - final GridRowInfo rowData; + final RowInfo rowData; const GridRowActionSheet({required this.rowData, Key? key}) : super(key: key); @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => RowActionSheetBloc(rowData: rowData), + create: (context) => RowActionSheetBloc(rowInfo: rowData), child: BlocBuilder( builder: (context, state) { final cells = _RowAction.values @@ -53,7 +53,10 @@ class GridRowActionSheet extends StatelessWidget { ); } - void show(BuildContext overlayContext) { + void show( + BuildContext overlayContext, { + AnchorDirection direction = AnchorDirection.leftWithCenterAligned, + }) { FlowyOverlay.of(overlayContext).insertWithAnchor( widget: OverlayContainer( child: this, @@ -61,7 +64,7 @@ class GridRowActionSheet extends StatelessWidget { ), identifier: GridRowActionSheet.identifier(), anchorContext: overlayContext, - anchorDirection: AnchorDirection.leftWithCenterAligned, + anchorDirection: direction, ); } 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 e7e00ead68..a98fcfa688 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 @@ -1,7 +1,7 @@ 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:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; +import 'package:app_flowy/plugins/grid/application/row/row_data_controller.dart'; import 'package:app_flowy/plugins/grid/application/row/row_detail_bloc.dart'; -import 'package:app_flowy/plugins/grid/application/row/row_service.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -21,13 +21,11 @@ import '../header/field_cell.dart'; import '../header/field_editor.dart'; class RowDetailPage extends StatefulWidget with FlowyOverlayDelegate { - final GridRowInfo rowInfo; - final GridRowCache rowCache; + final GridRowDataController dataController; final GridCellBuilder cellBuilder; const RowDetailPage({ - required this.rowInfo, - required this.rowCache, + required this.dataController, required this.cellBuilder, Key? key, }) : super(key: key); @@ -62,8 +60,9 @@ class _RowDetailPageState extends State { Widget build(BuildContext context) { return BlocProvider( create: (context) { - final bloc = - RowDetailBloc(rowInfo: widget.rowInfo, rowCache: widget.rowCache); + final bloc = RowDetailBloc( + dataController: widget.dataController, + ); bloc.add(const RowDetailEvent.initial()); return bloc; }, 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 df4eb2115c..02230e5139 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 @@ -1,6 +1,5 @@ +import 'package:app_flowy/plugins/grid/application/field/type_option/type_option_context.dart'; import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/plugins/grid/application/field/field_service.dart'; -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; import 'package:app_flowy/plugins/grid/application/setting/property_bloc.dart'; import 'package:app_flowy/plugins/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:flowy_infra/image.dart'; @@ -15,6 +14,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:styled_widget/styled_widget.dart'; +import '../../../application/field/field_cache.dart'; import '../../layout/sizes.dart'; import '../header/field_editor.dart'; @@ -78,7 +78,7 @@ class GridPropertyList extends StatelessWidget with FlowyOverlayDelegate { } class _GridPropertyCell extends StatelessWidget { - final GridFieldPB field; + final FieldPB field; final String gridId; const _GridPropertyCell({required this.gridId, required this.field, Key? key}) : super(key: key); diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart index d7c084fe2d..f555b6266a 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/toolbar/grid_setting.dart @@ -1,4 +1,3 @@ -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; import 'package:app_flowy/plugins/grid/application/setting/setting_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; @@ -12,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; +import '../../../application/field/field_cache.dart'; import '../../layout/sizes.dart'; import 'grid_property.dart'; 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 2f332ffe2f..4ced06bf99 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 @@ -1,4 +1,3 @@ -import 'package:app_flowy/plugins/grid/application/grid_service.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/style_widget/extension.dart'; @@ -6,6 +5,7 @@ import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../application/field/field_cache.dart'; import '../../layout/sizes.dart'; import 'grid_setting.dart'; diff --git a/frontend/app_flowy/lib/plugins/trash/menu.dart b/frontend/app_flowy/lib/plugins/trash/menu.dart index e0a6c5ce49..15cfc398a1 100644 --- a/frontend/app_flowy/lib/plugins/trash/menu.dart +++ b/frontend/app_flowy/lib/plugins/trash/menu.dart @@ -23,7 +23,7 @@ class MenuTrash extends StatelessWidget { onTap: () { getIt().latestOpenView = null; getIt() - .setPlugin(makePlugin(pluginType: DefaultPlugin.trash.type())); + .setPlugin(makePlugin(pluginType: PluginType.trash)); }, child: _render(context), ), diff --git a/frontend/app_flowy/lib/plugins/trash/trash.dart b/frontend/app_flowy/lib/plugins/trash/trash.dart index 18a2c4aa8e..0d17091581 100644 --- a/frontend/app_flowy/lib/plugins/trash/trash.dart +++ b/frontend/app_flowy/lib/plugins/trash/trash.dart @@ -34,7 +34,7 @@ class TrashPluginBuilder extends PluginBuilder { String get menuName => "TrashPB"; @override - PluginType get pluginType => DefaultPlugin.trash.type(); + PluginType get pluginType => PluginType.trash; } class TrashPluginConfig implements PluginConfig { diff --git a/frontend/app_flowy/lib/startup/deps_resolver.dart b/frontend/app_flowy/lib/startup/deps_resolver.dart index 8b173a4002..be3c617943 100644 --- a/frontend/app_flowy/lib/startup/deps_resolver.dart +++ b/frontend/app_flowy/lib/startup/deps_resolver.dart @@ -21,6 +21,8 @@ import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:get_it/get_it.dart'; +import '../plugins/grid/application/field/field_cache.dart'; + class DependencyResolver { static Future resolve(GetIt getIt) async { _resolveUserDeps(getIt); @@ -168,33 +170,33 @@ void _resolveGridDeps(GetIt getIt) { getIt.registerFactoryParam( (context, _) => TextCellBloc( - cellContext: context, + cellController: context, ), ); getIt.registerFactoryParam( (context, _) => SelectOptionCellBloc( - cellContext: context, + cellController: context, ), ); getIt.registerFactoryParam( (context, _) => NumberCellBloc( - cellContext: context, + cellController: context, ), ); getIt.registerFactoryParam( (context, _) => DateCellBloc( - cellContext: context, + cellController: context, ), ); getIt.registerFactoryParam( (cellData, _) => CheckboxCellBloc( service: CellService(), - cellContext: cellData, + cellController: cellData, ), ); diff --git a/frontend/app_flowy/lib/startup/plugin/plugin.dart b/frontend/app_flowy/lib/startup/plugin/plugin.dart index a02a8cfb83..6e06450295 100644 --- a/frontend/app_flowy/lib/startup/plugin/plugin.dart +++ b/frontend/app_flowy/lib/startup/plugin/plugin.dart @@ -9,33 +9,32 @@ import 'package:flutter/widgets.dart'; export "./src/sandbox.dart"; -enum DefaultPlugin { - quill, +enum PluginType { + editor, blank, trash, grid, board, } -extension FlowyDefaultPluginExt on DefaultPlugin { - int type() { - switch (this) { - case DefaultPlugin.quill: - return 0; - case DefaultPlugin.blank: - return 1; - case DefaultPlugin.trash: - return 2; - case DefaultPlugin.grid: - return 3; - case DefaultPlugin.board: - return 4; - } - } -} +// extension FlowyDefaultPluginExt on DefaultPlugin { +// int type() { +// switch (this) { +// case DefaultPlugin.editor: +// return 0; +// case DefaultPlugin.blank: +// return 1; +// case DefaultPlugin.trash: +// return 2; +// case DefaultPlugin.grid: +// return 3; +// case DefaultPlugin.board: +// return 4; +// } +// } +// } -typedef PluginType = int; -typedef PluginDataType = ViewDataType; +// typedef PluginType = int; typedef PluginId = String; abstract class Plugin { @@ -55,7 +54,9 @@ abstract class PluginBuilder { PluginType get pluginType; - ViewDataType get dataType => ViewDataType.TextBlock; + ViewDataTypePB get dataType => ViewDataTypePB.Text; + + ViewLayoutTypePB? get subDataType => null; } abstract class PluginConfig { diff --git a/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart b/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart index 5f1d63c287..ca32ec4217 100644 --- a/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart +++ b/frontend/app_flowy/lib/startup/plugin/src/sandbox.dart @@ -6,8 +6,10 @@ import '../plugin.dart'; import 'runner.dart'; class PluginSandbox { - final LinkedHashMap _pluginBuilders = LinkedHashMap(); - final Map _pluginConfigs = {}; + final LinkedHashMap _pluginBuilders = + LinkedHashMap(); + final Map _pluginConfigs = + {}; late PluginRunner pluginRunner; PluginSandbox() { @@ -15,9 +17,11 @@ class PluginSandbox { } int indexOf(PluginType pluginType) { - final index = _pluginBuilders.keys.toList().indexWhere((ty) => ty == pluginType); + final index = + _pluginBuilders.keys.toList().indexWhere((ty) => ty == pluginType); if (index == -1) { - throw PlatformException(code: '-1', message: "Can't find the flowy plugin type: $pluginType"); + throw PlatformException( + code: '-1', message: "Can't find the flowy plugin type: $pluginType"); } return index; } @@ -27,9 +31,11 @@ class PluginSandbox { return plugin; } - void registerPlugin(PluginType pluginType, PluginBuilder builder, {PluginConfig? config}) { + void registerPlugin(PluginType pluginType, PluginBuilder builder, + {PluginConfig? config}) { if (_pluginBuilders.containsKey(pluginType)) { - throw PlatformException(code: '-1', message: "$pluginType was registered before"); + throw PlatformException( + code: '-1', message: "$pluginType was registered before"); } _pluginBuilders[pluginType] = builder; @@ -38,7 +44,7 @@ class PluginSandbox { } } - List get supportPluginTypes => _pluginBuilders.keys.toList(); + List get supportPluginTypes => _pluginBuilders.keys.toList(); List get builders => _pluginBuilders.values.toList(); diff --git a/frontend/app_flowy/lib/user/application/user_service.dart b/frontend/app_flowy/lib/user/application/user_service.dart index 48bea6aa41..35e32f2eb1 100644 --- a/frontend/app_flowy/lib/user/application/user_service.dart +++ b/frontend/app_flowy/lib/user/application/user_service.dart @@ -11,7 +11,8 @@ class UserService { UserService({ required this.userId, }); - Future> getUserProfile({required String userId}) { + Future> getUserProfile( + {required String userId}) { return UserEventGetUserProfile().send(); } @@ -19,6 +20,7 @@ class UserService { String? name, String? password, String? email, + String? iconUrl, }) { var payload = UpdateUserProfilePayloadPB.create()..id = userId; @@ -34,10 +36,15 @@ class UserService { payload.email = email; } + if (iconUrl != null) { + payload.iconUrl = iconUrl; + } + return UserEventUpdateUserProfile(payload).send(); } - Future> deleteWorkspace({required String workspaceId}) { + Future> deleteWorkspace( + {required String workspaceId}) { throw UnimplementedError(); } @@ -70,7 +77,8 @@ class UserService { }); } - Future> createWorkspace(String name, String desc) { + Future> createWorkspace( + String name, String desc) { final request = CreateWorkspacePayloadPB.create() ..name = name ..desc = desc; diff --git a/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart b/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart index f86bb5855b..61db9a0cdf 100644 --- a/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart +++ b/frontend/app_flowy/lib/workspace/application/app/app_bloc.dart @@ -86,6 +86,7 @@ class AppBloc extends Bloc { desc: value.desc, dataType: value.dataType, pluginType: value.pluginType, + layout: value.layout, ); viewOrFailed.fold( (view) => emit(state.copyWith( @@ -138,7 +139,8 @@ class AppEvent with _$AppEvent { const factory AppEvent.createView( String name, String desc, - PluginDataType dataType, + ViewDataTypePB dataType, + ViewLayoutTypePB layout, PluginType pluginType, ) = CreateView; const factory AppEvent.delete() = Delete; diff --git a/frontend/app_flowy/lib/workspace/application/app/app_service.dart b/frontend/app_flowy/lib/workspace/application/app/app_service.dart index 695f2f4b6b..e2a6896066 100644 --- a/frontend/app_flowy/lib/workspace/application/app/app_service.dart +++ b/frontend/app_flowy/lib/workspace/application/app/app_service.dart @@ -24,15 +24,16 @@ class AppService { required String appId, required String name, required String desc, - required PluginDataType dataType, + required ViewDataTypePB dataType, required PluginType pluginType, + required ViewLayoutTypePB layout, }) { - final payload = CreateViewPayloadPB.create() + var payload = CreateViewPayloadPB.create() ..belongToId = appId ..name = name ..desc = desc ..dataType = dataType - ..pluginType = pluginType; + ..layout = layout; return FolderEventCreateView(payload).send(); } diff --git a/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart b/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart index 4b699570e6..debb1e71c3 100644 --- a/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart +++ b/frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart @@ -113,6 +113,6 @@ class MenuState with _$MenuState { factory MenuState.initial() => MenuState( apps: [], successOrFailure: left(unit), - plugin: makePlugin(pluginType: DefaultPlugin.blank.type()), + plugin: makePlugin(pluginType: PluginType.blank), ); } diff --git a/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart b/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart index 7435778471..de63777812 100644 --- a/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart +++ b/frontend/app_flowy/lib/workspace/application/user/settings_user_bloc.dart @@ -35,6 +35,14 @@ class SettingsUserViewBloc extends Bloc { ); }); }, + updateUserIcon: (String iconUrl) { + _userService.updateUserProfile(iconUrl: iconUrl).then((result) { + result.fold( + (l) => null, + (err) => Log.error(err), + ); + }); + }, ); }); } @@ -52,7 +60,8 @@ class SettingsUserViewBloc extends Bloc { void _profileUpdated(Either userProfileOrFailed) { userProfileOrFailed.fold( - (newUserProfile) => add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)), + (newUserProfile) => + add(SettingsUserEvent.didReceiveUserProfile(newUserProfile)), (err) => Log.error(err), ); } @@ -62,7 +71,10 @@ class SettingsUserViewBloc extends Bloc { class SettingsUserEvent with _$SettingsUserEvent { const factory SettingsUserEvent.initial() = _Initial; const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName; - const factory SettingsUserEvent.didReceiveUserProfile(UserProfilePB newUserProfile) = _DidReceiveUserProfile; + const factory SettingsUserEvent.updateUserIcon(String iconUrl) = + _UpdateUserIcon; + const factory SettingsUserEvent.didReceiveUserProfile( + UserProfilePB newUserProfile) = _DidReceiveUserProfile; } @freezed @@ -72,7 +84,8 @@ class SettingsUserState with _$SettingsUserState { required Either successOrFailure, }) = _SettingsUserState; - factory SettingsUserState.initial(UserProfilePB userProfile) => SettingsUserState( + factory SettingsUserState.initial(UserProfilePB userProfile) => + SettingsUserState( userProfile: userProfile, successOrFailure: left(unit), ); diff --git a/frontend/app_flowy/lib/workspace/application/view/view_ext.dart b/frontend/app_flowy/lib/workspace/application/view/view_ext.dart index 79a27b64ce..3684d9670c 100644 --- a/frontend/app_flowy/lib/workspace/application/view/view_ext.dart +++ b/frontend/app_flowy/lib/workspace/application/view/view_ext.dart @@ -40,6 +40,19 @@ extension ViewExtension on ViewPB { return widget; } + PluginType get pluginType { + switch (layout) { + case ViewLayoutTypePB.Board: + return PluginType.board; + case ViewLayoutTypePB.Document: + return PluginType.editor; + case ViewLayoutTypePB.Grid: + return PluginType.grid; + } + + throw UnimplementedError; + } + Plugin plugin() { final plugin = makePlugin(pluginType: pluginType, data: this); return plugin; diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart index dde5091d44..5194b9edb7 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_layout.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:app_flowy/workspace/application/home/home_bloc.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/time/duration.dart'; @@ -15,6 +17,7 @@ class HomeLayout { late double editPanelWidth; late double homePageLOffset; late double homePageROffset; + late double menuSpacing; late Duration animDuration; HomeLayout(BuildContext context, BoxConstraints homeScreenConstraint, @@ -37,6 +40,8 @@ class HomeLayout { } homePageLOffset = showMenu ? menuWidth : 0.0; + + menuSpacing = !showMenu && Platform.isMacOS ? 80.0 : 0.0; animDuration = .35.seconds; editPanelWidth = HomeSizes.editPanelWidth; diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart index 508fbd1759..aac9f214bc 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_screen.dart @@ -1,6 +1,8 @@ import 'package:app_flowy/startup/plugin/plugin.dart'; import 'package:app_flowy/workspace/application/home/home_bloc.dart'; + import 'package:app_flowy/workspace/presentation/home/hotkeys.dart'; +import 'package:app_flowy/workspace/application/view/view_ext.dart'; import 'package:app_flowy/workspace/presentation/widgets/edit_panel/panel_animation.dart'; import 'package:app_flowy/workspace/presentation/widgets/float_bubble/question_bubble.dart'; import 'package:app_flowy/startup/startup.dart'; @@ -89,7 +91,9 @@ class _HomeScreenState extends State { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final layout = HomeLayout(context, constraints, state.forceCollapse); - const homeStack = HomeStack(); + final homeStack = HomeStack( + layout: layout, + ); final menu = _buildHomeMenu( layout: layout, context: context, diff --git a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart index d58f849cd7..5099dcddfc 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart @@ -1,13 +1,9 @@ -import 'dart:io' show Platform; - import 'package:app_flowy/startup/startup.dart'; -import 'package:app_flowy/workspace/application/home/home_bloc.dart'; import 'package:app_flowy/plugins/blank/blank.dart'; import 'package:app_flowy/workspace/presentation/home/toast.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_sdk/log.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import 'package:time/time.dart'; import 'package:app_flowy/startup/plugin/plugin.dart'; @@ -17,11 +13,14 @@ import 'package:app_flowy/core/frameless_window.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/style_widget/extension.dart'; import 'package:flowy_infra/notifier.dart'; +import 'home_layout.dart'; typedef NavigationCallback = void Function(String id); class HomeStack extends StatelessWidget { - const HomeStack({Key? key}) : super(key: key); + const HomeStack({Key? key, required this.layout}) : super(key: key); + + final HomeLayout layout; @override Widget build(BuildContext context) { @@ -30,7 +29,7 @@ class HomeStack extends StatelessWidget { return Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - getIt().stackTopBar(), + getIt().stackTopBar(layout: layout), Expanded( child: Container( color: theme.surface, @@ -108,7 +107,7 @@ class HomeStackNotifier extends ChangeNotifier { Widget get titleWidget => _plugin.display.leftBarItem; HomeStackNotifier({Plugin? plugin}) - : _plugin = plugin ?? makePlugin(pluginType: DefaultPlugin.blank.type()); + : _plugin = plugin ?? makePlugin(pluginType: PluginType.blank); set plugin(Plugin newPlugin) { if (newPlugin.id == _plugin.id) { @@ -143,7 +142,7 @@ class HomeStackManager { void setStackWithId(String id) {} - Widget stackTopBar() { + Widget stackTopBar({required HomeLayout layout}) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: _notifier), @@ -151,7 +150,7 @@ class HomeStackManager { child: Selector( selector: (context, notifier) => notifier.titleWidget, builder: (context, widget, child) { - return const MoveWindowDetector(child: HomeTopBar()); + return MoveWindowDetector(child: HomeTopBar(layout: layout)); }, ), ); @@ -181,7 +180,9 @@ class HomeStackManager { } class HomeTopBar extends StatelessWidget { - const HomeTopBar({Key? key}) : super(key: key); + const HomeTopBar({Key? key, required this.layout}) : super(key: key); + + final HomeLayout layout; @override Widget build(BuildContext context) { @@ -192,15 +193,7 @@ class HomeTopBar extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - BlocBuilder( - buildWhen: ((previous, current) => - previous.isMenuCollapsed != current.isMenuCollapsed), - builder: (context, state) { - if (state.isMenuCollapsed && Platform.isMacOS) { - return const HSpace(80); - } - return const HSpace(0); - }), + HSpace(layout.menuSpacing), const FlowyNavigation(), const HSpace(16), ChangeNotifierProvider.value( 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 dbeb2248cc..9d02f1a013 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 @@ -49,7 +49,9 @@ class MenuAppHeader extends StatelessWidget { height: MenuAppSizes.headerHeight, child: InkWell( onTap: () { - ExpandableController.of(context, rebuildOnChange: false, required: true)?.toggle(); + ExpandableController.of(context, + rebuildOnChange: false, required: true) + ?.toggle(); }, child: ExpandableIcon( theme: ExpandableThemeData( @@ -68,18 +70,23 @@ class MenuAppHeader extends StatelessWidget { Widget _renderTitle(BuildContext context, AppTheme theme) { return Expanded( child: BlocListener( - listenWhen: (p, c) => (p.latestCreatedView == null && c.latestCreatedView != null), + listenWhen: (p, c) => + (p.latestCreatedView == null && c.latestCreatedView != null), listener: (context, state) { - final expandableController = ExpandableController.of(context, rebuildOnChange: false, required: true)!; + final expandableController = ExpandableController.of(context, + rebuildOnChange: false, required: true)!; if (!expandableController.expanded) { expandableController.toggle(); } }, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => ExpandableController.of(context, rebuildOnChange: false, required: true)?.toggle(), + onTap: () => ExpandableController.of(context, + rebuildOnChange: false, required: true) + ?.toggle(), onSecondaryTap: () { - final actionList = AppDisclosureActionSheet(onSelected: (action) => _handleAction(context, action)); + final actionList = AppDisclosureActionSheet( + onSelected: (action) => _handleAction(context, action)); actionList.show( context, anchorDirection: AnchorDirection.bottomWithCenterAligned, @@ -107,6 +114,7 @@ class MenuAppHeader extends StatelessWidget { LocaleKeys.menuAppHeader_defaultNewPageName.tr(), "", pluginBuilder.dataType, + pluginBuilder.subDataType!, pluginBuilder.pluginType, )); }, diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/disclosure_action.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/disclosure_action.dart index c9a02a9de7..019f674b01 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/disclosure_action.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/app/section/disclosure_action.dart @@ -3,6 +3,7 @@ import 'package:dartz/dartz.dart' as dartz; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flowy_infra/theme.dart'; import 'package:provider/provider.dart'; @@ -11,10 +12,13 @@ import 'item.dart'; // [[Widget: LifeCycle]] // https://flutterbyexample.com/lesson/stateful-widget-lifecycle -class ViewDisclosureButton extends StatelessWidget with ActionList, FlowyOverlayDelegate { +class ViewDisclosureButton extends StatelessWidget + with ActionList, FlowyOverlayDelegate { final Function() onTap; final Function(dartz.Option) onSelected; - final _items = ViewDisclosureAction.values.map((action) => ViewDisclosureActionWrapper(action)).toList(); + final _items = ViewDisclosureAction.values + .map((action) => ViewDisclosureActionWrapper(action)) + .toList(); ViewDisclosureButton({ Key? key, @@ -40,12 +44,13 @@ class ViewDisclosureButton extends StatelessWidget with ActionList get items => _items; @override - void Function(dartz.Option p1) get selectCallback => (result) { - result.fold( - () => onSelected(dartz.none()), - (wrapper) => onSelected(dartz.some(wrapper.inner)), - ); - }; + void Function(dartz.Option p1) + get selectCallback => (result) { + result.fold( + () => onSelected(dartz.none()), + (wrapper) => onSelected(dartz.some(wrapper.inner)), + ); + }; @override FlowyOverlayDelegate? get delegate => this; @@ -56,6 +61,63 @@ class ViewDisclosureButton extends StatelessWidget with ActionList, FlowyOverlayDelegate { + final Widget child; + final Function() onTap; + final Function(dartz.Option) onSelected; + final _items = ViewDisclosureAction.values + .map((action) => ViewDisclosureActionWrapper(action)) + .toList(); + + ViewDisclosureRegion( + {Key? key, + required this.onSelected, + required this.onTap, + required this.child}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: (event) => {_handleClick(event, context)}, + child: child, + ); + } + + @override + FlowyOverlayDelegate? get delegate => this; + + @override + List get items => _items; + + @override + void Function(dartz.Option p1) + get selectCallback => (result) { + result.fold( + () => onSelected(dartz.none()), + (wrapper) => onSelected(dartz.some(wrapper.inner)), + ); + }; + + @override + void didRemove() { + onSelected(dartz.none()); + } + + void _handleClick(PointerDownEvent event, BuildContext context) { + if (event.kind == PointerDeviceKind.mouse && + event.buttons == kSecondaryMouseButton) { + RenderBox box = context.findRenderObject() as RenderBox; + Offset position = box.localToGlobal(Offset.zero); + double x = event.position.dx - position.dx - box.size.width; + double y = event.position.dy - position.dy - box.size.height; + onTap(); + show(context, anchorOffset: Offset(x, y)); + } + } +} + class ViewDisclosureActionWrapper extends ActionItem { final ViewDisclosureAction inner; 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 97ae1dae52..cb3146a70c 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 @@ -36,37 +36,57 @@ class ViewSectionItem extends StatelessWidget { final theme = context.watch(); return MultiBlocProvider( providers: [ - BlocProvider(create: (ctx) => getIt(param1: view)..add(const ViewEvent.initial())), + BlocProvider( + create: (ctx) => + getIt(param1: view)..add(const ViewEvent.initial())), ], child: BlocBuilder( builder: (context, state) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: InkWell( - onTap: () => onSelected(context.read().state.view), - child: FlowyHover( - style: HoverStyle(hoverColor: theme.bg3), - builder: (_, onHover) => _render(context, onHover, state, theme.iconColor), - setSelected: () => state.isEditing || isSelected, - ), - ), - ); + return ViewDisclosureRegion( + onTap: () => context + .read() + .add(const ViewEvent.setIsEditing(true)), + onSelected: (action) { + context + .read() + .add(const ViewEvent.setIsEditing(false)); + _handleAction(context, action); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: InkWell( + onTap: () => onSelected(context.read().state.view), + child: FlowyHover( + style: HoverStyle(hoverColor: theme.bg3), + builder: (_, onHover) => + _render(context, onHover, state, theme.iconColor), + setSelected: () => state.isEditing || isSelected, + ), + ), + )); }, ), ); } - Widget _render(BuildContext context, bool onHover, ViewState state, Color iconColor) { + Widget _render( + BuildContext context, bool onHover, ViewState state, Color iconColor) { List children = [ - SizedBox(width: 16, height: 16, child: state.view.renderThumbnail(iconColor: iconColor)), + SizedBox( + width: 16, + height: 16, + child: state.view.renderThumbnail(iconColor: iconColor)), const HSpace(2), - Expanded(child: FlowyText.regular(state.view.name, fontSize: 12, overflow: TextOverflow.clip)), + Expanded( + child: FlowyText.regular(state.view.name, + fontSize: 12, overflow: TextOverflow.clip)), ]; if (onHover || state.isEditing) { children.add( ViewDisclosureButton( - onTap: () => context.read().add(const ViewEvent.setIsEditing(true)), + onTap: () => + context.read().add(const ViewEvent.setIsEditing(true)), onSelected: (action) { context.read().add(const ViewEvent.setIsEditing(false)); _handleAction(context, action); @@ -84,7 +104,8 @@ class ViewSectionItem extends StatelessWidget { ); } - void _handleAction(BuildContext context, dartz.Option action) { + void _handleAction( + BuildContext context, dartz.Option action) { action.foldRight({}, (action, previous) { switch (action) { case ViewDisclosureAction.rename: diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart index d7b8dce4af..3d9d76fe29 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu_user.dart @@ -1,6 +1,7 @@ import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/workspace/application/menu/menu_user_bloc.dart'; import 'package:app_flowy/workspace/presentation/settings/settings_dialog.dart'; +import 'package:app_flowy/workspace/presentation/settings/widgets/settings_user_view.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; @@ -19,7 +20,8 @@ class MenuUser extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => getIt(param1: user)..add(const MenuUserEvent.initial()), + create: (context) => + getIt(param1: user)..add(const MenuUserEvent.initial()), child: BlocBuilder( builder: (context, state) => Row( children: [ @@ -39,20 +41,19 @@ class MenuUser extends StatelessWidget { } Widget _renderAvatar(BuildContext context) { - return const SizedBox( + String iconUrl = context.read().state.userProfile.iconUrl; + if (iconUrl.isEmpty) { + iconUrl = defaultUserAvatar; + } + + return SizedBox( width: 25, height: 25, child: ClipRRect( borderRadius: Corners.s5Border, child: CircleAvatar( - backgroundColor: Color.fromRGBO(132, 39, 224, 1.0), - child: Text( - 'M', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w300, - ), - ), + backgroundColor: Colors.transparent, + child: svgWidget('emoji/$iconUrl'), )), ); } diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart index f8f094d1b0..ceabdd9b7b 100644 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_user_view.dart @@ -2,7 +2,13 @@ import 'package:app_flowy/startup/startup.dart'; import 'package:flutter/material.dart'; import 'package:app_flowy/workspace/application/user/settings_user_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_sdk/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra/image.dart'; + +import 'dart:convert'; + +const defaultUserAvatar = '1F600'; class SettingsUserView extends StatelessWidget { final UserProfilePB user; @@ -11,12 +17,17 @@ class SettingsUserView extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => getIt(param1: user)..add(const SettingsUserEvent.initial()), + create: (context) => getIt(param1: user) + ..add(const SettingsUserEvent.initial()), child: BlocBuilder( builder: (context, state) => SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [_renderUserNameInput(context)], + children: [ + _renderUserNameInput(context), + const VSpace(20), + _renderCurrentIcon(context) + ], ), ), ), @@ -27,6 +38,15 @@ class SettingsUserView extends StatelessWidget { String name = context.read().state.userProfile.name; return _UserNameInput(name); } + + Widget _renderCurrentIcon(BuildContext context) { + String iconUrl = + context.read().state.userProfile.iconUrl; + if (iconUrl.isEmpty) { + iconUrl = defaultUserAvatar; + } + return _CurrentIcon(iconUrl); + } } class _UserNameInput extends StatelessWidget { @@ -44,7 +64,121 @@ class _UserNameInput extends StatelessWidget { labelText: 'Name', ), onSubmitted: (val) { - context.read().add(SettingsUserEvent.updateUserName(val)); + context + .read() + .add(SettingsUserEvent.updateUserName(val)); }); } } + +class _CurrentIcon extends StatelessWidget { + final String iconUrl; + const _CurrentIcon(this.iconUrl, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + _setIcon(String iconUrl) { + context + .read() + .add(SettingsUserEvent.updateUserIcon(iconUrl)); + Navigator.of(context).pop(); + } + + return Material( + color: Colors.transparent, + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: const Text('Select an Icon'), + children: [ + SizedBox( + height: 300, width: 300, child: IconGallery(_setIcon)) + ]); + }, + ); + }, + child: Column(children: [ + const Align( + alignment: Alignment.topLeft, + child: Text( + "Icon", + style: TextStyle(color: Colors.grey), + )), + Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.all(5.0), + decoration: + BoxDecoration(border: Border.all(color: Colors.grey)), + child: svgWithSize('emoji/$iconUrl', const Size(60, 60)), + )), + ])), + ); + } +} + +class IconGallery extends StatelessWidget { + final Function setIcon; + const IconGallery(this.setIcon, {Key? key}) : super(key: key); + + Future> _getIcons(BuildContext context) async { + final manifestContent = + await DefaultAssetBundle.of(context).loadString('AssetManifest.json'); + + final Map manifestMap = json.decode(manifestContent); + + final iconUrls = manifestMap.keys + .where((String key) => + key.startsWith('assets/images/emoji/') && key.endsWith('.svg')) + .map((String key) => key.split('/').last.split('.').first) + .toList(); + + return iconUrls; + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _getIcons(context), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasData) { + return GridView.count( + padding: const EdgeInsets.all(20), + crossAxisCount: 5, + children: (snapshot.data ?? []).map((String iconUrl) { + return IconOption(iconUrl, setIcon); + }).toList(), + ); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } +} + +class IconOption extends StatelessWidget { + final String iconUrl; + final Function setIcon; + + IconOption(this.iconUrl, this.setIcon, {Key? key}) + : super(key: ValueKey(iconUrl)); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: GestureDetector( + onTap: () { + setIcon(iconUrl); + }, + child: svgWidget('emoji/$iconUrl'), + ), + ); + } +} diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart index b5d364f67e..d012b0e7ca 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_lists.dart @@ -1632,7 +1632,7 @@ final Map activities = Map.fromIterables([ 'Flying Disc', 'Bowling', 'Cricket Game', - 'GridFieldPB Hockey', + 'FieldPB Hockey', 'Ice Hockey', 'Lacrosse', 'Ping Pong', diff --git a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc index 9fdfa38294..ae6ec7ed89 100644 --- a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc +++ b/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flowy_infra_ui_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlowyInfraUIPlugin"); flowy_infra_u_i_plugin_register_with_registrar(flowy_infra_ui_registrar); + g_autoptr(FlPluginRegistrar) rich_clipboard_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RichClipboardPlugin"); + rich_clipboard_plugin_register_with_registrar(rich_clipboard_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/frontend/app_flowy/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/linux/flutter/generated_plugins.cmake index c7ae414da2..3e0d068b6a 100644 --- a/frontend/app_flowy/linux/flutter/generated_plugins.cmake +++ b/frontend/app_flowy/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flowy_infra_ui + rich_clipboard_linux url_launcher_linux window_size ) diff --git a/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift index 8d97942332..60d2b8c792 100644 --- a/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import flowy_infra_ui import flowy_sdk import package_info_plus_macos import path_provider_macos +import rich_clipboard_macos import shared_preferences_macos import url_launcher_macos import window_size @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart index d692a6fe20..83f75d2a0e 100644 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart +++ b/frontend/app_flowy/packages/appflowy_board/example/lib/multi_board_list_example.dart @@ -9,7 +9,7 @@ class MultiBoardListExample extends StatefulWidget { } class _MultiBoardListExampleState extends State { - final BoardDataController boardDataController = BoardDataController( + final AFBoardDataController boardDataController = AFBoardDataController( onMoveColumn: (fromIndex, toIndex) { debugPrint('Move column from $fromIndex to $toIndex'); }, @@ -23,18 +23,19 @@ class _MultiBoardListExampleState extends State { @override void initState() { - final column1 = BoardColumnData(id: "To Do", items: [ + List a = [ TextItem("Card 1"), TextItem("Card 2"), - RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'), + // RichTextItem(title: "Card 3", subtitle: 'Aug 1, 2020 4:05 PM'), TextItem("Card 4"), - ]); - final column2 = BoardColumnData(id: "In Progress", items: [ - RichTextItem(title: "Card 5", subtitle: 'Aug 1, 2020 4:05 PM'), - TextItem("Card 6"), + ]; + final column1 = AFBoardColumnData(id: "To Do", items: a); + final column2 = AFBoardColumnData(id: "In Progress", items: [ + // RichTextItem(title: "Card 5", subtitle: 'Aug 1, 2020 4:05 PM'), + // TextItem("Card 6"), ]); - final column3 = BoardColumnData(id: "Done", items: []); + final column3 = AFBoardColumnData(id: "Done", items: []); boardDataController.addColumn(column1); boardDataController.addColumn(column2); @@ -45,14 +46,14 @@ class _MultiBoardListExampleState extends State { @override Widget build(BuildContext context) { - final config = BoardConfig( + final config = AFBoardConfig( columnBackgroundColor: HexColor.fromHex('#F7F8FC'), ); return Container( color: Colors.white, child: Padding( padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 20), - child: Board( + child: AFBoard( dataController: boardDataController, footBuilder: (context, columnData) { return AppFlowyColumnFooter( @@ -79,7 +80,7 @@ class _MultiBoardListExampleState extends State { ); }, columnConstraints: const BoxConstraints.tightFor(width: 240), - config: BoardConfig( + config: AFBoardConfig( columnBackgroundColor: HexColor.fromHex('#F7F8FC'), ), ), @@ -87,7 +88,7 @@ class _MultiBoardListExampleState extends State { ); } - Widget _buildCard(ColumnItem item) { + Widget _buildCard(AFColumnItem item) { if (item is TextItem) { return Align( alignment: Alignment.centerLeft, @@ -126,7 +127,7 @@ class _MultiBoardListExampleState extends State { } } -class TextItem extends ColumnItem { +class TextItem extends AFColumnItem { final String s; TextItem(this.s); @@ -135,7 +136,7 @@ class TextItem extends ColumnItem { String get id => s; } -class RichTextItem extends ColumnItem { +class RichTextItem extends AFColumnItem { final String title; final String subtitle; diff --git a/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart b/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart index 655f1439c1..97e83df448 100644 --- a/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart +++ b/frontend/app_flowy/packages/appflowy_board/example/lib/single_board_list_example.dart @@ -9,11 +9,11 @@ class SingleBoardListExample extends StatefulWidget { } class _SingleBoardListExampleState extends State { - final BoardDataController boardData = BoardDataController(); + final AFBoardDataController boardData = AFBoardDataController(); @override void initState() { - final column = BoardColumnData(id: "1", items: [ + final column = AFBoardColumnData(id: "1", items: [ TextItem("a"), TextItem("b"), TextItem("c"), @@ -26,7 +26,7 @@ class _SingleBoardListExampleState extends State { @override Widget build(BuildContext context) { - return Board( + return AFBoard( dataController: boardData, cardBuilder: (context, item) { return _RowWidget(item: item as TextItem, key: ObjectKey(item)); @@ -50,7 +50,7 @@ class _RowWidget extends StatelessWidget { } } -class TextItem extends ColumnItem { +class TextItem extends AFColumnItem { final String s; TextItem(this.s); diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart index b9f766f961..20f810a966 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/utils/log.dart @@ -6,7 +6,7 @@ const DART_LOG = "Dart_LOG"; class Log { // static const enableLog = bool.hasEnvironment(DART_LOG); // static final shared = Log(); - static const enableLog = true; + static const enableLog = false; static void info(String? message) { if (enableLog) { @@ -20,6 +20,12 @@ class Log { } } + static void warn(String? message) { + if (enableLog) { + debugPrint('🐛[Warn]=> $message'); + } + } + static void trace(String? message) { if (enableLog) { // debugPrint('❗️[Trace]=> $message'); diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart index 3cd2a331f1..20824ba6b9 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board.dart @@ -3,18 +3,18 @@ import 'package:provider/provider.dart'; import 'board_column/board_column.dart'; import 'board_column/board_column_data.dart'; import 'board_data.dart'; -import 'reorder_flex/drag_target_inteceptor.dart'; +import 'reorder_flex/drag_target_interceptor.dart'; import 'reorder_flex/reorder_flex.dart'; import 'reorder_phantom/phantom_controller.dart'; import '../rendering/board_overlay.dart'; -class BoardConfig { +class AFBoardConfig { final double cornerRadius; final EdgeInsets columnPadding; final EdgeInsets columnItemPadding; final Color columnBackgroundColor; - const BoardConfig({ + const AFBoardConfig({ this.cornerRadius = 6.0, this.columnPadding = const EdgeInsets.symmetric(horizontal: 8), this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 10), @@ -22,7 +22,7 @@ class BoardConfig { }); } -class Board extends StatelessWidget { +class AFBoard extends StatelessWidget { /// The direction to use as the main axis. final Axis direction = Axis.vertical; @@ -30,32 +30,35 @@ class Board extends StatelessWidget { final Widget? background; /// - final BoardColumnCardBuilder cardBuilder; + final AFBoardColumnCardBuilder cardBuilder; /// - final BoardColumnHeaderBuilder? headerBuilder; + final AFBoardColumnHeaderBuilder? headerBuilder; /// - final BoardColumnFooterBuilder? footBuilder; + final AFBoardColumnFooterBuilder? footBuilder; /// - final BoardDataController dataController; + final AFBoardDataController dataController; final BoxConstraints columnConstraints; /// final BoardPhantomController phantomController; - final BoardConfig config; + final ScrollController? scrollController; - Board({ + final AFBoardConfig config; + + AFBoard({ required this.dataController, required this.cardBuilder, this.background, this.footBuilder, this.headerBuilder, + this.scrollController, this.columnConstraints = const BoxConstraints(maxWidth: 200), - this.config = const BoardConfig(), + this.config = const AFBoardConfig(), Key? key, }) : phantomController = BoardPhantomController(delegate: dataController), super(key: key); @@ -64,11 +67,12 @@ class Board extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: dataController, - child: Consumer( + child: Consumer( builder: (context, notifier, child) { return BoardContent( config: config, dataController: dataController, + scrollController: scrollController, background: background, delegate: phantomController, columnConstraints: columnConstraints, @@ -89,20 +93,20 @@ class BoardContent extends StatefulWidget { final OnDragStarted? onDragStarted; final OnReorder onReorder; final OnDragEnded? onDragEnded; - final BoardDataController dataController; + final AFBoardDataController dataController; final Widget? background; - final BoardConfig config; + final AFBoardConfig config; final ReorderFlexConfig reorderFlexConfig; final BoxConstraints columnConstraints; /// - final BoardColumnCardBuilder cardBuilder; + final AFBoardColumnCardBuilder cardBuilder; /// - final BoardColumnHeaderBuilder? headerBuilder; + final AFBoardColumnHeaderBuilder? headerBuilder; /// - final BoardColumnFooterBuilder? footBuilder; + final AFBoardColumnFooterBuilder? footBuilder; final OverlapDragTargetDelegate delegate; @@ -139,7 +143,7 @@ class _BoardContentState extends State { void initState() { _overlayEntry = BoardOverlayEntry( builder: (BuildContext context) { - final interceptor = OverlappingDragTargetInteceptor( + final interceptor = OverlappingDragTargetInterceptor( reorderFlexId: widget.dataController.identifier, acceptedReorderFlexId: widget.dataController.columnIds, delegate: widget.delegate, @@ -202,11 +206,11 @@ class _BoardContentState extends State { return ChangeNotifierProvider.value( key: ValueKey(columnData.id), value: widget.dataController.columnController(columnData.id), - child: Consumer( + child: Consumer( builder: (context, value, child) { return ConstrainedBox( constraints: widget.columnConstraints, - child: BoardColumnWidget( + child: AFBoardColumnWidget( margin: _marginFromIndex(columnIndex), itemMargin: widget.config.columnItemPadding, headerBuilder: widget.headerBuilder, @@ -246,9 +250,9 @@ class _BoardContentState extends State { } } -class _BoardColumnDataSourceImpl extends BoardColumnDataDataSource { +class _BoardColumnDataSourceImpl extends AFBoardColumnDataDataSource { String columnId; - final BoardDataController dataController; + final AFBoardDataController dataController; _BoardColumnDataSourceImpl({ required this.columnId, @@ -256,7 +260,7 @@ class _BoardColumnDataSourceImpl extends BoardColumnDataDataSource { }); @override - BoardColumnData get columnData => + AFBoardColumnData get columnData => dataController.columnController(columnId).columnData; @override diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column.dart index d8981096e3..cbc537810e 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column.dart @@ -5,7 +5,7 @@ import '../../rendering/board_overlay.dart'; import '../../utils/log.dart'; import '../reorder_phantom/phantom_controller.dart'; import '../reorder_flex/reorder_flex.dart'; -import '../reorder_flex/drag_target_inteceptor.dart'; +import '../reorder_flex/drag_target_interceptor.dart'; import 'board_column_data.dart'; typedef OnColumnDragStarted = void Function(int index); @@ -22,23 +22,23 @@ typedef OnColumnDeleted = void Function(String listId, int deletedIndex); typedef OnColumnInserted = void Function(String listId, int insertedIndex); -typedef BoardColumnCardBuilder = Widget Function( +typedef AFBoardColumnCardBuilder = Widget Function( BuildContext context, - ColumnItem item, + AFColumnItem item, ); -typedef BoardColumnHeaderBuilder = Widget Function( +typedef AFBoardColumnHeaderBuilder = Widget Function( BuildContext context, - BoardColumnData columnData, + AFBoardColumnData columnData, ); -typedef BoardColumnFooterBuilder = Widget Function( +typedef AFBoardColumnFooterBuilder = Widget Function( BuildContext context, - BoardColumnData columnData, + AFBoardColumnData columnData, ); -abstract class BoardColumnDataDataSource extends ReoderFlextDataSource { - BoardColumnData get columnData; +abstract class AFBoardColumnDataDataSource extends ReoderFlexDataSource { + AFBoardColumnData get columnData; List get acceptedColumnIds; @@ -46,10 +46,10 @@ abstract class BoardColumnDataDataSource extends ReoderFlextDataSource { String get identifier => columnData.id; @override - UnmodifiableListView get items => columnData.items; + UnmodifiableListView get items => columnData.items; void debugPrint() { - String msg = '[$BoardColumnDataDataSource] $columnData data: '; + String msg = '[$AFBoardColumnDataDataSource] $columnData data: '; for (var element in items) { msg = '$msg$element,'; } @@ -58,10 +58,10 @@ abstract class BoardColumnDataDataSource extends ReoderFlextDataSource { } } -/// [BoardColumnWidget] represents the column of the Board. +/// [AFBoardColumnWidget] represents the column of the Board. /// -class BoardColumnWidget extends StatefulWidget { - final BoardColumnDataDataSource dataSource; +class AFBoardColumnWidget extends StatefulWidget { + final AFBoardColumnDataDataSource dataSource; final ScrollController? scrollController; final ReorderFlexConfig config; @@ -73,11 +73,11 @@ class BoardColumnWidget extends StatefulWidget { String get columnId => dataSource.columnData.id; - final BoardColumnCardBuilder cardBuilder; + final AFBoardColumnCardBuilder cardBuilder; - final BoardColumnHeaderBuilder? headerBuilder; + final AFBoardColumnHeaderBuilder? headerBuilder; - final BoardColumnFooterBuilder? footBuilder; + final AFBoardColumnFooterBuilder? footBuilder; final EdgeInsets margin; @@ -87,7 +87,7 @@ class BoardColumnWidget extends StatefulWidget { final Color backgroundColor; - const BoardColumnWidget({ + const AFBoardColumnWidget({ Key? key, this.headerBuilder, this.footBuilder, @@ -106,12 +106,12 @@ class BoardColumnWidget extends StatefulWidget { super(key: key); @override - State createState() => _BoardColumnWidgetState(); + State createState() => _AFBoardColumnWidgetState(); } -class _BoardColumnWidgetState extends State { +class _AFBoardColumnWidgetState extends State { final GlobalKey _columnOverlayKey = - GlobalKey(debugLabel: '$BoardColumnWidget overlay key'); + GlobalKey(debugLabel: '$AFBoardColumnWidget overlay key'); late BoardOverlayEntry _overlayEntry; @@ -194,7 +194,7 @@ class _BoardColumnWidgetState extends State { ); } - Widget _buildWidget(BuildContext context, ColumnItem item) { + Widget _buildWidget(BuildContext context, AFColumnItem item) { if (item is PhantomColumnItem) { return PassthroughPhantomWidget( key: UniqueKey(), diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart index 2ce739220e..6e184761c5 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_column/board_column_data.dart @@ -5,14 +5,14 @@ import 'package:flutter/material.dart'; import '../../utils/log.dart'; import '../reorder_flex/reorder_flex.dart'; -abstract class ColumnItem extends ReoderFlexItem { +abstract class AFColumnItem extends ReoderFlexItem { bool get isPhantom => false; @override String toString() => id; } -/// [BoardColumnDataController] is used to handle the [BoardColumnData]. +/// [AFBoardColumnDataController] is used to handle the [AFBoardColumnData]. /// * Remove an item by calling [removeAt] method. /// * Move item to another position by calling [move] method. /// * Insert item to index by calling [insert] method @@ -20,10 +20,10 @@ abstract class ColumnItem extends ReoderFlexItem { /// /// All there operations will notify listeners by default. /// -class BoardColumnDataController extends ChangeNotifier with EquatableMixin { - final BoardColumnData columnData; +class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin { + final AFBoardColumnData columnData; - BoardColumnDataController({ + AFBoardColumnDataController({ required this.columnData, }); @@ -31,7 +31,7 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin { List get props => columnData.props; /// Returns the readonly List - UnmodifiableListView get items => + UnmodifiableListView get items => UnmodifiableListView(columnData.items); /// Remove the item at [index]. @@ -39,10 +39,11 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin { /// * [notify] the default value of [notify] is true, it will notify the /// listener. Set to [false] if you do not want to notify the listeners. /// - ColumnItem removeAt(int index, {bool notify = true}) { + AFColumnItem removeAt(int index, {bool notify = true}) { assert(index >= 0); - Log.debug('[$BoardColumnDataController] $columnData remove item at $index'); + Log.debug( + '[$AFBoardColumnDataController] $columnData remove item at $index'); final item = columnData._items.removeAt(index); if (notify) { notifyListeners(); @@ -50,75 +51,101 @@ class BoardColumnDataController extends ChangeNotifier with EquatableMixin { return item; } - int removeWhere(bool Function(ColumnItem) condition) { - return items.indexWhere(condition); + void removeWhere(bool Function(AFColumnItem) condition) { + final index = items.indexWhere(condition); + if (index != -1) { + removeAt(index); + } } /// Move the item from [fromIndex] to [toIndex]. It will do nothing if the /// [fromIndex] equal to the [toIndex]. - void move(int fromIndex, int toIndex) { + bool move(int fromIndex, int toIndex) { assert(fromIndex >= 0); assert(toIndex >= 0); if (fromIndex == toIndex) { - return; + return false; } Log.debug( - '[$BoardColumnDataController] $columnData move item from $fromIndex to $toIndex'); + '[$AFBoardColumnDataController] $columnData move item from $fromIndex to $toIndex'); final item = columnData._items.removeAt(fromIndex); columnData._items.insert(toIndex, item); notifyListeners(); + return true; } /// Insert an item to [index] and notify the listen if the value of [notify] /// is true. /// /// The default value of [notify] is true. - void insert(int index, ColumnItem item, {bool notify = true}) { + bool insert(int index, AFColumnItem item, {bool notify = true}) { assert(index >= 0); Log.debug( - '[$BoardColumnDataController] $columnData insert $item at $index'); + '[$AFBoardColumnDataController] $columnData insert $item at $index'); - if (columnData._items.length > index) { - columnData._items.insert(index, item); + if (_containsItem(item)) { + return false; + } else { + if (columnData._items.length > index) { + columnData._items.insert(index, item); + } else { + columnData._items.add(item); + } + + if (notify) notifyListeners(); + return true; + } + } + + bool add(AFColumnItem item, {bool notify = true}) { + if (_containsItem(item)) { + return false; } else { columnData._items.add(item); - } - - if (notify) { - notifyListeners(); + if (notify) notifyListeners(); + return true; } } /// Replace the item at index with the [newItem]. - void replace(int index, ColumnItem newItem) { + void replace(int index, AFColumnItem newItem) { if (columnData._items.isEmpty) { columnData._items.add(newItem); - Log.debug('[$BoardColumnDataController] $columnData add $newItem'); + Log.debug('[$AFBoardColumnDataController] $columnData add $newItem'); } else { final removedItem = columnData._items.removeAt(index); columnData._items.insert(index, newItem); Log.debug( - '[$BoardColumnDataController] $columnData replace $removedItem with $newItem at $index'); + '[$AFBoardColumnDataController] $columnData replace $removedItem with $newItem at $index'); } notifyListeners(); } + + bool _containsItem(AFColumnItem item) { + return columnData._items.indexWhere((element) => element.id == item.id) != + -1; + } } -/// [BoardColumnData] represents the data of each Column of the Board. -class BoardColumnData extends ReoderFlexItem with EquatableMixin { +/// [AFBoardColumnData] represents the data of each Column of the Board. +class AFBoardColumnData extends ReoderFlexItem with EquatableMixin { @override final String id; - final List _items; + final String desc; + final List _items; + final CustomData? customData; - BoardColumnData({ + AFBoardColumnData({ + this.customData, required this.id, - required List items, + this.desc = "", + List items = const [], }) : _items = items; /// Returns the readonly List - UnmodifiableListView get items => UnmodifiableListView(_items); + UnmodifiableListView get items => UnmodifiableListView(_items); @override List get props => [id, ..._items]; diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart index 06e8ff1a57..6208dbd0f0 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/board_data.dart @@ -23,53 +23,105 @@ typedef OnMoveColumnItemToColumn = void Function( int toIndex, ); -class BoardDataController extends ChangeNotifier - with EquatableMixin, BoardPhantomControllerDelegate, ReoderFlextDataSource { - final List _columnDatas = []; +class AFBoardDataController extends ChangeNotifier + with EquatableMixin, BoardPhantomControllerDelegate, ReoderFlexDataSource { + final List _columnDatas = []; final OnMoveColumn? onMoveColumn; final OnMoveColumnItem? onMoveColumnItem; final OnMoveColumnItemToColumn? onMoveColumnItemToColumn; - List get columnDatas => _columnDatas; + List get columnDatas => _columnDatas; List get columnIds => _columnDatas.map((columnData) => columnData.id).toList(); - final LinkedHashMap _columnControllers = + final LinkedHashMap _columnControllers = LinkedHashMap(); - BoardDataController({ + AFBoardDataController({ this.onMoveColumn, this.onMoveColumnItem, this.onMoveColumnItemToColumn, }); - void addColumn(BoardColumnData columnData) { - final controller = BoardColumnDataController(columnData: columnData); + void addColumn(AFBoardColumnData columnData, {bool notify = true}) { + if (_columnControllers[columnData.id] != null) return; + + final controller = AFBoardColumnDataController(columnData: columnData); _columnDatas.add(columnData); _columnControllers[columnData.id] = controller; + if (notify) notifyListeners(); } - BoardColumnDataController columnController(String columnId) { + void addColumns(List columns, {bool notify = true}) { + for (final column in columns) { + addColumn(column, notify: false); + } + + if (columns.isNotEmpty && notify) notifyListeners(); + } + + void removeColumn(String columnId, {bool notify = true}) { + final index = _columnDatas.indexWhere((column) => column.id == columnId); + if (index == -1) { + Log.warn( + 'Try to remove Column:[$columnId] failed. Column:[$columnId] not exist'); + } + + if (index != -1) { + _columnDatas.removeAt(index); + _columnControllers.remove(columnId); + + if (notify) notifyListeners(); + } + } + + void removeColumns(List columnIds, {bool notify = true}) { + for (final columnId in columnIds) { + removeColumn(columnId, notify: false); + } + + if (columnIds.isNotEmpty && notify) notifyListeners(); + } + + AFBoardColumnDataController columnController(String columnId) { return _columnControllers[columnId]!; } - void moveColumn(int fromIndex, int toIndex) { + AFBoardColumnDataController? getColumnController(String columnId) { + final columnController = _columnControllers[columnId]; + if (columnController == null) { + Log.warn('Column:[$columnId] \'s controller is not exist'); + } + + return columnController; + } + + void moveColumn(int fromIndex, int toIndex, {bool notify = true}) { final columnData = _columnDatas.removeAt(fromIndex); _columnDatas.insert(toIndex, columnData); onMoveColumn?.call(fromIndex, toIndex); - notifyListeners(); + if (notify) notifyListeners(); } void moveColumnItem(String columnId, int fromIndex, int toIndex) { - final columnController = _columnControllers[columnId]; - assert(columnController != null); - if (columnController != null) { - columnController.move(fromIndex, toIndex); + if (getColumnController(columnId)?.move(fromIndex, toIndex) ?? false) { onMoveColumnItem?.call(columnId, fromIndex, toIndex); } } + void addColumnItem(String columnId, AFColumnItem item) { + getColumnController(columnId)?.add(item); + } + + void insertColumnItem(String columnId, int index, AFColumnItem item) { + getColumnController(columnId)?.insert(index, item); + } + + void removeColumnItem(String columnId, String itemId) { + getColumnController(columnId)?.removeWhere((item) => item.id == itemId); + } + @override @protected void swapColumnItem( @@ -101,12 +153,12 @@ class BoardDataController extends ChangeNotifier } @override - BoardColumnDataController? controller(String columnId) { + AFBoardColumnDataController? controller(String columnId) { return _columnControllers[columnId]; } @override - String get identifier => '$BoardDataController'; + String get identifier => '$AFBoardDataController'; @override UnmodifiableListView get items => @@ -123,7 +175,7 @@ class BoardDataController extends ChangeNotifier columnController.removeAt(index); Log.debug( - '[$BoardDataController] Column:[$columnId] remove phantom, current count: ${columnController.items.length}'); + '[$AFBoardDataController] Column:[$columnId] remove phantom, current count: ${columnController.items.length}'); } return isExist; } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart index a6a09a9770..132d3d9bc4 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target.dart @@ -13,14 +13,14 @@ abstract class ReorderFlexDraggableTargetBuilder { Widget child, DragTargetOnStarted onDragStarted, DragTargetOnEnded onDragEnded, - DragTargetWillAccpet onWillAccept, + DragTargetWillAccepted onWillAccept, AnimationController insertAnimationController, AnimationController deleteAnimationController, ); } /// -typedef DragTargetWillAccpet = bool Function( +typedef DragTargetWillAccepted = bool Function( T dragTargetData); /// @@ -51,7 +51,7 @@ class ReorderDragTarget extends StatefulWidget { /// /// [toAccept] represents the dragTarget index, which is the value passed in /// when creating the [ReorderDragTarget]. - final DragTargetWillAccpet onWillAccept; + final DragTargetWillAccepted onWillAccept; /// Called when an acceptable piece of data was dropped over this drag target. /// @@ -228,7 +228,7 @@ class DragTargetAnimation { value: 0.0, vsync: vsync, duration: const Duration(milliseconds: 10)); } - void startDargging() { + void startDragging() { entranceController.value = 1.0; } @@ -386,7 +386,7 @@ class FakeDragTarget extends StatefulWidget { final FakeDragTargetEventData eventData; final DragTargetOnStarted onDragStarted; final DragTargetOnEnded onDragEnded; - final DragTargetWillAccpet onWillAccept; + final DragTargetWillAccepted onWillAccept; final Widget child; final AnimationController insertAnimationController; final AnimationController deleteAnimationController; diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_inteceptor.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart similarity index 96% rename from frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_inteceptor.dart rename to frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart index da529819dd..be74b4eef8 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_inteceptor.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart @@ -40,18 +40,18 @@ abstract class OverlapDragTargetDelegate { bool canMoveTo(String dragTargetId); } -/// [OverlappingDragTargetInteceptor] is used to receive the overlapping +/// [OverlappingDragTargetInterceptor] is used to receive the overlapping /// [DragTarget] event. If a [DragTarget] child is [DragTarget], it will /// receive the [DragTarget] event when being dragged. /// /// Receive the [DragTarget] event if the [acceptedReorderFlexId] contains /// the passed in dragTarget' reorderFlexId. -class OverlappingDragTargetInteceptor extends DragTargetInterceptor { +class OverlappingDragTargetInterceptor extends DragTargetInterceptor { final String reorderFlexId; final List acceptedReorderFlexId; final OverlapDragTargetDelegate delegate; - OverlappingDragTargetInteceptor({ + OverlappingDragTargetInterceptor({ required this.delegate, required this.reorderFlexId, required this.acceptedReorderFlexId, diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart index 27af28d778..7fa1a405e1 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart @@ -7,25 +7,25 @@ import '../../utils/log.dart'; import 'reorder_mixin.dart'; import 'drag_target.dart'; import 'drag_state.dart'; -import 'drag_target_inteceptor.dart'; +import 'drag_target_interceptor.dart'; typedef OnDragStarted = void Function(int index); typedef OnDragEnded = void Function(); typedef OnReorder = void Function(int fromIndex, int toIndex); typedef OnDeleted = void Function(int deletedIndex); typedef OnInserted = void Function(int insertedIndex); -typedef OnReveivePassedInPhantom = void Function( +typedef OnReceivePassedInPhantom = void Function( FlexDragTargetData dragTargetData, int phantomIndex); -abstract class ReoderFlextDataSource { +abstract class ReoderFlexDataSource { /// [identifier] represents the id the [ReorderFlex]. It must be unique. String get identifier; - /// The number of [ReoderFlexItem]s will be displaied in the [ReorderFlex]. + /// The number of [ReoderFlexItem]s will be displayed in the [ReorderFlex]. UnmodifiableListView get items; } -/// Each item displaied in the [ReorderFlex] required to implement the [ReoderFlexItem]. +/// Each item displayed in the [ReorderFlex] required to implement the [ReoderFlexItem]. abstract class ReoderFlexItem { /// [id] is used to identify the item. It must be unique. String get id; @@ -70,7 +70,7 @@ class ReorderFlex extends StatefulWidget { /// [onDragEnded] is called when dragTarget did end dragging final OnDragEnded? onDragEnded; - final ReoderFlextDataSource dataSource; + final ReoderFlexDataSource dataSource; final DragTargetInterceptor? interceptor; @@ -187,7 +187,7 @@ class ReorderFlexState extends State void _requestAnimationToNextIndex({bool isAcceptingNewTarget = false}) { /// Update the dragState and animate to the next index if the current /// dragging animation is completed. Otherwise, it will get called again - /// when the animation finishs. + /// when the animation finish. if (_animation.entranceController.isCompleted) { dragState.removePhantom(); @@ -425,7 +425,7 @@ class ReorderFlexState extends State ) { setState(() { dragState.startDragging(draggingWidget, dragIndex, feedbackSize); - _animation.startDargging(); + _animation.startDragging(); }); } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart index ccff83b502..0db70d0bae 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_controller.dart @@ -1,13 +1,14 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + import '../../utils/log.dart'; import '../board_column/board_column_data.dart'; import '../reorder_flex/drag_state.dart'; import '../reorder_flex/drag_target.dart'; -import '../reorder_flex/drag_target_inteceptor.dart'; +import '../reorder_flex/drag_target_interceptor.dart'; import 'phantom_state.dart'; abstract class BoardPhantomControllerDelegate { - BoardColumnDataController? controller(String columnId); + AFBoardColumnDataController? controller(String columnId); bool removePhantom(String columnId); @@ -61,7 +62,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate columnsState.setColumnIsDragging(columnId, false); } - /// Remove the phanton in the column when the column is end dragging. + /// Remove the phantom in the column when the column is end dragging. void columnEndDragging(String columnId) { columnsState.setColumnIsDragging(columnId, true); if (phantomRecord == null) return; @@ -253,7 +254,7 @@ class PhantomRecord { } } -class PhantomColumnItem extends ColumnItem { +class PhantomColumnItem extends AFColumnItem { final PassthroughPhantomContext phantomContext; PhantomColumnItem(PassthroughPhantomContext insertedPhantom) @@ -290,7 +291,7 @@ class PassthroughPhantomContext extends FakeDragTargetEventTrigger Widget? get draggingWidget => dragTargetData.draggingWidget; - ColumnItem get itemData => dragTargetData.reorderFlexItem as ColumnItem; + AFColumnItem get itemData => dragTargetData.reorderFlexItem as AFColumnItem; @override VoidCallback? onInserted; @@ -331,7 +332,7 @@ class PhantomDraggableBuilder extends ReorderFlexDraggableTargetBuilder { Widget child, DragTargetOnStarted onDragStarted, DragTargetOnEnded onDragEnded, - DragTargetWillAccpet onWillAccept, + DragTargetWillAccepted onWillAccept, AnimationController insertAnimationController, AnimationController deleteAnimationController, ) { diff --git a/frontend/app_flowy/packages/flowy_editor/.gitignore b/frontend/app_flowy/packages/appflowy_editor/.gitignore similarity index 96% rename from frontend/app_flowy/packages/flowy_editor/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/.gitignore index 96486fd930..7501b909b4 100644 --- a/frontend/app_flowy/packages/flowy_editor/.gitignore +++ b/frontend/app_flowy/packages/appflowy_editor/.gitignore @@ -19,7 +19,7 @@ migrate_working_dir/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. -#.vscode/ +.vscode/ # Flutter/Dart/Pub related # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. @@ -28,3 +28,4 @@ migrate_working_dir/ .dart_tool/ .packages build/ +coverage/ diff --git a/frontend/app_flowy/packages/flowy_editor/.metadata b/frontend/app_flowy/packages/appflowy_editor/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/.metadata rename to frontend/app_flowy/packages/appflowy_editor/.metadata diff --git a/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md new file mode 100644 index 0000000000..bac617937b --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.0.2 +Minor Updates to Documentation. + +## 0.0.1 +Initial Version of the library. \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/LICENSE b/frontend/app_flowy/packages/appflowy_editor/LICENSE new file mode 100644 index 0000000000..29ebfa545f --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/README.md b/frontend/app_flowy/packages/appflowy_editor/README.md new file mode 100644 index 0000000000..8e239b6004 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/README.md @@ -0,0 +1,97 @@ + + +

AppFlowy Editor

+ +

A highly customizable rich-text editor for Flutter

+ +

+ Discord • + Twitter +

+ +
+ +
+ +## Key Features + +* Allow you to build rich, intuitive editors +* Design and modify it your way by customizing components, shortcut events, and many more coming soon including menu options and themes +* [Test-covered](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md) and maintained by AppFlowy's core team along with a community of more than 1,000 builders + + +## Getting started + +```shell +flutter pub add appflowy_editor +flutter pub get +``` + +## How to use + +Let's create a new AppFlowyEditor object +```dart +final editorState = EditorState.empty(); // an empty state +final editor = AppFlowyEditor( + editorState: editorState, + keyEventHandlers: const [], + customBuilders: const {}, +); +``` + +You can also create an editor from a JSON file +```dart +final json = ...; +final editorState = EditorState(StateTree.fromJson(data)); +final editor = AppFlowyEditor( + editorState: editorState, + keyEventHandlers: const [], + customBuilders: const {}, +); +``` + +To get a sense for how you might use it, run this example: +```shell +git clone https://github.com/AppFlowy-IO/AppFlowy.git +cd frontend/app_flowy/packages/appflowy_editor/example +flutter run +``` + + +## How to customize +### Customize a component +Please refer to [customizing a component](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md#customize-a-component) for more details. + + +### Customize a shortcut event +Please refer to [customizing a shortcut event](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md#customize-a-shortcut-event) for more details. + +## More Examples +* Customize a component + * [Checkbox Text](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart) shows you how to extend new styles based on existing rich text components + * [Image](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart) teaches you how to extend a new node and render it + * And more examples on [rich-text plugins](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text) +* Customize a shortcut event + * [BIUS](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart) shows you how to make text bold/italic/underline/strikethrough through shortcut keys + * [Paste HTML](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart) gives you an idea on how to handle pasted styles through shortcut keys + * Need more examples? Check out [Internal key event handlers](https://github.com/AppFlowy-IO/AppFlowy/blob/main/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers) + +## Glossary +Please refer to the API documentation. + +## Contributing +Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. Please look at [CONTRIBUTING.md](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/contributing-to-appflowy) for details. + +## License +Distributed under the AGPLv3 License. See LICENSE for more information. diff --git a/frontend/app_flowy/packages/flowy_editor/analysis_options.yaml b/frontend/app_flowy/packages/appflowy_editor/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/analysis_options.yaml rename to frontend/app_flowy/packages/appflowy_editor/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/check.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/check.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/check.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/check.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/point.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/point.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/point.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/point.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/quote.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/bullets.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/bulleted_list.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/bullets.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/bulleted_list.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/checkbox.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/checkbox.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/checkbox.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/checkbox.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h1.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h1.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h1.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h1.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h2.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h2.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h2.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h2.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h3.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h3.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/h3.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/h3.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/number.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/number.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/number.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/number.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/text.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/text.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/popup_list/text.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/text.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/bold.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bold.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/bold.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bold.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/bulleted_list.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bulleted_list.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/bulleted_list.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/bulleted_list.svg diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/center.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/center.svg new file mode 100644 index 0000000000..ea834a35bd --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/code.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/code.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/code.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/code.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/divider.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/divider.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/divider.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/divider.svg diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h1.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h1.svg new file mode 100644 index 0000000000..8a87ece4f0 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h2.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h2.svg new file mode 100644 index 0000000000..9ce394b0b1 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h3.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h3.svg new file mode 100644 index 0000000000..43af128937 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/h3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/highlight.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/highlight.svg new file mode 100644 index 0000000000..697603a054 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/highlight.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/italic.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/italic.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/italic.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/italic.svg diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/left.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/left.svg new file mode 100644 index 0000000000..b4f2d0101e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg new file mode 100644 index 0000000000..612e8377b6 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/number_list.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/number_list.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/number_list.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/number_list.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/quote.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/quote.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/quote.svg diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/right.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/right.svg new file mode 100644 index 0000000000..86a1facaac --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/strikethrough.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/strikethrough.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/strikethrough.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/strikethrough.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/underline.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/underline.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/toolbar/underline.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/underline.svg diff --git a/frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/uncheck.svg similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/assets/images/uncheck.svg rename to frontend/app_flowy/packages/appflowy_editor/assets/images/uncheck.svg diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md b/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md new file mode 100644 index 0000000000..85fa195c96 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/documentation/customizing.md @@ -0,0 +1,286 @@ +# How to customize ... + +## Customize a shortcut event + +We will use a simple example to illustrate how to quickly add a shortcut event. + +For example, typing `_xxx_` will be converted into _xxx_. + +Let's start with a blank document. + +```dart +@override +Widget build(BuildContext context) { + return Scaffold( + body: Container( + alignment: Alignment.topCenter, + child: AppFlowyEditor( + editorState: EditorState.empty(), + keyEventHandlers: const [], + ), + ), + ); +} +``` + +At this point, nothing magic will happen after typing `_xxx_`. + +![Before](./images/customizing_a_shortcut_event_before.gif) + +Next, we will create a function to handle an underscore input. + +```dart +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +FlowyKeyEventHandler underscoreToItalicHandler = (editorState, event) { + // Since we only need to handler the input of `underscore`. + // All inputs except `underscore` will be ignored directly. + if (event.logicalKey != LogicalKeyboardKey.underscore) { + return KeyEventResult.ignored; + } +}; +``` + +Then, we need to determine if the currently selected node is `TextNode` and the selection is collapsed. + +```dart +// ... +FlowyKeyEventHandler underscoreToItalicHandler = (editorState, event) { + // ... + + // Obtaining the selection and selected nodes of the current document through `selectionService`. + // And determine whether the selection is collapsed and whether the selected node is a text node. + final selectionService = editorState.service.selectionService; + final selection = selectionService.currentSelection.value; + final textNodes = selectionService.currentSelectedNodes.whereType(); + if (selection == null || !selection.isSingle || textNodes.length != 1) { + return KeyEventResult.ignored; + } +``` + +Now, we start dealing with underscore. + +Look for the position of the previous underscore and +1. return, if not found. +2. if found, the text wrapped in between two underscores will be displayed in italic. + +```dart +// ... +FlowyKeyEventHandler underscoreToItalicHandler = (editorState, event) { + // ... + + final textNode = textNodes.first; + final text = textNode.toRawString(); + // Determine if `underscore` already exists in the text node + final previousUnderscore = text.indexOf('_'); + if (previousUnderscore == -1) { + return KeyEventResult.ignored; + } + + // Delete the previous `underscore`, + // update the style of the text surrounded by two underscores to `italic`, + // and update the cursor position. + TransactionBuilder(editorState) + ..deleteText(textNode, previousUnderscore, 1) + ..formatText( + textNode, + previousUnderscore, + selection.end.offset - previousUnderscore - 1, + {'italic': true}, + ) + ..afterSelection = Selection.collapsed( + Position(path: textNode.path, offset: selection.end.offset - 1), + ) + ..commit(); + + return KeyEventResult.handled; +}; +``` + +So far, the 'underscore handler' function is done and the only task left is to inject it into the AppFlowyEditor. + +```dart +@override +Widget build(BuildContext context) { + return Scaffold( + body: Container( + alignment: Alignment.topCenter, + child: AppFlowyEditor( + editorState: EditorState.empty(), + keyEventHandlers: [ + underscoreToItalicHandler, + ], + ), + ), + ); +} +``` + +![After](./images/customizing_a_shortcut_event_after.gif) + +[Complete code example]() + +## Customize a component +We will use a simple example to showcase how to quickly add a custom component. + +For example, we want to render an image from the network. + +To start with, let's create an empty document by running commands as follows: + +```dart +@override +Widget build(BuildContext context) { + return Scaffold( + body: Container( + alignment: Alignment.topCenter, + child: AppFlowyEditor( + editorState: EditorState.empty(), + keyEventHandlers: const [], + ), + ), + ); +} +``` + +Next, we choose a unique string for your custom node's type. We use `network_image` in this case. And we add `network_image_src` to the `attributes` to describe the link of the image. + +> For the definition of the [Node](), please refer to this [link](). + +```JSON +{ + "type": "network_image", + "attributes": { + "network_image_src": "https://docs.flutter.dev/assets/images/dash/dash-fainting.gif" + } +} +``` + +Then, we create a class that inherits [NodeWidgetBuilder](). As shown in the autoprompt, we need to implement two functions: +1. one returns a widget +2. the other verifies the correctness of the [Node](). + + +```dart +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + throw UnimplementedError(); + } + + @override + NodeValidator get nodeValidator => throw UnimplementedError(); +} +``` + +Now, let's implement a simple image widget based on `Image`. + +**It is important to note that the `State` of the returned `Widget` must be with [Selectable]().** + +> For the definition of the [Selectable](), please refer to this [link](). + +```dart +class _NetworkImageNodeWidget extends StatefulWidget { + const _NetworkImageNodeWidget({ + Key? key, + required this.node, + }) : super(key: key); + + final Node node; + + @override + State<_NetworkImageNodeWidget> createState() => + __NetworkImageNodeWidgetState(); +} + +class __NetworkImageNodeWidgetState extends State<_NetworkImageNodeWidget> + with Selectable { + RenderBox get _renderBox => context.findRenderObject() as RenderBox; + + @override + Widget build(BuildContext context) { + return Image.network( + widget.node.attributes['network_image_src'], + height: 200, + loadingBuilder: (context, child, loadingProgress) => + loadingProgress == null ? child : const CircularProgressIndicator(), + ); + } + + @override + Position start() => Position(path: widget.node.path, offset: 0); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + List getRectsInSelection(Selection selection) => + [Offset.zero & _renderBox.size]; + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); +} +``` + +Finally, we return `_NetworkImageNodeWidget` in the `build` function of `NetworkImageNodeWidgetBuilder` and register `NetworkImageNodeWidgetBuilder` into `AppFlowyEditor`. + +```dart +class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return _NetworkImageNodeWidget( + key: context.node.key, + node: context.node, + ); + } + + @override + NodeValidator get nodeValidator => (node) { + return node.type == 'network_image' && + node.attributes['network_image_src'] is String; + }; +} +``` + +```dart +final editorState = EditorState( + document: StateTree.empty() + ..insert( + [0], + [ + TextNode.empty(), + Node.fromJson({ + 'type': 'network_image', + 'attributes': { + 'network_image_src': + 'https://docs.flutter.dev/assets/images/dash/dash-fainting.gif' + } + }) + ], + ), +); +return AppFlowyEditor( + editorState: editorState, + customBuilders: { + 'network_image': NetworkImageNodeWidgetBuilder(), + }, +); +``` + +![](./images/customizing_a_component.gif) + +[Here you can check out the complete code file of this example]() diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_component.gif b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_component.gif new file mode 100644 index 0000000000..d226d56d76 Binary files /dev/null and b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_component.gif differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_shortcut_event_after.gif b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_shortcut_event_after.gif new file mode 100644 index 0000000000..0637e9b916 Binary files /dev/null and b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_shortcut_event_after.gif differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_shortcut_event_before.gif b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_shortcut_event_before.gif new file mode 100644 index 0000000000..9d7dbc377e Binary files /dev/null and b/frontend/app_flowy/packages/appflowy_editor/documentation/images/customizing_a_shortcut_event_before.gif differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/example.png b/frontend/app_flowy/packages/appflowy_editor/documentation/images/example.png new file mode 100644 index 0000000000..9bd16610dc Binary files /dev/null and b/frontend/app_flowy/packages/appflowy_editor/documentation/images/example.png differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/images/reporting_bugs.png b/frontend/app_flowy/packages/appflowy_editor/documentation/images/reporting_bugs.png new file mode 100644 index 0000000000..5d4d7f6150 Binary files /dev/null and b/frontend/app_flowy/packages/appflowy_editor/documentation/images/reporting_bugs.png differ diff --git a/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md b/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md new file mode 100644 index 0000000000..25d918c4b5 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/documentation/testing.md @@ -0,0 +1,128 @@ +# Testing + +> The directory structure of test files is consistent with the code files, making it easy for us to map a file with the corresponding test and check if the test is updated + +## Testing Functions + +**Construct a document for testing** +```dart +const text = 'Welcome to Appflowy 😁'; +// Get the instance of editor. +final editor = tester.editor; +// Insert empty text node. +editor.insertEmptyTextNode(); +// Insert text node with string. +editor.insertTextNode(text); +// Insert text node with heading style. +editor.insertTextNode(text, attributes: { + StyleKey.subtype: StyleKey.heading, + StyleKey.heading: StyleKey.h1, +}); +// Insert text node with bulleted list style and bold style. +editor.insertTextNode( + '', + attributes: { + StyleKey.subtype: StyleKey.bulletedList, + }, + delta: Delta([ + TextInsert(text, {StyleKey.bold: true}), + ]), +); +``` + +**The `startTesting` function must be called before testing**. +```dart +await editor.startTesting(); +``` + +**Get the number of nodes in the document** +```dart +final length = editor.documentLength; +print(length); +``` + +**Get the node of a defined path** +```dart +final firstTextNode = editor.nodeAtPath([0]) as TextNode; +``` + +**Update selection** +```dart +await editor.updateSelection( + Selection.single(path: firstTextNode.path, startOffset: 0), +); +``` + +**Get the selection** +```dart +final selection = editor.documentSelection; +print(selection); +``` + +**Simulate shortcut event inputs** +```dart +// Command + A. +await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); +// Command + shift + S. +await editor.pressLogicKey( + LogicalKeyboardKey.keyS, + isMetaPressed: true, + isShiftPressed: true, +); +``` + +**Simulate a text input** +```dart +// Insert 'Hello World' at the beginning of the first node. +editor.insertText(firstTextNode, 'Hello World', 0); +``` + +**Get information about the text node** +```dart +// Get plain text. +final textAfterInserted = firstTextNode.toRawString(); +print(textAfterInserted); +// Get attributes. +final attributes = firstTextNode.attributes; +print(attributes); +``` + +## Example +For example, we are going to test `select_all_handler.dart` + + +```dart +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('select_all_handler_test.dart', () { + testWidgets('Presses Command + A in the document', (tester) async { + const lines = 100; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 0; i < lines; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); + + expect( + editor.documentSelection, + Selection( + start: Position(path: [0], offset: 0), + end: Position(path: [lines - 1], offset: text.length), + ), + ); + }); + }); +} +``` + +For more information about testing, such as simulating a click, please refer to [An introduction to widget testing](https://docs.flutter.dev/cookbook/testing/widget/introduction) diff --git a/frontend/app_flowy/packages/flowy_editor/example/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/example/.gitignore diff --git a/frontend/app_flowy/packages/flowy_editor/example/.metadata b/frontend/app_flowy/packages/appflowy_editor/example/.metadata similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/.metadata rename to frontend/app_flowy/packages/appflowy_editor/example/.metadata diff --git a/frontend/app_flowy/packages/flowy_editor/example/README.md b/frontend/app_flowy/packages/appflowy_editor/example/README.md similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/README.md rename to frontend/app_flowy/packages/appflowy_editor/example/README.md diff --git a/frontend/app_flowy/packages/flowy_editor/example/analysis_options.yaml b/frontend/app_flowy/packages/appflowy_editor/example/analysis_options.yaml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/analysis_options.yaml rename to frontend/app_flowy/packages/appflowy_editor/example/analysis_options.yaml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/android/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/example/android/.gitignore diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/build.gradle b/frontend/app_flowy/packages/appflowy_editor/example/android/app/build.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/build.gradle rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/build.gradle diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/debug/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/debug/AndroidManifest.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/debug/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/AndroidManifest.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/drawable-v21/launch_background.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable-v21/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/drawable-v21/launch_background.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable-v21/launch_background.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/drawable/launch_background.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/drawable/launch_background.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/drawable/launch_background.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/values-night/styles.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values-night/styles.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/values-night/styles.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values-night/styles.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/values/styles.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values/styles.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/main/res/values/styles.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/main/res/values/styles.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/app/src/profile/AndroidManifest.xml b/frontend/app_flowy/packages/appflowy_editor/example/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/app/src/profile/AndroidManifest.xml rename to frontend/app_flowy/packages/appflowy_editor/example/android/app/src/profile/AndroidManifest.xml diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/build.gradle b/frontend/app_flowy/packages/appflowy_editor/example/android/build.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/build.gradle rename to frontend/app_flowy/packages/appflowy_editor/example/android/build.gradle diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/gradle.properties b/frontend/app_flowy/packages/appflowy_editor/example/android/gradle.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/gradle.properties rename to frontend/app_flowy/packages/appflowy_editor/example/android/gradle.properties diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties b/frontend/app_flowy/packages/appflowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties rename to frontend/app_flowy/packages/appflowy_editor/example/android/gradle/wrapper/gradle-wrapper.properties diff --git a/frontend/app_flowy/packages/flowy_editor/example/android/settings.gradle b/frontend/app_flowy/packages/appflowy_editor/example/android/settings.gradle similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/android/settings.gradle rename to frontend/app_flowy/packages/appflowy_editor/example/android/settings.gradle diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/big_document.json b/frontend/app_flowy/packages/appflowy_editor/example/assets/big_document.json similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/assets/big_document.json rename to frontend/app_flowy/packages/appflowy_editor/example/assets/big_document.json diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json similarity index 56% rename from frontend/app_flowy/packages/flowy_editor/example/assets/example.json rename to frontend/app_flowy/packages/appflowy_editor/example/assets/example.json index b6fc3467dc..901e57f796 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json +++ b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json @@ -6,7 +6,7 @@ { "type": "image", "attributes": { - "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" + "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png" } }, { @@ -25,7 +25,7 @@ "type": "text", "delta": [ { - "insert": "👋 Welcome to Appflowy" + "insert": "👋 Welcome to FlowyEditor" } ], "attributes": { @@ -37,7 +37,24 @@ "type": "text", "delta": [ { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." + "insert": "To be honest, we are still in the alpha stage. There are still many functions that need to be completed. And we are developing more features. Please give us a star if the " + }, + { + "insert": "FlowyEditor", + "attributes": { + "href": "https://github.com/AppFlowy-IO/AppFlowy" + } + }, + { + "insert": " helps you. 😊😊😊" + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "Since the FlowyEditor are a community-driven open source editor, we very welcome and appreciate every pull request submissions from everyone.😄😄😄" } ] }, @@ -69,10 +86,10 @@ }, { "insert": " / ", - "attributes": { "highlightColor": "0xFFFFFF00" } + "attributes": { "backgroundColor": "0xFFFFFF00" } }, { - "insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc." + "insert": "to see all the types of content you can add - headers, bulleted lists, checkboxes, etc." } ] }, @@ -80,7 +97,11 @@ "type": "text", "delta": [ { - "insert": "Highlight any text, and use the menu that pops up to " + "insert": "Highlight", + "attributes": { "backgroundColor": "0xFF00BCFB" } + }, + { + "insert": " any text, and use the menu that pops up to " }, { "insert": "style", "attributes": { "bold": true } }, { "insert": " your ", "attributes": { "italic": true } }, @@ -241,62 +262,6 @@ "subtype": "number-list", "number": 3 } - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow." - } - ] } ] } diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/ios/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/example/ios/.gitignore diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Flutter/AppFrameworkInfo.plist b/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Flutter/AppFrameworkInfo.plist rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/AppFrameworkInfo.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Flutter/Debug.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Flutter/Debug.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Debug.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Flutter/Release.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Flutter/Release.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Flutter/Release.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Podfile b/frontend/app_flowy/packages/appflowy_editor/example/ios/Podfile similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Podfile rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Podfile diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.pbxproj similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.pbxproj rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.pbxproj diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/AppDelegate.swift b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/AppDelegate.swift rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Base.lproj/Main.storyboard b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Base.lproj/Main.storyboard rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Base.lproj/Main.storyboard diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Info.plist b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Info.plist rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Info.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Runner-Bridging-Header.h b/frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Runner-Bridging-Header.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/ios/Runner/Runner-Bridging-Header.h rename to frontend/app_flowy/packages/appflowy_editor/example/ios/Runner/Runner-Bridging-Header.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/expandable_floating_action_button.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/lib/expandable_floating_action_button.dart rename to frontend/app_flowy/packages/appflowy_editor/example/lib/expandable_floating_action_button.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart similarity index 68% rename from frontend/app_flowy/packages/flowy_editor/example/lib/main.dart rename to frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart index 3dc9fe4806..e17088c954 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart @@ -1,13 +1,14 @@ -import 'dart:collection'; import 'dart:convert'; -import 'package:example/expandable_floating_action_button.dart'; -import 'package:example/plugin/image_node_widget.dart'; -import 'package:example/plugin/youtube_link_node_widget.dart'; import 'package:flutter/material.dart'; -import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/services.dart'; +import 'expandable_floating_action_button.dart'; +import 'plugin/image_node_widget.dart'; +import 'plugin/youtube_link_node_widget.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + void main() { runApp(const MyApp()); } @@ -19,7 +20,6 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', theme: ThemeData( // This is the theme of your application. // @@ -32,7 +32,7 @@ class MyApp extends StatelessWidget { // is not restarted. primarySwatch: Colors.blue, ), - home: const MyHomePage(title: 'FlowyEditor Example'), + home: const MyHomePage(title: 'AppFlowyEditor Example'), ); } } @@ -62,29 +62,90 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), + body: Container( + alignment: Alignment.topCenter, + child: _buildBody(), ), - body: _buildBody(), floatingActionButton: _buildExpandableFab(), ); } Widget _buildBody() { if (page == 0) { - return _buildFlowyEditorWithExample(); + return _buildAppFlowyEditorWithExample(); } else if (page == 1) { - return _buildFlowyEditorWithEmptyDocument(); + return _buildAppFlowyEditorWithEmptyDocument(); } else if (page == 2) { - return _buildTextField(); - } else if (page == 3) { - return _buildFlowyEditorWithBigDocument(); + return _buildAppFlowyEditorWithBigDocument(); } return Container(); } + Widget _buildAppFlowyEditorWithEmptyDocument() { + final editorState = EditorState.empty(); + final editor = AppFlowyEditor( + editorState: editorState, + keyEventHandlers: const [], + customBuilders: const {}, + ); + return editor; + } + + Widget _buildAppFlowyEditorWithExample() { + return FutureBuilder( + future: rootBundle.loadString('assets/example.json'), + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = Map.from(json.decode(snapshot.data!)); + final editorState = EditorState(document: StateTree.fromJson(data)); + editorState.logConfiguration + ..level = LogLevel.all + ..handler = (message) { + debugPrint(message); + }; + return _buildAppFlowyEditor(editorState); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } + + Widget _buildAppFlowyEditorWithBigDocument() { + return FutureBuilder( + future: rootBundle.loadString('assets/big_document.json'), + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = Map.from(json.decode(snapshot.data!)); + return _buildAppFlowyEditor(EditorState( + document: StateTree.fromJson(data), + )); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } + + Widget _buildAppFlowyEditor(EditorState editorState) { + return Container( + padding: const EdgeInsets.only(left: 20, right: 20), + child: AppFlowyEditor( + key: editorKey, + editorState: editorState, + keyEventHandlers: const [], + customBuilders: { + 'image': ImageNodeBuilder(), + 'youtube_link': YouTubeLinkNodeBuilder() + }, + ), + ); + } + Widget _buildExpandableFab() { return ExpandableFab( distance: 112.0, @@ -116,93 +177,7 @@ class _MyHomePageState extends State { }, icon: const Icon(Icons.text_fields), ), - ActionButton( - onPressed: () { - if (page == 3) return; - setState(() { - page = 3; - }); - }, - icon: const Icon(Icons.email), - ), ], ); } - - Widget _buildFlowyEditorWithEmptyDocument() { - return _buildFlowyEditor( - EditorState( - document: StateTree( - root: Node( - type: 'editor', - children: LinkedList() - ..add( - TextNode.empty() - ..delta = Delta( - [TextInsert('')], - ), - ), - attributes: {}, - ), - ), - ), - ); - } - - Widget _buildFlowyEditorWithExample() { - return FutureBuilder( - future: rootBundle.loadString('assets/example.json'), - builder: (context, snapshot) { - if (snapshot.hasData) { - final data = Map.from(json.decode(snapshot.data!)); - return _buildFlowyEditor(EditorState( - document: StateTree.fromJson(data), - )); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } - - Widget _buildFlowyEditorWithBigDocument() { - return FutureBuilder( - future: rootBundle.loadString('assets/big_document.json'), - builder: (context, snapshot) { - if (snapshot.hasData) { - final data = Map.from(json.decode(snapshot.data!)); - return _buildFlowyEditor(EditorState( - document: StateTree.fromJson(data), - )); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } - - Widget _buildFlowyEditor(EditorState editorState) { - return Container( - padding: const EdgeInsets.only(left: 20, right: 20), - child: FlowyEditor( - key: editorKey, - editorState: editorState, - keyEventHandlers: const [], - customBuilders: { - 'image': ImageNodeBuilder(), - 'youtube_link': YouTubeLinkNodeBuilder() - }, - ), - ); - } - - Widget _buildTextField() { - return const Center( - child: TextField(), - ); - } } diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart similarity index 55% rename from frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart rename to frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart index 7a47802163..c535bc6295 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart @@ -1,4 +1,4 @@ -import 'package:flowy_editor/flowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; /// 1. define your custom type in example.json @@ -15,7 +15,7 @@ import 'package:flutter/material.dart'; /// /// 4. override the getter `nodeValidator` /// to verify the data structure in [Node]. -/// 5. register the plugin with `type` to `flowy_editor` in `main.dart`. +/// 5. register the plugin with `type` to `AppFlowyEditor` in `main.dart`. /// 6. Congratulations! class ImageNodeBuilder extends NodeWidgetBuilder { @@ -34,6 +34,8 @@ class ImageNodeBuilder extends NodeWidgetBuilder { }); } +const double placeholderHeight = 132; + class ImageNodeWidget extends StatefulWidget { final Node node; final EditorState editorState; @@ -49,6 +51,7 @@ class ImageNodeWidget extends StatefulWidget { } class _ImageNodeWidgetState extends State with Selectable { + bool isHovered = false; Node get node => widget.node; EditorState get editorState => widget.editorState; String get src => widget.node.attributes['image_src'] as String; @@ -88,13 +91,73 @@ class _ImageNodeWidgetState extends State with Selectable { return _build(context); } + Widget _loadingBuilder( + BuildContext context, Widget widget, ImageChunkEvent? evt) { + if (evt == null) { + return widget; + } + return Container( + alignment: Alignment.center, + height: placeholderHeight, + child: const Text("Loading..."), + ); + } + + Widget _errorBuilder( + BuildContext context, Object obj, StackTrace? stackTrace) { + return Container( + alignment: Alignment.center, + height: placeholderHeight, + child: const Text("Error..."), + ); + } + + Widget _frameBuilder( + BuildContext context, + Widget child, + int? frame, + bool wasSynchronouslyLoaded, + ) { + if (frame == null) { + return Container( + alignment: Alignment.center, + height: placeholderHeight, + child: const Text("Loading..."), + ); + } + + return child; + } + Widget _build(BuildContext context) { return Column( children: [ - Image.network( - src, - width: MediaQuery.of(context).size.width, - ) + MouseRegion( + onEnter: (event) { + setState(() { + isHovered = true; + }); + }, + onExit: (event) { + setState(() { + isHovered = false; + }); + }, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + border: Border.all( + color: isHovered ? Colors.blue : Colors.grey, + ), + borderRadius: const BorderRadius.all(Radius.circular(20))), + child: Image.network( + src, + width: MediaQuery.of(context).size.width, + frameBuilder: _frameBuilder, + loadingBuilder: _loadingBuilder, + errorBuilder: _errorBuilder, + ), + )), ], ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart new file mode 100644 index 0000000000..84d2e72918 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/network_image_node_widget.dart @@ -0,0 +1,69 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class NetworkImageNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return _NetworkImageNodeWidget( + key: context.node.key, + node: context.node, + ); + } + + @override + NodeValidator get nodeValidator => (node) { + return node.type == 'network_image' && + node.attributes['network_image_src'] is String; + }; +} + +class _NetworkImageNodeWidget extends StatefulWidget { + const _NetworkImageNodeWidget({ + Key? key, + required this.node, + }) : super(key: key); + + final Node node; + + @override + State<_NetworkImageNodeWidget> createState() => + __NetworkImageNodeWidgetState(); +} + +class __NetworkImageNodeWidgetState extends State<_NetworkImageNodeWidget> + with Selectable { + RenderBox get _renderBox => context.findRenderObject() as RenderBox; + + @override + Widget build(BuildContext context) { + return Image.network( + widget.node.attributes['network_image_src'], + height: 200, + loadingBuilder: (context, child, loadingProgress) => + loadingProgress == null ? child : const CircularProgressIndicator(), + ); + } + + @override + Position start() => Position(path: widget.node.path, offset: 0); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + List getRectsInSelection(Selection selection) => + [Offset.zero & _renderBox.size]; + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic_key_event_handler.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic_key_event_handler.dart new file mode 100644 index 0000000000..539a756e1f --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/underscore_to_italic_key_event_handler.dart @@ -0,0 +1,46 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +AppFlowyKeyEventHandler underscoreToItalicHandler = (editorState, event) { + // Since we only need to handler the input of `underscore`. + // All inputs except `underscore` will be ignored directly. + if (event.logicalKey != LogicalKeyboardKey.underscore) { + return KeyEventResult.ignored; + } + + // Obtaining the selection and selected nodes of the current document through `selectionService`, + // and determine whether it is a single selection and whether the selected node is a text node. + final selectionService = editorState.service.selectionService; + final selection = selectionService.currentSelection.value; + final textNodes = selectionService.currentSelectedNodes.whereType(); + if (selection == null || !selection.isSingle || textNodes.length != 1) { + return KeyEventResult.ignored; + } + + final textNode = textNodes.first; + final text = textNode.toRawString(); + // Determine if `underscore` already exists in the text node + final previousUnderscore = text.indexOf('_'); + if (previousUnderscore == -1) { + return KeyEventResult.ignored; + } + + // Delete the previous `underscore`, + // update the style of the text surrounded by two underscores to `italic`, + // and update the cursor position. + TransactionBuilder(editorState) + ..deleteText(textNode, previousUnderscore, 1) + ..formatText( + textNode, + previousUnderscore, + selection.end.offset - previousUnderscore - 1, + {'italic': true}, + ) + ..afterSelection = Selection.collapsed( + Position(path: textNode.path, offset: selection.end.offset - 1), + ) + ..commit(); + + return KeyEventResult.handled; +}; diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/youtube_link_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart similarity index 97% rename from frontend/app_flowy/packages/flowy_editor/example/lib/plugin/youtube_link_node_widget.dart rename to frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart index fd79077912..27bbe922e3 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/youtube_link_node_widget.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart @@ -1,4 +1,4 @@ -import 'package:flowy_editor/flowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:pod_player/pod_player.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/linux/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/example/linux/.gitignore diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/linux/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/CMakeLists.txt rename to frontend/app_flowy/packages/appflowy_editor/example/linux/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/flutter/CMakeLists.txt rename to frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc rename to frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.cc diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.h rename to frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugin_registrant.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugins.cmake similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake rename to frontend/app_flowy/packages/appflowy_editor/example/linux/flutter/generated_plugins.cmake diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/main.cc b/frontend/app_flowy/packages/appflowy_editor/example/linux/main.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/main.cc rename to frontend/app_flowy/packages/appflowy_editor/example/linux/main.cc diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/my_application.cc b/frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/my_application.cc rename to frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.cc diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/my_application.h b/frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/linux/my_application.h rename to frontend/app_flowy/packages/appflowy_editor/example/linux/my_application.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/macos/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/example/macos/.gitignore diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/Flutter-Debug.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/Flutter-Debug.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Debug.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/Flutter-Release.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/Flutter-Release.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/Flutter-Release.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Podfile b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Podfile rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcodeproj/project.pbxproj b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.pbxproj similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcodeproj/project.pbxproj rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.pbxproj diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/AppDelegate.swift b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/AppDelegate.swift similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/AppDelegate.swift rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/AppDelegate.swift diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Base.lproj/MainMenu.xib diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/AppInfo.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/AppInfo.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/AppInfo.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/AppInfo.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/Debug.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/Debug.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Debug.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/Release.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/Release.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Release.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/Warnings.xcconfig b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Configs/Warnings.xcconfig rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Configs/Warnings.xcconfig diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/DebugProfile.entitlements b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/DebugProfile.entitlements similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/DebugProfile.entitlements rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/DebugProfile.entitlements diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Info.plist b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Info.plist similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Info.plist rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Info.plist diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/MainFlutterWindow.swift b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/MainFlutterWindow.swift similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/MainFlutterWindow.swift rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/MainFlutterWindow.swift diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Release.entitlements b/frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Release.entitlements similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/macos/Runner/Release.entitlements rename to frontend/app_flowy/packages/appflowy_editor/example/macos/Runner/Release.entitlements diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml similarity index 97% rename from frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml rename to frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml index 65f15eae46..4e2e2d68ab 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml @@ -3,7 +3,7 @@ description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -30,11 +30,10 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 - flowy_editor: + appflowy_editor: path: ../ provider: ^6.0.3 url_launcher: ^6.1.5 @@ -58,7 +57,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. @@ -66,7 +64,6 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - document.json - example.json - big_document.json # - images/a_dot_ham.jpeg diff --git a/frontend/app_flowy/packages/appflowy_editor/example/test/widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/example/test/widget_test.dart new file mode 100644 index 0000000000..2a2b819285 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/test/widget_test.dart @@ -0,0 +1,8 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +void main() {} diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/favicon.png b/frontend/app_flowy/packages/appflowy_editor/example/web/favicon.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/favicon.png rename to frontend/app_flowy/packages/appflowy_editor/example/web/favicon.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-192.png b/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-192.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-192.png rename to frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-192.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-512.png b/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-512.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-512.png rename to frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-512.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-maskable-192.png b/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-192.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-maskable-192.png rename to frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-192.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-maskable-512.png b/frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-512.png similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/icons/Icon-maskable-512.png rename to frontend/app_flowy/packages/appflowy_editor/example/web/icons/Icon-maskable-512.png diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/index.html b/frontend/app_flowy/packages/appflowy_editor/example/web/index.html similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/index.html rename to frontend/app_flowy/packages/appflowy_editor/example/web/index.html diff --git a/frontend/app_flowy/packages/flowy_editor/example/web/manifest.json b/frontend/app_flowy/packages/appflowy_editor/example/web/manifest.json similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/web/manifest.json rename to frontend/app_flowy/packages/appflowy_editor/example/web/manifest.json diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/.gitignore b/frontend/app_flowy/packages/appflowy_editor/example/windows/.gitignore similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/.gitignore rename to frontend/app_flowy/packages/appflowy_editor/example/windows/.gitignore diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/windows/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/CMakeLists.txt rename to frontend/app_flowy/packages/appflowy_editor/example/windows/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/flutter/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/flutter/CMakeLists.txt rename to frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.cc similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/flutter/generated_plugin_registrant.cc rename to frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.cc diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/flutter/generated_plugin_registrant.h b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/flutter/generated_plugin_registrant.h rename to frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugin_registrant.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugins.cmake similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/flutter/generated_plugins.cmake rename to frontend/app_flowy/packages/appflowy_editor/example/windows/flutter/generated_plugins.cmake diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/CMakeLists.txt b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/CMakeLists.txt similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/CMakeLists.txt rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/CMakeLists.txt diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/Runner.rc b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/Runner.rc similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/Runner.rc rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/Runner.rc diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/flutter_window.cpp b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/flutter_window.cpp rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.cpp diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/flutter_window.h b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/flutter_window.h rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/flutter_window.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/main.cpp b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/main.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/main.cpp rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/main.cpp diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/resource.h b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resource.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/resource.h rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resource.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/resources/app_icon.ico b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resources/app_icon.ico similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/resources/app_icon.ico rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/resources/app_icon.ico diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/runner.exe.manifest b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/runner.exe.manifest similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/runner.exe.manifest rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/runner.exe.manifest diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/utils.cpp b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/utils.cpp rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.cpp diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/utils.h b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/utils.h rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/utils.h diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/win32_window.cpp b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.cpp similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/win32_window.cpp rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.cpp diff --git a/frontend/app_flowy/packages/flowy_editor/example/windows/runner/win32_window.h b/frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.h similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/example/windows/runner/win32_window.h rename to frontend/app_flowy/packages/appflowy_editor/example/windows/runner/win32_window.h diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart new file mode 100644 index 0000000000..14826ff713 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -0,0 +1,23 @@ +/// AppFlowyEditor library +library appflowy_editor; + +export 'src/infra/log.dart'; +export 'src/document/node.dart'; +export 'src/document/path.dart'; +export 'src/document/position.dart'; +export 'src/document/selection.dart'; +export 'src/document/state_tree.dart'; +export 'src/document/text_delta.dart'; +export 'src/document/attributes.dart'; +export 'src/editor_state.dart'; +export 'src/operation/operation.dart'; +export 'src/operation/transaction.dart'; +export 'src/operation/transaction_builder.dart'; +export 'src/render/selection/selectable.dart'; +export 'src/service/editor_service.dart'; +export 'src/service/render_plugin_service.dart'; +export 'src/service/service.dart'; +export 'src/service/selection_service.dart'; +export 'src/service/scroll_service.dart'; +export 'src/service/keyboard_service.dart'; +export 'src/service/input_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/attributes.dart similarity index 68% rename from frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/attributes.dart index 4e1f39775f..1a846eec2c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/attributes.dart @@ -15,18 +15,22 @@ Attributes invertAttributes(Attributes? attr, Attributes? base) { return memo; }); return attr.keys.fold(baseInverted, (memo, key) { - if (attr![key] != base![key] && base.containsKey(key)) { + if (attr![key] != base![key] && !base.containsKey(key)) { memo[key] = null; } return memo; }); } -Attributes? composeAttributes(Attributes? a, Attributes? b) { +Attributes? composeAttributes(Attributes? a, Attributes? b, + [bool keepNull = false]) { a ??= {}; b ??= {}; - final Attributes attributes = {}; - attributes.addAll(Map.from(b)..removeWhere((_, value) => value == null)); + Attributes attributes = {...b}; + + if (!keepNull) { + attributes = Map.from(attributes)..removeWhere((_, value) => value == null); + } for (final entry in a.entries) { if (!b.containsKey(entry.key)) { @@ -34,9 +38,5 @@ Attributes? composeAttributes(Attributes? a, Attributes? b) { } } - if (attributes.isEmpty) { - return null; - } - - return attributes; + return attributes.isNotEmpty ? attributes : null; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart similarity index 67% rename from frontend/app_flowy/packages/flowy_editor/lib/document/node.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart index 21883362b1..a0c4e33a70 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart @@ -1,7 +1,6 @@ import 'dart:collection'; -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/text_delta.dart'; -import 'package:flowy_editor/operation/operation.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; import 'package:flutter/material.dart'; import './attributes.dart'; @@ -9,7 +8,7 @@ class Node extends ChangeNotifier with LinkedListEntry { Node? parent; final String type; final LinkedList children; - final Attributes attributes; + Attributes _attributes; GlobalKey? key; // TODO: abstract a selectable node?? @@ -17,22 +16,24 @@ class Node extends ChangeNotifier with LinkedListEntry { String? get subtype { // TODO: make 'subtype' as a const value. - if (attributes.containsKey('subtype')) { - assert(attributes['subtype'] is String?, + if (_attributes.containsKey('subtype')) { + assert(_attributes['subtype'] is String?, 'subtype must be a [String] or [null]'); - return attributes['subtype'] as String?; + return _attributes['subtype'] as String?; } return null; } Path get path => _path(); + Attributes get attributes => _attributes; + Node({ required this.type, required this.children, - required this.attributes, + required Attributes attributes, this.parent, - }) { + }) : _attributes = attributes { for (final child in children) { child.parent = this; } @@ -85,17 +86,10 @@ class Node extends ChangeNotifier with LinkedListEntry { } void updateAttributes(Attributes attributes) { - bool shouldNotifyParent = - this.attributes['subtype'] != attributes['subtype']; + bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype']; - for (final attribute in attributes.entries) { - if (attribute.value == null) { - this.attributes.remove(attribute.key); - } else { - this.attributes[attribute.key] = attribute.value; - } - } - // Notify the new attributes + _attributes = composeAttributes(_attributes, attributes) ?? {}; + // Notifies the new attributes // if attributes contains 'subtype', should notify parent to rebuild node // else, just notify current node. shouldNotifyParent ? parent?.notifyListeners() : notifyListeners(); @@ -117,12 +111,33 @@ class Node extends ChangeNotifier with LinkedListEntry { return childAtIndex(path.first)?.childAtPath(path.sublist(1)); } + void insert(Node entry, {int? index}) { + index ??= children.length; + + if (children.isEmpty) { + entry.parent = this; + children.add(entry); + notifyListeners(); + return; + } + + final length = children.length; + + if (index >= length) { + children.last.insertAfter(entry); + } else if (index <= 0) { + children.first.insertBefore(entry); + } else { + childAtIndex(index)?.insertBefore(entry); + } + } + @override void insertAfter(Node entry) { entry.parent = parent; super.insertAfter(entry); - // Notify the new node. + // Notifies the new node. parent?.notifyListeners(); } @@ -131,7 +146,7 @@ class Node extends ChangeNotifier with LinkedListEntry { entry.parent = parent; super.insertBefore(entry); - // Notify the new node. + // Notifies the new node. parent?.notifyListeners(); } @@ -150,8 +165,8 @@ class Node extends ChangeNotifier with LinkedListEntry { if (children.isNotEmpty) { map['children'] = children.map((node) => node.toJson()); } - if (attributes.isNotEmpty) { - map['attributes'] = attributes; + if (_attributes.isNotEmpty) { + map['attributes'] = _attributes; } return map; } @@ -169,6 +184,18 @@ class Node extends ChangeNotifier with LinkedListEntry { } return parent!._path([index, ...previous]); } + + Node deepClone() { + final newNode = Node( + type: type, children: LinkedList(), attributes: {...attributes}); + + for (final node in children) { + final newNode = node.deepClone(); + newNode.parent = this; + newNode.children.add(newNode); + } + return newNode; + } } class TextNode extends Node { @@ -182,12 +209,12 @@ class TextNode extends Node { }) : _delta = delta, super(children: children ?? LinkedList(), attributes: attributes ?? {}); - TextNode.empty() + TextNode.empty({Attributes? attributes}) : _delta = Delta([TextInsert('')]), super( type: 'text', children: LinkedList(), - attributes: {}, + attributes: attributes ?? {}, ); Delta get delta { @@ -215,11 +242,25 @@ class TextNode extends Node { TextNode( type: type ?? this.type, children: children ?? this.children, - attributes: attributes ?? this.attributes, + attributes: attributes ?? _attributes, delta: delta ?? this.delta, ); - // TODO: It's unneccesry to compute everytime. - String toRawString() => - _delta.operations.whereType().map((op) => op.content).join(); + @override + TextNode deepClone() { + final newNode = TextNode( + type: type, + children: LinkedList(), + delta: delta.slice(0), + attributes: {...attributes}); + + for (final node in children) { + final newNode = node.deepClone(); + newNode.parent = this; + newNode.children.add(newNode); + } + return newNode; + } + + String toRawString() => _delta.toRawString(); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node_iterator.dart similarity index 96% rename from frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/node_iterator.dart index bafe106f27..9c666bdfea 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node_iterator.dart @@ -1,4 +1,4 @@ -import 'package:flowy_editor/document/node.dart'; +import 'package:appflowy_editor/src/document/node.dart'; import './state_tree.dart'; import './node.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/path.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/path.dart similarity index 87% rename from frontend/app_flowy/packages/flowy_editor/lib/document/path.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/path.dart index 8f24947649..a8163f094d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/path.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/path.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/foundation.dart'; typedef Path = List; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/position.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/lib/document/position.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/position.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart similarity index 50% rename from frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart index 16341cee1a..99a81c9273 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart @@ -1,16 +1,27 @@ -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/extensions/path_extensions.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; +/// Selection represents the selected area or the cursor area in the editor. +/// +/// [Selection] is directional. +/// +/// 1. forward,the end position is before the start position. +/// 2. backward, the end position is after the start position. +/// 3. collapsed, the end position is equal to the start position. class Selection { - final Position start; - final Position end; - + /// Create a selection with [start], [end]. Selection({ required this.start, required this.end, }); + /// Create a selection with [Path], [startOffset] and [endOffset]. + /// + /// The [endOffset] is optional. + /// + /// This constructor will return a collapsed [Selection] if [endOffset] is null. + /// Selection.single({ required Path path, required int startOffset, @@ -18,10 +29,32 @@ class Selection { }) : start = Position(path: path, offset: startOffset), end = Position(path: path, offset: endOffset ?? startOffset); + /// Create a collapsed selection with [position]. Selection.collapsed(Position position) : start = position, end = position; + final Position start; + final Position end; + + bool get isCollapsed => start == end; + bool get isSingle => pathEquals(start.path, end.path); + bool get isForward => + (start.path >= end.path && !pathEquals(start.path, end.path)) || + (isSingle && start.offset > end.offset); + bool get isBackward => + (start.path <= end.path && !pathEquals(start.path, end.path)) || + (isSingle && start.offset < end.offset); + + Selection normalize() { + if (isForward) { + return Selection(start: end, end: start); + } + return this; + } + + Selection get reversed => copyWith(start: end, end: start); + Selection collapse({bool atStart = false}) { if (atStart) { return Selection(start: start, end: start); @@ -30,13 +63,6 @@ class Selection { } } - bool get isCollapsed => start == end; - bool get isSingle => pathEquals(start.path, end.path); - bool get isUpward => - start.path >= end.path && !pathEquals(start.path, end.path); - bool get isDownward => - start.path <= end.path && !pathEquals(start.path, end.path); - Selection copyWith({Position? start, Position? end}) { return Selection( start: start ?? this.start, @@ -46,13 +72,10 @@ class Selection { Selection copy() => Selection(start: start, end: end); - @override - String toString() => '[Selection] start = $start, end = $end'; - Map toJson() { return { - "start": start.toJson(), - "end": end.toJson(), + 'start': start.toJson(), + 'end': end.toJson(), }; } @@ -69,4 +92,7 @@ class Selection { @override int get hashCode => Object.hash(start, end); + + @override + String toString() => '[Selection] start = $start, end = $end'; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart similarity index 65% rename from frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart index cf49f48ac8..5bf49c0048 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart @@ -1,6 +1,8 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/text_delta.dart'; +import 'dart:math'; + +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; import './attributes.dart'; class StateTree { @@ -10,6 +12,19 @@ class StateTree { required this.root, }); + factory StateTree.empty() { + return StateTree( + root: Node.fromJson({ + 'type': 'editor', + 'children': [ + { + 'type': 'text', + } + ] + }), + ); + } + factory StateTree.fromJson(Attributes json) { assert(json['document'] is Map); @@ -27,9 +42,18 @@ class StateTree { return false; } Node? insertedNode = root.childAtPath( - path.sublist(0, path.length - 1) + [path.last - 1], + path.sublist(0, path.length - 1) + [max(0, path.last - 1)], ); if (insertedNode == null) { + final insertedNode = root.childAtPath( + path.sublist(0, path.length - 1), + ); + if (insertedNode != null) { + for (final node in nodes) { + insertedNode.insert(node); + } + return true; + } return false; } for (var i = 0; i < nodes.length; i++) { @@ -65,16 +89,15 @@ class StateTree { } } - Attributes? update(Path path, Attributes attributes) { + bool update(Path path, Attributes attributes) { if (path.isEmpty) { - return null; + return false; } final updatedNode = root.childAtPath(path); if (updatedNode == null) { - return null; + return false; } - final previousAttributes = Attributes.from(updatedNode.attributes); updatedNode.updateAttributes(attributes); - return previousAttributes; + return true; } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/text_delta.dart similarity index 60% rename from frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/document/text_delta.dart index 64335d4a05..2e6fa82437 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/text_delta.dart @@ -1,7 +1,7 @@ import 'dart:collection'; import 'dart:math'; -import 'package:flowy_editor/document/attributes.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import './attributes.dart'; @@ -182,7 +182,7 @@ class _OpIterator { return _maxInt; } - TextOperation next([int? length]) { + TextOperation _next([int? length]) { length ??= _maxInt; if (_index >= _operations.length) { @@ -229,11 +229,11 @@ class _OpIterator { } else { final offset = _offset; final index = _index; - final _next = next(); + final next = _next(); final rest = _operations.sublist(_index); _offset = offset; _index = index; - return [_next] + rest; + return [next] + rest; } } } @@ -256,9 +256,16 @@ TextOperation? _textOperationFromJson(Map json) { return result; } -// basically copy from: https://github.com/quilljs/delta -class Delta { - final List operations; +/// Deltas are a simple, yet expressive format that can be used to describe contents and changes. +/// The format is JSON based, and is human readable, yet easily parsible by machines. +/// Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML. +/// + +/// Basically borrowed from: https://github.com/quilljs/delta +class Delta extends Iterable { + final List _operations; + String? _rawString; + List? _runeIndexes; factory Delta.fromJson(List list) { final operations = []; @@ -273,59 +280,61 @@ class Delta { return Delta(operations); } - Delta([List? ops]) : operations = ops ?? []; + Delta([List? ops]) : _operations = ops ?? []; - Delta addAll(List textOps) { + void addAll(Iterable textOps) { textOps.forEach(add); - return this; } - Delta add(TextOperation textOp) { + void add(TextOperation textOp) { if (textOp.isEmpty) { - return this; + return; } + _rawString = null; - if (operations.isNotEmpty) { - final lastOp = operations.last; + if (_operations.isNotEmpty) { + final lastOp = _operations.last; if (lastOp is TextDelete && textOp is TextDelete) { lastOp.length += textOp.length; - return this; + return; } if (mapEquals(lastOp.attributes, textOp.attributes)) { if (lastOp is TextInsert && textOp is TextInsert) { lastOp.content += textOp.content; - return this; + return; } // if there is an delete before the insert // swap the order if (lastOp is TextDelete && textOp is TextInsert) { - operations.removeLast(); - operations.add(textOp); - operations.add(lastOp); - return this; + _operations.removeLast(); + _operations.add(textOp); + _operations.add(lastOp); + return; } if (lastOp is TextRetain && textOp is TextRetain) { lastOp.length += textOp.length; - return this; + return; } } } - operations.add(textOp); - return this; + _operations.add(textOp); } + /// The slice() method does not change the original string. + /// The start and end parameters specifies the part of the string to extract. + /// The end position is optional. Delta slice(int start, [int? end]) { final result = Delta(); - final iterator = _OpIterator(operations); + final iterator = _OpIterator(_operations); int index = 0; while ((end == null || index < end) && iterator.hasNext) { TextOperation? nextOp; if (index < start) { - nextOp = iterator.next(start - index); + nextOp = iterator._next(start - index); } else { - nextOp = iterator.next(end == null ? null : end - index); + nextOp = iterator._next(end == null ? null : end - index); result.add(nextOp); } @@ -335,29 +344,33 @@ class Delta { return result; } - Delta insert(String content, [Attributes? attributes]) { - final op = TextInsert(content, attributes); - return add(op); - } + /// Insert operations have an `insert` key defined. + /// A String value represents inserting text. + void insert(String content, [Attributes? attributes]) => + add(TextInsert(content, attributes)); - Delta retain(int length, [Attributes? attributes]) { - final op = TextRetain(length, attributes); - return add(op); - } + /// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip). + /// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range. + /// A value of `null` in the `attributes` Object represents removal of that key. + /// + /// *Note: It is not necessary to retain the last characters of a document as this is implied.* + void retain(int length, [Attributes? attributes]) => + add(TextRetain(length, attributes)); - Delta delete(int length) { - final op = TextDelete(length); - return add(op); - } + /// Delete operations have a Number `delete` key defined representing the number of characters to delete. + void delete(int length) => add(TextDelete(length)); + /// The length of the string fo the [Delta]. + @override int get length { - return operations.fold( + return _operations.fold( 0, (previousValue, element) => previousValue + element.length); } + /// Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta. Delta compose(Delta other) { - final thisIter = _OpIterator(operations); - final otherIter = _OpIterator(other.operations); + final thisIter = _OpIterator(_operations); + final otherIter = _OpIterator(other._operations); final ops = []; final firstOther = otherIter.peek(); @@ -368,29 +381,29 @@ class Delta { while ( thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) { firstLeft -= thisIter.peekLength(); - final next = thisIter.next(); + final next = thisIter._next(); ops.add(next); } if (firstOther.length - firstLeft > 0) { - otherIter.next(firstOther.length - firstLeft); + otherIter._next(firstOther.length - firstLeft); } } final delta = Delta(ops); while (thisIter.hasNext || otherIter.hasNext) { if (otherIter.peek() is TextInsert) { - final next = otherIter.next(); + final next = otherIter._next(); delta.add(next); } else if (thisIter.peek() is TextDelete) { - final next = thisIter.next(); + final next = thisIter._next(); delta.add(next); } else { // otherIs final length = min(thisIter.peekLength(), otherIter.peekLength()); - final thisOp = thisIter.next(length); - final otherOp = otherIter.next(length); - final attributes = - composeAttributes(thisOp.attributes, otherOp.attributes); + final thisOp = thisIter._next(length); + final otherOp = otherIter._next(length); + final attributes = composeAttributes( + thisOp.attributes, otherOp.attributes, thisOp is TextRetain); if (otherOp is TextRetain && otherOp.length > 0) { TextOperation? newOp; if (thisOp is TextRetain) { @@ -405,9 +418,10 @@ class Delta { // Optimization if rest of other is just retain if (!otherIter.hasNext && - delta.operations[delta.operations.length - 1] == newOp) { + delta._operations.isNotEmpty && + delta._operations.last == newOp) { final rest = Delta(thisIter.rest()); - return delta.concat(rest).chop(); + return (delta + rest)..chop(); } } else if (otherOp is TextDelete && (thisOp is TextRetain)) { delta.add(otherOp); @@ -415,27 +429,28 @@ class Delta { } } - return delta.chop(); + return delta..chop(); } - Delta concat(Delta other) { - var ops = [...operations]; - if (other.operations.isNotEmpty) { - ops.add(other.operations[0]); - ops.addAll(other.operations.sublist(1)); + /// This method joins two Delta together. + Delta operator +(Delta other) { + var ops = [..._operations]; + if (other._operations.isNotEmpty) { + ops.add(other._operations[0]); + ops.addAll(other._operations.sublist(1)); } return Delta(ops); } - Delta chop() { - if (operations.isEmpty) { - return this; + void chop() { + if (_operations.isEmpty) { + return; } - final lastOp = operations.last; + _rawString = null; + final lastOp = _operations.last; if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) { - operations.removeLast(); + _operations.removeLast(); } - return this; } @override @@ -443,17 +458,18 @@ class Delta { if (other is! Delta) { return false; } - return listEquals(operations, other.operations); + return listEquals(_operations, other._operations); } @override int get hashCode { - return hashList(operations); + return hashList(_operations); } + /// Returned an inverted delta that has the opposite effect of against a base document delta. Delta invert(Delta base) { final inverted = Delta(); - operations.fold(0, (int previousValue, op) { + _operations.fold(0, (int previousValue, op) { if (op is TextInsert) { inverted.delete(op.length); } else if (op is TextRetain && op.attributes == null) { @@ -462,7 +478,7 @@ class Delta { } else if (op is TextDelete || op is TextRetain) { final length = op.length; final slice = base.slice(previousValue, previousValue + length); - for (final baseOp in slice.operations) { + for (final baseOp in slice._operations) { if (op is TextDelete) { inverted.add(baseOp); } else if (op is TextRetain && op.attributes != null) { @@ -474,10 +490,72 @@ class Delta { } return previousValue; }); - return inverted.chop(); + return inverted..chop(); } List toJson() { - return operations.map((e) => e.toJson()).toList(); + return _operations.map((e) => e.toJson()).toList(); } + + /// This method will return the position of the previous rune. + /// + /// Since the encoding of the [String] in Dart is UTF-16. + /// If you want to find the previous character of a position, + /// you can' just use the `position - 1` simply. + /// + /// This method can help you to compute the position of the previous character. + int prevRunePosition(int pos) { + if (pos == 0) { + return pos - 1; + } + _rawString ??= + _operations.whereType().map((op) => op.content).join(); + _runeIndexes ??= stringIndexes(_rawString!); + return _runeIndexes![pos - 1]; + } + + /// This method will return the position of the next rune. + /// + /// Since the encoding of the [String] in Dart is UTF-16. + /// If you want to find the previous character of a position, + /// you can' just use the `position + 1` simply. + /// + /// This method can help you to compute the position of the next character. + int nextRunePosition(int pos) { + final stringContent = toRawString(); + if (pos >= stringContent.length - 1) { + return stringContent.length; + } + _runeIndexes ??= stringIndexes(_rawString!); + + for (var i = pos + 1; i < _runeIndexes!.length; i++) { + if (_runeIndexes![i] != pos) { + return _runeIndexes![i]; + } + } + + return stringContent.length; + } + + String toRawString() { + _rawString ??= + _operations.whereType().map((op) => op.content).join(); + return _rawString!; + } + + @override + Iterator get iterator => _operations.iterator; +} + +List stringIndexes(String content) { + final indexes = List.filled(content.length, 0); + final iterator = content.runes.iterator; + + while (iterator.moveNext()) { + for (var i = 0; i < iterator.currentSize; i++) { + indexes[iterator.rawIndex + i] = iterator.rawIndex; + } + } + + return indexes; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart similarity index 56% rename from frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index 5ea49c644d..396b428baf 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -1,18 +1,19 @@ import 'dart:async'; -import 'package:flowy_editor/service/service.dart'; +import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:appflowy_editor/src/service/service.dart'; import 'package:flutter/material.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/document/state_tree.dart'; -import 'package:flowy_editor/operation/operation.dart'; -import 'package:flowy_editor/operation/transaction.dart'; -import 'package:flowy_editor/undo_manager.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/document/state_tree.dart'; +import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/operation/transaction.dart'; +import 'package:appflowy_editor/src/undo_manager.dart'; class ApplyOptions { /// This flag indicates that /// whether the transaction should be recorded into - /// the undo stack. + /// the undo stack final bool recordUndo; final bool recordRedo; const ApplyOptions({ @@ -21,27 +22,56 @@ class ApplyOptions { }); } +enum CursorUpdateReason { + uiEvent, + others, +} + +/// The state of the editor. +/// +/// The state includes: +/// - The document to render +/// - The state of the selection +/// +/// [EditorState] also includes the services of the editor: +/// - Selection service +/// - Scroll service +/// - Keyboard service +/// - Input service +/// - Toolbar service +/// +/// In consideration of collaborative editing, +/// all the mutations should be applied through [Transaction]. +/// +/// Mutating the document with document's API is not recommended. class EditorState { final StateTree document; - List selectedNodes = []; - // Service reference. final service = FlowyService(); + /// Configures log output parameters, + /// such as log level and log output callbacks, + /// with this variable. + LogConfiguration get logConfiguration => LogConfiguration(); + + /// Stores the selection menu items. + List selectionMenuItems = []; + final UndoManager undoManager = UndoManager(); Selection? _cursorSelection; + // TODO: only for testing. + bool disableSealTimer = false; + Selection? get cursorSelection { return _cursorSelection; } - /// add the set reason in the future, don't use setter - updateCursorSelection(Selection? cursorSelection) { + updateCursorSelection(Selection? cursorSelection, + [CursorUpdateReason reason = CursorUpdateReason.others]) { // broadcast to other users here - if (cursorSelection == null) { - service.selectionService.clearSelection(); - } else { + if (reason != CursorUpdateReason.uiEvent) { service.selectionService.updateSelection(cursorSelection); } _cursorSelection = cursorSelection; @@ -55,8 +85,17 @@ class EditorState { undoManager.state = this; } + factory EditorState.empty() { + return EditorState(document: StateTree.empty()); + } + + /// Apply the transaction to the state. + /// + /// The options can be used to determine whether the editor + /// should record the transaction in undo/redo stack. apply(Transaction transaction, [ApplyOptions options = const ApplyOptions()]) { + // TODO: validate the transation. for (final op in transaction.operations) { _applyOperation(op); } @@ -84,11 +123,14 @@ class EditorState { } _debouncedSealHistoryItem() { + if (disableSealTimer) { + return; + } _debouncedSealHistoryItemTimer?.cancel(); _debouncedSealHistoryItemTimer = Timer(const Duration(milliseconds: 1000), () { if (undoManager.undoStack.isNonEmpty) { - debugPrint('Seal history item'); + Log.editor.debug('Seal history item'); final last = undoManager.undoStack.last; last.seal(); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/color_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/color_extension.dart new file mode 100644 index 0000000000..7228127104 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/color_extension.dart @@ -0,0 +1,36 @@ +import 'package:flutter/painting.dart'; + +extension ColorExtension on Color { + /// Try to parse the `rgba(red, greed, blue, alpha)` + /// from the string. + static Color? tryFromRgbaString(String colorString) { + final reg = RegExp(r'rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)'); + final match = reg.firstMatch(colorString); + if (match == null) { + return null; + } + + if (match.groupCount < 4) { + return null; + } + final redStr = match.group(1); + final greenStr = match.group(2); + final blueStr = match.group(3); + final alphaStr = match.group(4); + + final red = redStr != null ? int.tryParse(redStr) : null; + final green = greenStr != null ? int.tryParse(greenStr) : null; + final blue = blueStr != null ? int.tryParse(blueStr) : null; + final alpha = alphaStr != null ? int.tryParse(alphaStr) : null; + + if (red == null || green == null || blue == null || alpha == null) { + return null; + } + + return Color.fromARGB(alpha, red, green, blue); + } + + String toRgbaString() { + return 'rgba($red, $green, $blue, $alpha)'; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart similarity index 69% rename from frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart index 0ca9ba08a6..5df8665a6b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/extensions/node_extensions.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/node_extensions.dart @@ -1,8 +1,8 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/extensions/object_extensions.dart'; -import 'package:flowy_editor/extensions/path_extensions.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/extensions/object_extensions.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:flutter/material.dart'; extension NodeExtensions on Node { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/object_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/object_extensions.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/lib/extensions/object_extensions.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/object_extensions.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart similarity index 92% rename from frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart index 793dc552dd..fb643443e4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/extensions/path_extensions.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart @@ -1,4 +1,4 @@ -import 'package:flowy_editor/document/path.dart'; +import 'package:appflowy_editor/src/document/path.dart'; import 'dart:math'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart new file mode 100644 index 0000000000..1d7c68ab80 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart @@ -0,0 +1,127 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; + +extension TextNodeExtension on TextNode { + bool allSatisfyBoldInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.bold, true, selection); + + bool allSatisfyItalicInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.italic, true, selection); + + bool allSatisfyUnderlineInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.underline, true, selection); + + bool allSatisfyStrikethroughInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.strikethrough, true, selection); + + bool allSatisfyInSelection( + String styleKey, + dynamic value, + Selection selection, + ) { + final ops = delta.whereType(); + final startOffset = + selection.isBackward ? selection.start.offset : selection.end.offset; + final endOffset = + selection.isBackward ? selection.end.offset : selection.start.offset; + var start = 0; + for (final op in ops) { + if (start >= endOffset) { + break; + } + final length = op.length; + if (start < endOffset && start + length > startOffset) { + if (op.attributes == null || + !op.attributes!.containsKey(styleKey) || + op.attributes![styleKey] != value) { + return false; + } + } + start += length; + } + return true; + } + + bool allNotSatisfyInSelection( + String styleKey, + dynamic value, + Selection selection, + ) { + final ops = delta.whereType(); + final startOffset = + selection.isBackward ? selection.start.offset : selection.end.offset; + final endOffset = + selection.isBackward ? selection.end.offset : selection.start.offset; + var start = 0; + for (final op in ops) { + if (start >= endOffset) { + break; + } + final length = op.length; + if (start < endOffset && start + length > startOffset) { + if (op.attributes != null && + op.attributes!.containsKey(styleKey) && + op.attributes![styleKey] == value) { + return false; + } + } + start += length; + } + return true; + } +} + +extension TextNodesExtension on List { + bool allSatisfyBoldInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.bold, selection, true); + + bool allSatisfyItalicInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.italic, selection, true); + + bool allSatisfyUnderlineInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.underline, selection, true); + + bool allSatisfyStrikethroughInSelection(Selection selection) => + allSatisfyInSelection(StyleKey.strikethrough, selection, true); + + bool allSatisfyInSelection( + String styleKey, + Selection selection, + dynamic value, + ) { + if (isEmpty) { + return false; + } + if (length == 1) { + return first.allSatisfyInSelection(styleKey, value, selection); + } else { + for (var i = 0; i < length; i++) { + final node = this[i]; + final Selection newSelection; + if (i == 0 && pathEquals(node.path, selection.start.path)) { + newSelection = selection.copyWith( + end: Position(path: node.path, offset: node.toRawString().length), + ); + } else if (i == length - 1 && + pathEquals(node.path, selection.end.path)) { + newSelection = selection.copyWith( + start: Position(path: node.path, offset: 0), + ); + } else { + newSelection = Selection( + start: Position(path: node.path, offset: 0), + end: Position(path: node.path, offset: node.toRawString().length), + ); + } + if (!node.allSatisfyInSelection(styleKey, value, newSelection)) { + return false; + } + } + return true; + } + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/flowy_svg.dart similarity index 53% rename from frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/infra/flowy_svg.dart index d38fe2d16d..96ae89a4d5 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/flowy_svg.dart @@ -5,35 +5,48 @@ class FlowySvg extends StatelessWidget { const FlowySvg({ Key? key, this.name, - this.size = const Size(20, 20), + this.width, + this.height, this.color, this.number, + this.padding, }) : super(key: key); final String? name; - final Size size; + final double? width; + final double? height; final Color? color; final int? number; + final EdgeInsets? padding; + + final _defaultWidth = 20.0; + final _defaultHeight = 20.0; @override Widget build(BuildContext context) { + return Padding( + padding: padding ?? const EdgeInsets.all(0), + child: _buildSvg(), + ); + } + + Widget _buildSvg() { if (name != null) { - return SizedBox.fromSize( - size: size, - child: SvgPicture.asset( - 'assets/images/$name.svg', - color: color, - package: 'flowy_editor', - fit: BoxFit.fill, - ), + return SvgPicture.asset( + 'assets/images/$name.svg', + color: color, + fit: BoxFit.fill, + height: height, + width: width, + package: 'appflowy_editor', ); } else if (number != null) { final numberText = '$number.'; return SvgPicture.string( numberText, - width: size.width, - height: size.width, + width: width ?? _defaultWidth, + height: height ?? _defaultHeight, ); } return Container(); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart new file mode 100644 index 0000000000..fa49ee2e54 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/html_converter.dart @@ -0,0 +1,604 @@ +import 'dart:collection'; +import 'dart:ui'; + +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:html/parser.dart' show parse; +import 'package:html/dom.dart' as html; + +class HTMLTag { + static const h1 = "h1"; + static const h2 = "h2"; + static const h3 = "h3"; + static const orderedList = "ol"; + static const unorderedList = "ul"; + static const list = "li"; + static const paragraph = "p"; + static const image = "img"; + static const anchor = "a"; + static const italic = "i"; + static const bold = "b"; + static const underline = "u"; + static const del = "del"; + static const strong = "strong"; + static const span = "span"; + static const code = "code"; + static const blockQuote = "blockquote"; + static const div = "div"; + + static bool isTopLevel(String tag) { + return tag == h1 || + tag == h2 || + tag == h3 || + tag == paragraph || + tag == div || + tag == blockQuote; + } +} + +/// Converting the HTML to nodes +class HTMLToNodesConverter { + final html.Document _document; + + /// This flag is used for parsing HTML pasting from Google Docs + /// Google docs wraps the the content inside the `` tag. It's strange. + /// + /// If a `` element is parsing in the

, we regard it as as text spans. + /// Otherwise, it's parsed as a container. + bool _inParagraph = false; + + HTMLToNodesConverter(String htmlString) : _document = parse(htmlString); + + List toNodes() { + final childNodes = _document.body?.nodes.toList() ?? []; + return _handleContainer(childNodes); + } + + List _handleContainer(List childNodes) { + final delta = Delta(); + final result = []; + for (final child in childNodes) { + if (child is html.Element) { + if (child.localName == HTMLTag.anchor || + child.localName == HTMLTag.span || + child.localName == HTMLTag.code || + child.localName == HTMLTag.strong || + child.localName == HTMLTag.underline || + child.localName == HTMLTag.italic || + child.localName == HTMLTag.del) { + _handleRichTextElement(delta, child); + } else if (child.localName == HTMLTag.bold) { + // Google docs wraps the the content inside the `` tag. + // It's strange + if (!_inParagraph) { + result.addAll(_handleBTag(child)); + } else { + result.add(_handleRichText(child)); + } + } else if (child.localName == HTMLTag.blockQuote) { + result.addAll(_handleBlockQuote(child)); + } else { + result.addAll(_handleElement(child)); + } + } else { + delta.insert(child.text ?? ""); + } + } + if (delta.isNotEmpty) { + result.add(TextNode(type: "text", delta: delta)); + } + return result; + } + + List _handleBlockQuote(html.Element element) { + final result = []; + + for (final child in element.nodes.toList()) { + if (child is html.Element) { + result.addAll(_handleElement(child, {"subtype": StyleKey.quote})); + } + } + + return result; + } + + List _handleBTag(html.Element element) { + final childNodes = element.nodes; + return _handleContainer(childNodes); + } + + List _handleElement(html.Element element, + [Map? attributes]) { + if (element.localName == HTMLTag.h1) { + return [_handleHeadingElement(element, HTMLTag.h1)]; + } else if (element.localName == HTMLTag.h2) { + return [_handleHeadingElement(element, HTMLTag.h2)]; + } else if (element.localName == HTMLTag.h3) { + return [_handleHeadingElement(element, HTMLTag.h3)]; + } else if (element.localName == HTMLTag.unorderedList) { + return _handleUnorderedList(element); + } else if (element.localName == HTMLTag.orderedList) { + return _handleOrderedList(element); + } else if (element.localName == HTMLTag.list) { + return _handleListElement(element); + } else if (element.localName == HTMLTag.paragraph) { + return [_handleParagraph(element, attributes)]; + } else if (element.localName == HTMLTag.image) { + return [_handleImage(element)]; + } else { + final delta = Delta(); + delta.insert(element.text); + if (delta.isNotEmpty) { + return [TextNode(type: "text", delta: delta)]; + } + } + return []; + } + + Node _handleParagraph(html.Element element, + [Map? attributes]) { + _inParagraph = true; + final node = _handleRichText(element, attributes); + _inParagraph = false; + return node; + } + + Map _cssStringToMap(String? cssString) { + final result = {}; + if (cssString == null) { + return result; + } + + final entries = cssString.split(";"); + for (final entry in entries) { + final tuples = entry.split(":"); + if (tuples.length < 2) { + continue; + } + result[tuples[0].trim()] = tuples[1].trim(); + } + + return result; + } + + Attributes? _getDeltaAttributesFromHtmlAttributes( + LinkedHashMap htmlAttributes) { + final attrs = {}; + final styleString = htmlAttributes["style"]; + final cssMap = _cssStringToMap(styleString); + + final fontWeightStr = cssMap["font-weight"]; + if (fontWeightStr != null) { + if (fontWeightStr == "bold") { + attrs[StyleKey.bold] = true; + } else { + int? weight = int.tryParse(fontWeightStr); + if (weight != null && weight > 500) { + attrs[StyleKey.bold] = true; + } + } + } + + final textDecorationStr = cssMap["text-decoration"]; + if (textDecorationStr != null) { + _assignTextDecorations(attrs, textDecorationStr); + } + + final backgroundColorStr = cssMap["background-color"]; + final backgroundColor = backgroundColorStr == null + ? null + : ColorExtension.tryFromRgbaString(backgroundColorStr); + if (backgroundColor != null) { + attrs[StyleKey.backgroundColor] = + '0x${backgroundColor.value.toRadixString(16)}'; + } + + if (cssMap["font-style"] == "italic") { + attrs[StyleKey.italic] = true; + } + + return attrs.isEmpty ? null : attrs; + } + + _assignTextDecorations(Attributes attrs, String decorationStr) { + final decorations = decorationStr.split(" "); + for (final d in decorations) { + if (d == "line-through") { + attrs[StyleKey.strikethrough] = true; + } else if (d == "underline") { + attrs[StyleKey.underline] = true; + } + } + } + + _handleRichTextElement(Delta delta, html.Element element) { + if (element.localName == HTMLTag.span) { + delta.insert(element.text, + _getDeltaAttributesFromHtmlAttributes(element.attributes)); + } else if (element.localName == HTMLTag.anchor) { + final hyperLink = element.attributes["href"]; + Map? attributes; + if (hyperLink != null) { + attributes = {"href": hyperLink}; + } + delta.insert(element.text, attributes); + } else if (element.localName == HTMLTag.strong || + element.localName == HTMLTag.bold) { + delta.insert(element.text, {StyleKey.bold: true}); + } else if (element.localName == HTMLTag.underline) { + delta.insert(element.text, {StyleKey.underline: true}); + } else if (element.localName == HTMLTag.italic) { + delta.insert(element.text, {StyleKey.italic: true}); + } else if (element.localName == HTMLTag.del) { + delta.insert(element.text, {StyleKey.strikethrough: true}); + } else { + delta.insert(element.text); + } + } + + /// A container contains a will + /// be regarded as a checkbox block. + /// + /// A container contains a will be regarded as a image block + Node _handleRichText(html.Element element, + [Map? attributes]) { + final image = element.querySelector(HTMLTag.image); + if (image != null) { + final imageNode = _handleImage(image); + return imageNode; + } + final testInput = element.querySelector("input"); + bool checked = false; + final isCheckbox = + testInput != null && testInput.attributes["type"] == "checkbox"; + if (isCheckbox) { + checked = testInput.attributes.containsKey("checked") && + testInput.attributes["checked"] != "false"; + } + + final delta = Delta(); + + for (final child in element.nodes.toList()) { + if (child is html.Element) { + _handleRichTextElement(delta, child); + } else { + delta.insert(child.text ?? ""); + } + } + + final textNode = + TextNode(type: "text", delta: delta, attributes: attributes); + if (isCheckbox) { + textNode.attributes["subtype"] = StyleKey.checkbox; + textNode.attributes["checkbox"] = checked; + } + return textNode; + } + + Node _handleImage(html.Element element) { + final src = element.attributes["src"]; + final attributes = {}; + if (src != null) { + attributes["image_src"] = src; + } + return Node(type: "image", attributes: attributes, children: LinkedList()); + } + + List _handleUnorderedList(html.Element element) { + final result = []; + for (var child in element.children) { + result.addAll( + _handleListElement(child, {"subtype": StyleKey.bulletedList})); + } + return result; + } + + List _handleOrderedList(html.Element element) { + final result = []; + for (var i = 0; i < element.children.length; i++) { + final child = element.children[i]; + result.addAll(_handleListElement( + child, {"subtype": StyleKey.numberList, "number": i + 1})); + } + return result; + } + + Node _handleHeadingElement( + html.Element element, + String headingStyle, + ) { + final delta = Delta(); + delta.insert(element.text); + return TextNode( + type: "text", + attributes: {"subtype": "heading", "heading": headingStyle}, + delta: delta); + } + + List _handleListElement(html.Element element, + [Map? attributes]) { + final result = []; + final childNodes = element.nodes.toList(); + for (final child in childNodes) { + if (child is html.Element) { + result.addAll(_handleElement(child, attributes)); + } + } + return result; + } +} + +/// [NodesToHTMLConverter] is used to convert the nodes to HTML. +/// Can be used to copy & paste, exporting the document. +class NodesToHTMLConverter { + final List nodes; + final int? startOffset; + final int? endOffset; + final List _result = []; + + /// According to the W3C specs. The bullet list should be wrapped as + /// + ///

    + ///
  • xxx
  • + ///
  • xxx
  • + ///
  • xxx
  • + ///
+ /// + /// This container is used to save the list elements temporarily. + html.Element? _stashListContainer; + + NodesToHTMLConverter( + {required this.nodes, this.startOffset, this.endOffset}) { + if (nodes.isEmpty) { + return; + } else if (nodes.length == 1) { + final first = nodes.first; + if (first is TextNode) { + nodes[0] = first.copyWith( + delta: first.delta.slice(startOffset ?? 0, endOffset)); + } + } else { + final first = nodes.first; + final last = nodes.last; + if (first is TextNode) { + nodes[0] = first.copyWith(delta: first.delta.slice(startOffset ?? 0)); + } + if (last is TextNode) { + nodes[nodes.length - 1] = + last.copyWith(delta: last.delta.slice(0, endOffset)); + } + } + } + + List toHTMLNodes() { + for (final node in nodes) { + if (node.type == "text") { + final textNode = node as TextNode; + if (node == nodes.first) { + _addTextNode(textNode); + } else if (node == nodes.last) { + _addTextNode(textNode, end: endOffset); + } else { + _addTextNode(textNode); + } + } + // TODO: handle image and other blocks + } + if (_stashListContainer != null) { + _result.add(_stashListContainer!); + _stashListContainer = null; + } + return _result; + } + + _addTextNode(TextNode textNode, {int? end}) { + _addElement(textNode, _textNodeToHtml(textNode, end: end)); + } + + _addElement(TextNode textNode, html.Element element) { + if (element.localName == HTMLTag.list) { + final isNumbered = textNode.attributes["subtype"] == StyleKey.numberList; + _stashListContainer ??= html.Element.tag( + isNumbered ? HTMLTag.orderedList : HTMLTag.unorderedList); + _stashListContainer?.append(element); + } else { + if (_stashListContainer != null) { + _result.add(_stashListContainer!); + _stashListContainer = null; + } + _result.add(element); + } + } + + String toHTMLString() { + final elements = toHTMLNodes(); + final copyString = elements.fold( + "", ((previousValue, element) => previousValue + stringify(element))); + return copyString; + } + + html.Element _textNodeToHtml(TextNode textNode, {int? end}) { + String? subType = textNode.attributes["subtype"]; + String? heading = textNode.attributes["heading"]; + return _deltaToHtml(textNode.delta, + subType: subType, + heading: heading, + end: end, + checked: textNode.attributes["checkbox"] == true); + } + + String _textDecorationsFromAttributes(Attributes attributes) { + var textDecoration = []; + if (attributes[StyleKey.strikethrough] == true) { + textDecoration.add("line-through"); + } + if (attributes[StyleKey.underline] == true) { + textDecoration.add("underline"); + } + + return textDecoration.join(" "); + } + + String _attributesToCssStyle(Map attributes) { + final cssMap = {}; + if (attributes[StyleKey.backgroundColor] != null) { + final color = Color( + int.parse(attributes[StyleKey.backgroundColor]), + ); + cssMap["background-color"] = color.toRgbaString(); + } + if (attributes[StyleKey.color] != null) { + final color = Color( + int.parse(attributes[StyleKey.color]), + ); + cssMap["color"] = color.toRgbaString(); + } + if (attributes[StyleKey.bold] == true) { + cssMap["font-weight"] = "bold"; + } + + final textDecoration = _textDecorationsFromAttributes(attributes); + if (textDecoration.isNotEmpty) { + cssMap["text-decoration"] = textDecoration; + } + + if (attributes[StyleKey.italic] == true) { + cssMap["font-style"] = "italic"; + } + return _cssMapToCssStyle(cssMap); + } + + String _cssMapToCssStyle(Map cssMap) { + return cssMap.entries.fold("", (previousValue, element) { + final kv = '${element.key}: ${element.value}'; + if (previousValue.isEmpty) { + return kv; + } + return '$previousValue; $kv'; + }); + } + + /// Convert the rich text to HTML + /// + /// Use `` for bold only. + /// Use `` for italic only. + /// Use `` for strikethrough only. + /// Use `` for underline only. + /// + /// If the text has multiple styles, use a `` + /// to mix the styles. + /// + /// A CSS style string is used to describe the styles. + /// The HTML will be: + /// + /// ```html + /// Text + /// ``` + html.Element _deltaToHtml(Delta delta, + {String? subType, String? heading, int? end, bool? checked}) { + if (end != null) { + delta = delta.slice(0, end); + } + + final childNodes = []; + String tagName = HTMLTag.paragraph; + + if (subType == StyleKey.bulletedList || subType == StyleKey.numberList) { + tagName = HTMLTag.list; + } else if (subType == StyleKey.checkbox) { + final node = html.Element.html(''); + if (checked != null && checked) { + node.attributes["checked"] = "true"; + } + childNodes.add(node); + } else if (subType == StyleKey.heading) { + if (heading == StyleKey.h1) { + tagName = HTMLTag.h1; + } else if (heading == StyleKey.h2) { + tagName = HTMLTag.h2; + } else if (heading == StyleKey.h3) { + tagName = HTMLTag.h3; + } + } else if (subType == StyleKey.quote) { + tagName = HTMLTag.blockQuote; + } + + for (final op in delta) { + if (op is TextInsert) { + final attributes = op.attributes; + if (attributes != null) { + if (attributes.length == 1 && attributes[StyleKey.bold] == true) { + final strong = html.Element.tag(HTMLTag.strong); + strong.append(html.Text(op.content)); + childNodes.add(strong); + } else if (attributes.length == 1 && + attributes[StyleKey.underline] == true) { + final strong = html.Element.tag(HTMLTag.underline); + strong.append(html.Text(op.content)); + childNodes.add(strong); + } else if (attributes.length == 1 && + attributes[StyleKey.italic] == true) { + final strong = html.Element.tag(HTMLTag.italic); + strong.append(html.Text(op.content)); + childNodes.add(strong); + } else if (attributes.length == 1 && + attributes[StyleKey.strikethrough] == true) { + final strong = html.Element.tag(HTMLTag.del); + strong.append(html.Text(op.content)); + childNodes.add(strong); + } else { + final span = html.Element.tag(HTMLTag.span); + final cssString = _attributesToCssStyle(attributes); + if (cssString.isNotEmpty) { + span.attributes["style"] = cssString; + } + span.append(html.Text(op.content)); + childNodes.add(span); + } + } else { + childNodes.add(html.Text(op.content)); + } + } + } + + if (tagName == HTMLTag.blockQuote) { + final p = html.Element.tag(HTMLTag.paragraph); + for (final node in childNodes) { + p.append(node); + } + final blockQuote = html.Element.tag(tagName); + blockQuote.append(p); + return blockQuote; + } else if (!HTMLTag.isTopLevel(tagName)) { + final p = html.Element.tag(HTMLTag.paragraph); + for (final node in childNodes) { + p.append(node); + } + final result = html.Element.tag(HTMLTag.list); + result.append(p); + return result; + } else { + final p = html.Element.tag(tagName); + for (final node in childNodes) { + p.append(node); + } + return p; + } + } +} + +String stringify(html.Node node) { + if (node is html.Element) { + return node.outerHtml; + } + + if (node is html.Text) { + return node.text; + } + + return ""; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart new file mode 100644 index 0000000000..2218b10181 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart @@ -0,0 +1,138 @@ +import 'package:logging/logging.dart'; + +enum LogLevel { + off, + error, + warn, + info, + debug, + all, +} + +typedef LogHandler = void Function(String message); + +/// Manages log service for [AppFlowyEditor] +/// +/// Set the log level and config the handler depending on your need. +class LogConfiguration { + LogConfiguration._() { + Logger.root.onRecord.listen((record) { + if (handler != null) { + handler!( + '[${record.level.toLogLevel().name}][${record.loggerName}]: ${record.time}: ${record.message}', + ); + } + }); + } + + factory LogConfiguration() => _logConfiguration; + + static final LogConfiguration _logConfiguration = LogConfiguration._(); + + LogHandler? handler; + + LogLevel _level = LogLevel.off; + + LogLevel get level => _level; + set level(LogLevel level) { + _level = level; + Logger.root.level = level.toLevel(); + } +} + +/// For logging message in AppFlowyEditor +class Log { + Log._({ + required this.name, + }) : _logger = Logger(name); + + final String name; + late final Logger _logger; + + /// For logging message related to [AppFlowyEditor]. + /// + /// For example, uses the logger when registering plugins + /// or handling something related to [EditorState]. + static Log editor = Log._(name: 'editor'); + + /// For logging message related to [AppFlowySelectionService]. + /// + /// For example, uses the logger when updating or clearing selection. + static Log selection = Log._(name: 'selection'); + + /// For logging message related to [AppFlowyKeyboardService]. + /// + /// For example, uses the logger when processing shortcut events. + static Log keyboard = Log._(name: 'keyboard'); + + /// For logging message related to [AppFlowyInputService]. + /// + /// For example, uses the logger when processing text inputs. + static Log input = Log._(name: 'input'); + + /// For logging message related to [AppFlowyScrollService]. + /// + /// For example, uses the logger when processing scroll events. + static Log scroll = Log._(name: 'scroll'); + + /// For logging message related to UI. + /// + /// For example, uses the logger when building the widget. + static Log ui = Log._(name: 'ui'); + + void error(String message) => _logger.severe(message); + void warn(String message) => _logger.warning(message); + void info(String message) => _logger.info(message); + void debug(String message) => _logger.fine(message); +} + +extension on LogLevel { + Level toLevel() { + switch (this) { + case LogLevel.off: + return Level.OFF; + case LogLevel.error: + return Level.SEVERE; + case LogLevel.warn: + return Level.WARNING; + case LogLevel.info: + return Level.INFO; + case LogLevel.debug: + return Level.FINE; + case LogLevel.all: + return Level.ALL; + } + } + + String get name { + switch (this) { + case LogLevel.off: + return 'OFF'; + case LogLevel.error: + return 'ERROR'; + case LogLevel.warn: + return 'WARN'; + case LogLevel.info: + return 'INFO'; + case LogLevel.debug: + return 'DEBUG'; + case LogLevel.all: + return 'ALL'; + } + } +} + +extension on Level { + LogLevel toLogLevel() { + if (this == Level.SEVERE) { + return LogLevel.error; + } else if (this == Level.WARNING) { + return LogLevel.warn; + } else if (this == Level.INFO) { + return LogLevel.info; + } else if (this == Level.FINE) { + return LogLevel.debug; + } + return LogLevel.off; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart similarity index 98% rename from frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart index 58fe8e2f14..d1a0024a98 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart @@ -1,5 +1,4 @@ -import 'package:flowy_editor/document/attributes.dart'; -import 'package:flowy_editor/flowy_editor.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; abstract class Operation { factory Operation.fromJson(Map map) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart similarity index 95% rename from frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart index 5dcf167628..8527e15325 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart @@ -1,6 +1,6 @@ import 'dart:collection'; import 'package:flutter/material.dart'; -import 'package:flowy_editor/document/selection.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; import './operation.dart'; /// A [Transaction] has a list of [Operation] objects that will be applied diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart similarity index 54% rename from frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index 8fa67687c2..4f6de6e9b0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart @@ -1,19 +1,19 @@ import 'dart:collection'; +import 'dart:math'; -import 'package:flowy_editor/document/attributes.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/document/text_delta.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/operation/operation.dart'; -import 'package:flowy_editor/operation/transaction.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/operation/transaction.dart'; /// A [TransactionBuilder] is used to build the transaction from the state. -/// It will save make a snapshot of the cursor selection state automatically. -/// The cursor can be resorted if the transaction is undo. - +/// It will save a snapshot of the cursor selection state automatically. +/// The cursor can be restored if the transaction is undo. class TransactionBuilder { final List operations = []; EditorState state; @@ -22,30 +22,36 @@ class TransactionBuilder { TransactionBuilder(this.state); - /// Commit the operations to the state - commit() { + /// Commits the operations to the state + Future commit() async { final transaction = finish(); state.apply(transaction); } + /// Inserts the nodes at the position of path. insertNode(Path path, Node node) { insertNodes(path, [node]); } + /// Inserts a sequence of nodes at the position of path. insertNodes(Path path, List nodes) { beforeSelection = state.cursorSelection; - add(InsertOperation(path, nodes)); + add(InsertOperation(path, nodes.map((node) => node.deepClone()).toList())); } + /// Updates the attributes of nodes. updateNode(Node node, Attributes attributes) { beforeSelection = state.cursorSelection; + + final inverted = invertAttributes(attributes, node.attributes); add(UpdateOperation( node.path, - Attributes.from(node.attributes)..addAll(attributes), - node.attributes, + {...attributes}, + inverted, )); } + /// Deletes a node in the document. deleteNode(Node node) { deleteNodesAtPath(node.path); } @@ -54,6 +60,9 @@ class TransactionBuilder { nodes.forEach(deleteNode); } + /// Deletes a sequence of nodes at the path of the document. + /// The length specifies the length of the following nodes to delete( + /// including the start one). deleteNodesAtPath(Path path, [int length = 1]) { if (path.isEmpty) { return; @@ -66,7 +75,7 @@ class TransactionBuilder { nodes.add(node); } - add(DeleteOperation(path, nodes)); + add(DeleteOperation(path, nodes.map((node) => node.deepClone()).toList())); } textEdit(TextNode node, Delta Function() f) { @@ -93,7 +102,7 @@ class TransactionBuilder { () => Delta() ..retain(firstOffset ?? firstLength) ..delete(firstLength - (firstOffset ?? firstLength)) - ..addAll(secondNode.delta.slice(secondOffset, secondLength).operations), + ..addAll(secondNode.delta.slice(secondOffset, secondLength)), ); afterSelection = Selection.collapsed( Position( @@ -103,28 +112,65 @@ class TransactionBuilder { ); } + /// Inserts content at a specified index. + /// Optionally, you may specify formatting attributes that are applied to the inserted string. + /// By default, the formatting attributes before the insert position will be used. insertText(TextNode node, int index, String content, [Attributes? attributes]) { - textEdit(node, () => Delta().retain(index).insert(content, attributes)); + var newAttributes = attributes; + if (index != 0 && attributes == null) { + newAttributes = + node.delta.slice(max(index - 1, 0), index).first.attributes; + } + textEdit( + node, + () => Delta() + ..retain(index) + ..insert( + content, + newAttributes, + ), + ); afterSelection = Selection.collapsed( Position(path: node.path, offset: index + content.length)); } + /// Assigns formatting attributes to a range of text. formatText(TextNode node, int index, int length, Attributes attributes) { - textEdit(node, () => Delta().retain(index).retain(length, attributes)); + textEdit( + node, + () => Delta() + ..retain(index) + ..retain(length, attributes)); afterSelection = beforeSelection; } + /// Deletes length characters starting from index. deleteText(TextNode node, int index, int length) { - textEdit(node, () => Delta().retain(index).delete(length)); + textEdit( + node, + () => Delta() + ..retain(index) + ..delete(length)); afterSelection = Selection.collapsed(Position(path: node.path, offset: index)); } - replaceText(TextNode node, int index, int length, String content) { + replaceText(TextNode node, int index, int length, String content, + [Attributes? attributes]) { + var newAttributes = attributes; + if (attributes == null) { + final ops = node.delta.slice(index, index + length); + if (ops.isNotEmpty) { + newAttributes = ops.first.attributes; + } + } textEdit( node, - () => Delta().retain(index).delete(length).insert(content), + () => Delta() + ..retain(index) + ..delete(length) + ..insert(content, newAttributes), ); afterSelection = Selection.collapsed( Position( @@ -134,6 +180,11 @@ class TransactionBuilder { ); } + /// Adds an operation to the transaction. + /// This method will merge operations if they are both TextEdits. + /// + /// Also, this method will transform the path of the operations + /// to avoid conflicts. add(Operation op) { final Operation? last = operations.isEmpty ? null : operations.last; if (last != null) { @@ -152,9 +203,13 @@ class TransactionBuilder { for (var i = 0; i < operations.length; i++) { op = transformOperation(operations[i], op); } + if (op is TextEditOperation && op.delta.isEmpty) { + return; + } operations.add(op); } + /// Generates a immutable [Transaction] to apply or transmit. Transaction finish() { return Transaction( operations: UnmodifiableListView(operations), diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart similarity index 84% rename from frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart index fa32743b02..e71dc7c79b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; class EditorEntryWidgetBuilder extends NodeWidgetBuilder { @override @@ -33,7 +33,7 @@ class EditorNodeWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: node.children .map( (child) => diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart new file mode 100644 index 0000000000..267a5acc66 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart @@ -0,0 +1,89 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return BulletedListTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return true; + }); +} + +class BulletedListTextNodeWidget extends StatefulWidget { + const BulletedListTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => + _BulletedListTextNodeWidgetState(); +} + +// customize + +class _BulletedListTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + @override + final iconKey = GlobalKey(); + + final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text'); + final _iconWidth = 20.0; + final _iconRightPadding = 5.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Widget build(BuildContext context) { + final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding; + + return SizedBox( + width: defaultMaxTextNodeWidth, + child: Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + height: _iconWidth, + padding: + EdgeInsets.only(top: topPadding, right: _iconRightPadding), + name: 'point', + ), + Expanded( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'List', + textNode: widget.textNode, + editorState: widget.editorState, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart similarity index 56% rename from frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart index 317d1a6bdf..9b7d3a730f 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -1,12 +1,12 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/rich_text/default_selectable.dart'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; import 'package:flutter/material.dart'; class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder { @@ -41,19 +41,17 @@ class CheckboxNodeWidget extends StatefulWidget { class _CheckboxNodeWidgetState extends State with Selectable, DefaultSelectable { - final _richTextKey = GlobalKey(debugLabel: 'checkbox_text'); + @override + final iconKey = GlobalKey(); - final leftPadding = 20.0; + final _richTextKey = GlobalKey(debugLabel: 'checkbox_text'); + final _iconWidth = 20.0; + final _iconRightPadding = 5.0; @override Selectable get forward => _richTextKey.currentState as Selectable; - @override - Offset get baseOffset { - return Offset(leftPadding, 0); - } - @override Widget build(BuildContext context) { if (widget.textNode.children.isEmpty) { @@ -65,33 +63,46 @@ class _CheckboxNodeWidgetState extends State Widget _buildWithSingle(BuildContext context) { final check = widget.textNode.attributes.check; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - child: FlowySvg( - size: Size.square(leftPadding), - name: check ? 'check' : 'uncheck', - ), - onTap: () { - debugPrint('[Checkbox] onTap...'); - TransactionBuilder(widget.editorState) - ..updateNode(widget.textNode, { - StyleKey.checkbox: !check, - }) - ..commit(); - }, + final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding; + return SizedBox( + width: defaultMaxTextNodeWidth, + child: Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + key: iconKey, + child: FlowySvg( + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only( + top: topPadding, + right: _iconRightPadding, + ), + name: check ? 'check' : 'uncheck', + ), + onTap: () { + TransactionBuilder(widget.editorState) + ..updateNode(widget.textNode, { + StyleKey.checkbox: !check, + }) + ..commit(); + }, + ), + Expanded( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'To-do', + textNode: widget.textNode, + textSpanDecorator: _textSpanDecorator, + placeholderTextSpanDecorator: _textSpanDecorator, + editorState: widget.editorState, + ), + ), + ], ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'To-do', - textNode: widget.textNode, - textSpanDecorator: _textSpanDecorator, - editorState: widget.editorState, - ), - ), - ], + ), ); } @@ -141,7 +152,11 @@ class _CheckboxNodeWidgetState extends State style: widget.textNode.attributes.check ? span.style?.copyWith( color: Colors.grey, - decoration: TextDecoration.lineThrough, + decoration: TextDecoration.combine([ + TextDecoration.lineThrough, + if (span.style?.decoration != null) + span.style!.decoration! + ]), ) : span.style, recognizer: span.recognizer, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart similarity index 64% rename from frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart index e218fdcaf6..91b9cbf981 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart @@ -1,12 +1,22 @@ -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:flutter/material.dart'; mixin DefaultSelectable { Selectable get forward; - Offset get baseOffset; + GlobalKey? get iconKey; + + Offset get baseOffset { + if (iconKey != null) { + final renderBox = iconKey!.currentContext?.findRenderObject(); + if (renderBox is RenderBox) { + return Offset(renderBox.size.width, 0); + } + } + return Offset.zero; + } Position getPositionInOffset(Offset start) => forward.getPositionInOffset(start); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart similarity index 69% rename from frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 32715d8556..fb4dbdc4a1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -1,31 +1,16 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/document/text_delta.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; - -class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return FlowyRichText( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return true; - }); -} +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); @@ -33,7 +18,7 @@ class FlowyRichText extends StatefulWidget { const FlowyRichText({ Key? key, this.cursorHeight, - this.cursorWidth = 2.0, + this.cursorWidth = 1.0, this.textSpanDecorator, this.placeholderText = ' ', this.placeholderTextSpanDecorator, @@ -57,6 +42,8 @@ class _FlowyRichTextState extends State with Selectable { final _textKey = GlobalKey(); final _placeholderTextKey = GlobalKey(); + final _lineHeight = 1.5; + RenderParagraph get _renderParagraph => _textKey.currentContext?.findRenderObject() as RenderParagraph; @@ -73,7 +60,7 @@ class _FlowyRichTextState extends State with Selectable { @override Position end() => Position( - path: widget.textNode.path, offset: widget.textNode.toRawString().length); + path: widget.textNode.path, offset: widget.textNode.delta.length); @override Rect? getCursorRectInPosition(Position position) { @@ -83,13 +70,15 @@ class _FlowyRichTextState extends State with Selectable { final cursorHeight = widget.cursorHeight ?? _renderParagraph.getFullHeightForCaret(textPosition) ?? _placeholderRenderParagraph.getFullHeightForCaret(textPosition) ?? - 18.0; // default height - return Rect.fromLTWH( + 16.0; // default height + + final rect = Rect.fromLTWH( cursorOffset.dx - (widget.cursorWidth / 2), cursorOffset.dy, widget.cursorWidth, cursorHeight, ); + return rect; } @override @@ -119,7 +108,7 @@ class _FlowyRichTextState extends State with Selectable { extentOffset: selection.end.offset, ); return _renderParagraph - .getBoxesForSelection(textSelection) + .getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max) .map((box) => box.toRect()) .toList(); } @@ -152,23 +141,13 @@ class _FlowyRichTextState extends State with Selectable { } Widget _buildPlaceholderText(BuildContext context) { - final textSpan = TextSpan( - children: [ - TextSpan( - text: widget.placeholderText, - style: TextStyle( - color: widget.textNode.toRawString().isNotEmpty - ? Colors.transparent - : Colors.grey, - fontSize: baseFontSize, - ), - ), - ], - ); + final textSpan = _placeholderTextSpan; return RichText( key: _placeholderTextKey, - text: widget.placeholderTextSpanDecorator != null - ? widget.placeholderTextSpanDecorator!(textSpan) + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, applyHeightToLastDescent: false), + text: widget.textSpanDecorator != null + ? widget.textSpanDecorator!(textSpan) : textSpan, ); } @@ -177,6 +156,8 @@ class _FlowyRichTextState extends State with Selectable { final textSpan = _textSpan; return RichText( key: _textKey, + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, applyHeightToLastDescent: false), text: widget.textSpanDecorator != null ? widget.textSpanDecorator!(textSpan) : textSpan, @@ -184,26 +165,26 @@ class _FlowyRichTextState extends State with Selectable { } // unused now. - Widget _buildRichTextWithChildren(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSingleRichText(context), - ...widget.textNode.children - .map( - (child) => widget.editorState.service.renderPluginService - .buildPluginWidget( - NodeWidgetContext( - context: context, - node: child, - editorState: widget.editorState, - ), - ), - ) - .toList() - ], - ); - } + // Widget _buildRichTextWithChildren(BuildContext context) { + // return Column( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // _buildSingleRichText(context), + // ...widget.textNode.children + // .map( + // (child) => widget.editorState.service.renderPluginService + // .buildPluginWidget( + // NodeWidgetContext( + // context: context, + // node: child, + // editorState: widget.editorState, + // ), + // ), + // ) + // .toList() + // ], + // ); + // } @override Offset localToGlobal(Offset offset) { @@ -211,12 +192,23 @@ class _FlowyRichTextState extends State with Selectable { } TextSpan get _textSpan => TextSpan( - children: widget.textNode.delta.operations + children: widget.textNode.delta .whereType() .map((insert) => RichTextStyle( attributes: insert.attributes ?? {}, text: insert.content, + height: _lineHeight, ).toTextSpan()) .toList(growable: false), ); + + TextSpan get _placeholderTextSpan => TextSpan(children: [ + RichTextStyle( + text: widget.placeholderText, + attributes: { + StyleKey.color: '0xFF707070', + }, + height: _lineHeight, + ).toTextSpan() + ]); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart similarity index 65% rename from frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart index 2511349e4d..050b330f8b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart @@ -1,10 +1,10 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/render/rich_text/default_selectable.dart'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; import 'package:flutter/material.dart'; class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder { @@ -41,9 +41,11 @@ class HeadingTextNodeWidget extends StatefulWidget { class _HeadingTextNodeWidgetState extends State with Selectable, DefaultSelectable { + @override + GlobalKey? get iconKey => null; + final _richTextKey = GlobalKey(debugLabel: 'heading_text'); - final topPadding = 5.0; - final bottomPadding = 2.0; + final _topPadding = 5.0; @override Selectable get forward => @@ -51,30 +53,27 @@ class _HeadingTextNodeWidgetState extends State @override Offset get baseOffset { - return Offset(0, topPadding); + return Offset(0, _topPadding); } @override Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: EdgeInsets.only( - top: topPadding, - bottom: bottomPadding, - ), - child: Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Heading', - placeholderTextSpanDecorator: _placeholderTextSpanDecorator, - textSpanDecorator: _textSpanDecorator, - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ) - ], + return Padding( + padding: EdgeInsets.only( + top: _topPadding, + bottom: defaultLinePadding, + ), + child: SizedBox( + width: defaultMaxTextNodeWidth, + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'Heading', + placeholderTextSpanDecorator: _placeholderTextSpanDecorator, + textSpanDecorator: _textSpanDecorator, + textNode: widget.textNode, + editorState: widget.editorState, + ), + ), ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart new file mode 100644 index 0000000000..de3b0b55b6 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart @@ -0,0 +1,87 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return NumberListTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return node.attributes.number != null; + }); +} + +class NumberListTextNodeWidget extends StatefulWidget { + const NumberListTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => + _NumberListTextNodeWidgetState(); +} + +// customize + +class _NumberListTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + @override + final iconKey = GlobalKey(); + + final _richTextKey = GlobalKey(debugLabel: 'number_list_text'); + final _iconWidth = 20.0; + final _iconRightPadding = 5.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Widget build(BuildContext context) { + final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding; + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: SizedBox( + width: defaultMaxTextNodeWidth, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + height: _iconWidth, + padding: + EdgeInsets.only(top: topPadding, right: _iconRightPadding), + number: widget.textNode.attributes.number, + ), + Expanded( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'List', + textNode: widget.textNode, + editorState: widget.editorState, + ), + ), + ], + ), + )); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart new file mode 100644 index 0000000000..0389dfa50f --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart @@ -0,0 +1,93 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return QuotedTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return true; + }); +} + +class QuotedTextNodeWidget extends StatefulWidget { + const QuotedTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _QuotedTextNodeWidgetState(); +} + +// customize + +class _QuotedTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + @override + final iconKey = GlobalKey(); + + final _richTextKey = GlobalKey(debugLabel: 'quoted_text'); + final _iconWidth = 20.0; + final _iconRightPadding = 5.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Widget build(BuildContext context) { + final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding; + return SizedBox( + width: defaultMaxTextNodeWidth, + child: Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + padding: EdgeInsets.only( + top: topPadding, right: _iconRightPadding), + name: 'quote', + ), + Expanded( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'Quote', + textNode: widget.textNode, + editorState: widget.editorState, + ), + ), + ], + ), + ), + )); + } + + double get _quoteHeight { + final lines = + widget.textNode.toRawString().characters.where((c) => c == '\n').length; + return (lines + 1) * _iconWidth; + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart new file mode 100644 index 0000000000..d8dcfb91f6 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart @@ -0,0 +1,67 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; + +class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + return RichTextNodeWidget( + key: context.node.key, + textNode: context.node, + editorState: context.editorState, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return true; + }); +} + +class RichTextNodeWidget extends StatefulWidget { + const RichTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _RichTextNodeWidgetState(); +} + +// customize + +class _RichTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + @override + GlobalKey? get iconKey => null; + + final _richTextKey = GlobalKey(debugLabel: 'rich_text'); + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: defaultMaxTextNodeWidth, + child: Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart similarity index 68% rename from frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart index cc4f6038ac..7bd68c45e7 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart @@ -1,6 +1,8 @@ -import 'package:flowy_editor/document/attributes.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher_string.dart'; /// /// Supported partial rendering types: @@ -21,7 +23,7 @@ class StyleKey { static String underline = 'underline'; static String strikethrough = 'strikethrough'; static String color = 'color'; - static String highlightColor = 'highlightColor'; + static String backgroundColor = 'backgroundColor'; static String font = 'font'; static String href = 'href'; @@ -50,6 +52,7 @@ class StyleKey { ]; static List globalStyleKeys = [ + StyleKey.subtype, StyleKey.heading, StyleKey.checkbox, StyleKey.bulletedList, @@ -59,7 +62,12 @@ class StyleKey { ]; } +// TODO: customize +double defaultMaxTextNodeWidth = 780.0; +double defaultLinePadding = 8.0; double baseFontSize = 16.0; +String defaultHighlightColor = '0x6000BCF0'; +String defaultBackgroundColor = '0x00000000'; // TODO: customize. Map headingToFontSize = { StyleKey.h1: baseFontSize + 15, @@ -146,11 +154,11 @@ extension DeltaAttributesExtensions on Attributes { return null; } - Color? get hightlightColor { - if (containsKey(StyleKey.highlightColor) && - this[StyleKey.highlightColor] is String) { + Color? get backgroundColor { + if (containsKey(StyleKey.backgroundColor) && + this[StyleKey.backgroundColor] is String) { return Color( - int.parse(this[StyleKey.highlightColor]), + int.parse(this[StyleKey.backgroundColor]), ); } return null; @@ -174,28 +182,51 @@ class RichTextStyle { RichTextStyle({ required this.attributes, required this.text, + this.height = 1.5, }); + RichTextStyle.fromTextNode(TextNode textNode) + : this(attributes: textNode.attributes, text: textNode.toRawString()); + final Attributes attributes; final String text; + final double height; - TextSpan toTextSpan() { + TextSpan toTextSpan() => _toTextSpan(height); + + double get topPadding { + return 0; + } + + TextSpan _toTextSpan(double? height) { return TextSpan( text: text, style: TextStyle( - fontWeight: fontWeight, - fontStyle: fontStyle, - fontSize: fontSize, - color: textColor, - backgroundColor: backgroundColor, - decoration: textDecoration, + fontWeight: _fontWeight, + fontStyle: _fontStyle, + fontSize: _fontSize, + color: _textColor, + decoration: _textDecoration, + background: _background, + height: height, ), - recognizer: recognizer, + recognizer: _recognizer, ); } + Paint? get _background { + if (_backgroundColor != null) { + return Paint() + ..color = _backgroundColor! + ..strokeWidth = 24.0 + ..style = PaintingStyle.fill + ..strokeJoin = StrokeJoin.round; + } + return null; + } + // bold - FontWeight get fontWeight { + FontWeight get _fontWeight { if (attributes.bold) { return FontWeight.bold; } @@ -203,43 +234,50 @@ class RichTextStyle { } // underline or strikethrough - TextDecoration get textDecoration { + TextDecoration get _textDecoration { + var decorations = [TextDecoration.none]; if (attributes.underline || attributes.href != null) { - return TextDecoration.underline; - } else if (attributes.strikethrough) { - return TextDecoration.lineThrough; + decorations.add(TextDecoration.underline); } - return TextDecoration.none; + if (attributes.strikethrough) { + decorations.add(TextDecoration.lineThrough); + } + return TextDecoration.combine(decorations); } // font - FontStyle get fontStyle => + FontStyle get _fontStyle => attributes.italic ? FontStyle.italic : FontStyle.normal; // text color - Color get textColor { + Color get _textColor { if (attributes.href != null) { return Colors.lightBlue; } return attributes.color ?? Colors.black; } - Color get backgroundColor { - return attributes.hightlightColor ?? Colors.transparent; + Color? get _backgroundColor { + if (attributes.backgroundColor != null) { + return attributes.backgroundColor!; + } else if (attributes.code) { + return Colors.grey.withOpacity(0.4); + } + return null; } // font size - double get fontSize { + double get _fontSize { return baseFontSize; } // recognizer - GestureRecognizer? get recognizer { + GestureRecognizer? get _recognizer { final href = attributes.href; if (href != null) { return TapGestureRecognizer() ..onTap = () async { - // FIXME: launch the url + await launchUrlString(href); }; } return null; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/cursor_widget.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/cursor_widget.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart similarity index 57% rename from frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart index 58377dcc02..434b4b67b1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart @@ -1,34 +1,40 @@ -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; import 'package:flutter/material.dart'; +/// [Selectable] is used for the editor to calculate the position +/// and size of the selection. /// +/// The widget returned by NodeWidgetBuilder must be with [Selectable], +/// otherwise the [AppFlowySelectionService] will not work properly. mixin Selectable on State { - /// Returns a [List] of the [Rect] selection surrounded by start and end + /// Returns the [Selection] surrounded by start and end /// in current widget. /// /// [start] and [end] are the offsets under the global coordinate system. /// - /// The return result must be a [List] of the [Rect] - /// under the local coordinate system. Selection getSelectionInRange(Offset start, Offset end); + /// Returns a [List] of the [Rect] area within selection + /// in current widget. + /// + /// The return result must be a [List] of the [Rect] + /// under the local coordinate system. List getRectsInSelection(Selection selection); - /// Returns a [Rect] for the offset in current widget. + /// Returns [Position] for the offset in current widget. /// /// [start] is the offset of the global coordination system. + Position getPositionInOffset(Offset start); + + /// Returns [Rect] for the position in current widget. /// /// The return result must be an offset of the local coordinate system. - Position getPositionInOffset(Offset start); - Selection? getWorldBoundaryInOffset(Offset start) { - return null; - } - Rect? getCursorRectInPosition(Position position) { return null; } + /// Return global offset from local offset. Offset localToGlobal(Offset offset); Position start(); @@ -36,9 +42,15 @@ mixin Selectable on State { /// For [TextNode] only. /// - /// Returns a [TextSelection] or [Null]. - /// /// Only the widget rendered by [TextNode] need to implement the detail, /// and the rest can return null. TextSelection? getTextSelectionInSelection(Selection selection) => null; + + /// For [TextNode] only. + /// + /// Only the widget rendered by [TextNode] need to implement the detail, + /// and the rest can return null. + Selection? getWorldBoundaryInOffset(Offset start) { + return null; + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selection_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selection_widget.dart similarity index 100% rename from frontend/app_flowy/packages/flowy_editor/lib/render/selection/selection_widget.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selection_widget.dart diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/toolbar_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/toolbar_widget.dart new file mode 100644 index 0000000000..4c2b621795 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/toolbar_widget.dart @@ -0,0 +1,217 @@ +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; + +typedef ToolbarEventHandler = void Function(EditorState editorState); + +typedef ToolbarEventHandlers = Map; + +ToolbarEventHandlers defaultToolbarEventHandlers = { + 'bold': (editorState) => formatBold(editorState), + 'italic': (editorState) => formatItalic(editorState), + 'strikethrough': (editorState) => formatStrikethrough(editorState), + 'underline': (editorState) => formatUnderline(editorState), + 'quote': (editorState) => formatQuote(editorState), + 'bulleted_list': (editorState) => formatBulletedList(editorState), + 'highlight': (editorState) => formatHighlight(editorState), + 'Text': (editorState) => formatText(editorState), + 'h1': (editorState) => formatHeading(editorState, StyleKey.h1), + 'h2': (editorState) => formatHeading(editorState, StyleKey.h2), + 'h3': (editorState) => formatHeading(editorState, StyleKey.h3), +}; + +List defaultListToolbarEventNames = [ + 'Text', + 'H1', + 'H2', + 'H3', +]; + +mixin ToolbarMixin on State { + void hide(); +} + +class ToolbarWidget extends StatefulWidget { + const ToolbarWidget({ + Key? key, + required this.editorState, + required this.layerLink, + required this.offset, + required this.handlers, + }) : super(key: key); + + final EditorState editorState; + final LayerLink layerLink; + final Offset offset; + final ToolbarEventHandlers handlers; + + @override + State createState() => _ToolbarWidgetState(); +} + +class _ToolbarWidgetState extends State with ToolbarMixin { + // final GlobalKey _listToolbarKey = GlobalKey(); + + final toolbarHeight = 32.0; + final topPadding = 5.0; + + final listToolbarWidth = 60.0; + final listToolbarHeight = 120.0; + + final cornerRadius = 8.0; + + OverlayEntry? _listToolbarOverlay; + + @override + Widget build(BuildContext context) { + return Positioned( + top: widget.offset.dx, + left: widget.offset.dy, + child: CompositedTransformFollower( + link: widget.layerLink, + showWhenUnlinked: true, + offset: widget.offset, + child: _buildToolbar(context), + ), + ); + } + + @override + void hide() { + _listToolbarOverlay?.remove(); + _listToolbarOverlay = null; + } + + Widget _buildToolbar(BuildContext context) { + return Material( + borderRadius: BorderRadius.circular(cornerRadius), + color: const Color(0xFF333333), + child: SizedBox( + height: toolbarHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // _listToolbar(context), + _centerToolbarIcon('h1', tooltipMessage: 'Heading 1'), + _centerToolbarIcon('h2', tooltipMessage: 'Heading 2'), + _centerToolbarIcon('h3', tooltipMessage: 'Heading 3'), + _centerToolbarIcon('divider', width: 2), + _centerToolbarIcon('bold', tooltipMessage: 'Bold'), + _centerToolbarIcon('italic', tooltipMessage: 'Italic'), + _centerToolbarIcon('strikethrough', + tooltipMessage: 'Strikethrough'), + _centerToolbarIcon('underline', tooltipMessage: 'Underline'), + _centerToolbarIcon('divider', width: 2), + _centerToolbarIcon('quote', tooltipMessage: 'Quote'), + // _centerToolbarIcon('number_list'), + _centerToolbarIcon('bulleted_list', + tooltipMessage: 'Bulleted List'), + _centerToolbarIcon('divider', width: 2), + _centerToolbarIcon('highlight', tooltipMessage: 'Highlight'), + ], + ), + ), + ); + } + + // Widget _listToolbar(BuildContext context) { + // return _centerToolbarIcon( + // 'quote', + // key: _listToolbarKey, + // width: listToolbarWidth, + // onTap: () => _onTapListToolbar(context), + // ); + // } + + Widget _centerToolbarIcon(String name, + {Key? key, String? tooltipMessage, double? width, VoidCallback? onTap}) { + return Tooltip( + key: key, + preferBelow: false, + message: tooltipMessage ?? '', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap ?? () => _onTap(name), + child: SizedBox.fromSize( + size: + Size(toolbarHeight - (width != null ? 20 : 0), toolbarHeight), + child: Center( + child: FlowySvg( + width: width ?? 20, + name: 'toolbar/$name', + ), + ), + ), + ), + )); + } + + // void _onTapListToolbar(BuildContext context) { + // // TODO: implement more detailed UI. + // final items = defaultListToolbarEventNames; + // final renderBox = + // _listToolbarKey.currentContext?.findRenderObject() as RenderBox; + // final offset = renderBox + // .localToGlobal(Offset.zero) + // .translate(0, toolbarHeight - cornerRadius); + // final rect = offset & Size(listToolbarWidth, listToolbarHeight); + + // _listToolbarOverlay?.remove(); + // _listToolbarOverlay = OverlayEntry(builder: (context) { + // return Positioned.fromRect( + // rect: rect, + // child: Material( + // borderRadius: BorderRadius.only( + // bottomLeft: Radius.circular(cornerRadius), + // bottomRight: Radius.circular(cornerRadius), + // ), + // color: const Color(0xFF333333), + // child: SingleChildScrollView( + // child: ListView.builder( + // itemExtent: toolbarHeight, + // padding: const EdgeInsets.only(bottom: 10.0), + // shrinkWrap: true, + // itemCount: items.length, + // itemBuilder: ((context, index) { + // return ListTile( + // contentPadding: const EdgeInsets.only( + // left: 3.0, + // right: 3.0, + // ), + // minVerticalPadding: 0.0, + // title: FittedBox( + // fit: BoxFit.scaleDown, + // child: Text( + // items[index], + // textAlign: TextAlign.center, + // style: const TextStyle( + // color: Colors.white, + // ), + // ), + // ), + // onTap: () { + // _onTap(items[index]); + // }, + // ); + // }), + // ), + // ), + // ), + // ); + // }); + // // TODO: disable scrolling. + // Overlay.of(context)?.insert(_listToolbarOverlay!); + // } + + void _onTap(String eventName) { + if (defaultToolbarEventHandlers.containsKey(eventName)) { + defaultToolbarEventHandlers[eventName]!(widget.editorState); + return; + } + assert(false, 'Could not find the event handler for $eventName'); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart new file mode 100644 index 0000000000..36e0a2e02e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_item_widget.dart @@ -0,0 +1,54 @@ +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:flutter/material.dart'; + +class SelectionMenuItemWidget extends StatelessWidget { + const SelectionMenuItemWidget({ + Key? key, + required this.editorState, + required this.menuService, + required this.item, + required this.isSelected, + this.width = 140.0, + this.selectedColor = const Color(0xFFE0F8FF), + }) : super(key: key); + + final EditorState editorState; + final SelectionMenuService menuService; + final SelectionMenuItem item; + final double width; + final bool isSelected; + final Color selectedColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0), + child: SizedBox( + width: width, + child: TextButton.icon( + icon: item.icon, + style: ButtonStyle( + alignment: Alignment.centerLeft, + overlayColor: MaterialStateProperty.all(selectedColor), + backgroundColor: isSelected + ? MaterialStateProperty.all(selectedColor) + : MaterialStateProperty.all(Colors.transparent), + ), + label: Text( + item.name, + textAlign: TextAlign.left, + style: const TextStyle( + color: Colors.black, + fontSize: 14.0, + ), + ), + onPressed: () { + item.handler(editorState, menuService); + }, + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart new file mode 100644 index 0000000000..94fa6190d8 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_service.dart @@ -0,0 +1,171 @@ +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; +import 'package:flutter/material.dart'; + +abstract class SelectionMenuService { + Offset get topLeft; + + void show(); + void dismiss(); +} + +class SelectionMenu implements SelectionMenuService { + SelectionMenu({ + required this.context, + required this.editorState, + }); + + final BuildContext context; + final EditorState editorState; + + OverlayEntry? _selectionMenuEntry; + bool _selectionUpdateByInner = false; + + @override + void dismiss() { + if (_selectionMenuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + _selectionMenuEntry?.remove(); + _selectionMenuEntry = null; + + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + selectionService.currentSelection.removeListener(_onSelectionChange); + } + } + + @override + void show() { + dismiss(); + + final selectionService = editorState.service.selectionService; + final selectionRects = selectionService.selectionRects; + if (selectionRects.isEmpty) { + return; + } + final offset = selectionRects.first.bottomRight + const Offset(10, 10); + + _selectionMenuEntry = OverlayEntry(builder: (context) { + return Positioned( + top: offset.dy, + left: offset.dx, + child: SelectionMenuWidget( + items: [ + ..._defaultSelectionMenuItems, + ...editorState.selectionMenuItems, + ], + maxItemInRow: 5, + editorState: editorState, + menuService: this, + onExit: () { + dismiss(); + }, + onSelectionUpdate: () { + _selectionUpdateByInner = true; + }, + ), + ); + }); + + Overlay.of(context)?.insert(_selectionMenuEntry!); + + editorState.service.keyboardService?.disable(); + editorState.service.scrollService?.disable(); + selectionService.currentSelection.addListener(_onSelectionChange); + } + + @override + // TODO: implement topLeft + Offset get topLeft => throw UnimplementedError(); + + void _onSelectionChange() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + editorState.service.selectionServiceKey.currentState == null; + if (!isSelectionDisposed) { + final selectionService = editorState.service.selectionService; + if (selectionService.currentSelection.value == null) { + return; + } + } + + if (_selectionUpdateByInner) { + _selectionUpdateByInner = false; + return; + } + + dismiss(); + } +} + +@visibleForTesting +List get defaultSelectionMenuItems => + _defaultSelectionMenuItems; +final List _defaultSelectionMenuItems = [ + SelectionMenuItem( + name: 'Text', + icon: _selectionMenuIcon('text'), + keywords: ['text'], + handler: (editorState, menuService) { + insertTextNodeAfterSelection(editorState, {}); + }, + ), + SelectionMenuItem( + name: 'Heading 1', + icon: _selectionMenuIcon('h1'), + keywords: ['heading 1, h1'], + handler: (editorState, menuService) { + insertHeadingAfterSelection(editorState, StyleKey.h1); + }, + ), + SelectionMenuItem( + name: 'Heading 2', + icon: _selectionMenuIcon('h2'), + keywords: ['heading 2, h2'], + handler: (editorState, menuService) { + insertHeadingAfterSelection(editorState, StyleKey.h2); + }, + ), + SelectionMenuItem( + name: 'Heading 3', + icon: _selectionMenuIcon('h3'), + keywords: ['heading 3, h3'], + handler: (editorState, menuService) { + insertHeadingAfterSelection(editorState, StyleKey.h3); + }, + ), + SelectionMenuItem( + name: 'Bulleted list', + icon: _selectionMenuIcon('bulleted_list'), + keywords: ['bulleted list', 'list', 'unordered list'], + handler: (editorState, menuService) { + insertBulletedListAfterSelection(editorState); + }, + ), + SelectionMenuItem( + name: 'Checkbox', + icon: _selectionMenuIcon('checkbox'), + keywords: ['todo list', 'list', 'checkbox list'], + handler: (editorState, menuService) { + insertCheckboxAfterSelection(editorState); + }, + ), +]; + +Widget _selectionMenuIcon(String name) { + return FlowySvg( + name: 'selection_menu/$name', + color: Colors.black, + width: 18.0, + height: 18.0, + ); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart new file mode 100644 index 0000000000..70f7bbc337 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection_menu/selection_menu_widget.dart @@ -0,0 +1,278 @@ +import 'dart:math'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Selection Menu Item +class SelectionMenuItem { + SelectionMenuItem({ + required this.name, + required this.icon, + required this.keywords, + required this.handler, + }); + + final String name; + final Widget icon; + + /// Customizes keywords for item. + /// + /// The keywords are used to quickly retrieve items. + final List keywords; + final void Function(EditorState editorState, SelectionMenuService menuService) + handler; +} + +class SelectionMenuWidget extends StatefulWidget { + const SelectionMenuWidget({ + Key? key, + required this.items, + required this.maxItemInRow, + required this.editorState, + required this.menuService, + required this.onExit, + required this.onSelectionUpdate, + }) : super(key: key); + + final List items; + final int maxItemInRow; + + final SelectionMenuService menuService; + final EditorState editorState; + + final VoidCallback onSelectionUpdate; + final VoidCallback onExit; + + @override + State createState() => _SelectionMenuWidgetState(); +} + +class _SelectionMenuWidgetState extends State { + final _focusNode = FocusNode(debugLabel: 'popup_list_widget'); + + int _selectedIndex = 0; + List _showingItems = []; + + String _keyword = ''; + String get keyword => _keyword; + set keyword(String newKeyword) { + _keyword = newKeyword; + + // Search items according to the keyword, and calculate the length of + // the longest keyword, which is used to dismiss the selection_service. + var maxKeywordLength = 0; + final items = widget.items + .where( + (item) => item.keywords.any((keyword) { + final value = keyword.contains(newKeyword); + if (value) { + maxKeywordLength = max(maxKeywordLength, keyword.length); + } + return value; + }), + ) + .toList(growable: false); + + Log.ui.debug('$items'); + + if (keyword.length >= maxKeywordLength + 2) { + widget.onExit(); + } else { + setState(() { + _showingItems = items; + }); + } + } + + @override + void initState() { + super.initState(); + + _showingItems = widget.items; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + onKey: _onKey, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: _showingItems.isEmpty + ? _buildNoResultsWidget(context) + : _buildResultsWidget( + context, + _showingItems, + _selectedIndex, + ), + ), + ); + } + + Widget _buildResultsWidget( + BuildContext buildContext, + List items, + int selectedIndex, + ) { + List columns = []; + List itemWidgets = []; + for (var i = 0; i < items.length; i++) { + if (i != 0 && i % (widget.maxItemInRow) == 0) { + columns.add(Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + )); + itemWidgets = []; + } + itemWidgets.add(SelectionMenuItemWidget( + item: items[i], + isSelected: selectedIndex == i, + editorState: widget.editorState, + menuService: widget.menuService, + )); + } + if (itemWidgets.isNotEmpty) { + columns.add(Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: itemWidgets, + )); + itemWidgets = []; + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns, + ); + } + + Widget _buildNoResultsWidget(BuildContext context) { + return const Align( + alignment: Alignment.centerLeft, + child: Material( + child: Padding( + padding: EdgeInsets.all(12.0), + child: Text( + 'No results', + style: TextStyle(color: Colors.grey), + ), + ), + ), + ); + } + + /// Handles arrow keys to switch selected items + /// Handles keyword searches + /// Handles enter to select item and esc to exit + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + Log.keyboard.debug('slash command, on key $event'); + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + + final arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown + ]; + + if (event.logicalKey == LogicalKeyboardKey.enter) { + if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) { + _deleteLastCharacters(length: keyword.length + 1); + _showingItems[_selectedIndex] + .handler(widget.editorState, widget.menuService); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onExit(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + if (keyword.isEmpty) { + widget.onExit(); + } else { + keyword = keyword.substring(0, keyword.length - 1); + } + _deleteLastCharacters(); + return KeyEventResult.handled; + } else if (event.character != null && + !arrowKeys.contains(event.logicalKey)) { + keyword += event.character!; + _insertText(event.character!); + return KeyEventResult.handled; + } + + var newSelectedIndex = _selectedIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + newSelectedIndex -= widget.maxItemInRow; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + newSelectedIndex += widget.maxItemInRow; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + newSelectedIndex -= 1; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + newSelectedIndex += 1; + } + if (newSelectedIndex != _selectedIndex) { + setState(() { + _selectedIndex = newSelectedIndex.clamp(0, _showingItems.length - 1); + }); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _deleteLastCharacters({int length = 1}) { + final selectionService = widget.editorState.service.selectionService; + final selection = selectionService.currentSelection.value; + final nodes = selectionService.currentSelectedNodes; + if (selection != null && nodes.length == 1) { + widget.onSelectionUpdate(); + TransactionBuilder(widget.editorState) + ..deleteText( + nodes.first as TextNode, + selection.start.offset - length, + length, + ) + ..commit(); + } + } + + void _insertText(String text) { + final selection = + widget.editorState.service.selectionService.currentSelection.value; + final nodes = + widget.editorState.service.selectionService.currentSelectedNodes; + if (selection != null && nodes.length == 1) { + widget.onSelectionUpdate(); + TransactionBuilder(widget.editorState) + ..insertText( + nodes.first as TextNode, + selection.end.offset, + text, + ) + ..commit(); + } + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart new file mode 100644 index 0000000000..c4f765f2f4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart @@ -0,0 +1,223 @@ +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; + +void insertHeadingAfterSelection(EditorState editorState, String heading) { + insertTextNodeAfterSelection(editorState, { + StyleKey.subtype: StyleKey.heading, + StyleKey.heading: heading, + }); +} + +void insertQuoteAfterSelection(EditorState editorState) { + insertTextNodeAfterSelection(editorState, { + StyleKey.subtype: StyleKey.quote, + }); +} + +void insertCheckboxAfterSelection(EditorState editorState) { + insertTextNodeAfterSelection(editorState, { + StyleKey.subtype: StyleKey.checkbox, + StyleKey.checkbox: false, + }); +} + +void insertBulletedListAfterSelection(EditorState editorState) { + insertTextNodeAfterSelection(editorState, { + StyleKey.subtype: StyleKey.bulletedList, + }); +} + +bool insertTextNodeAfterSelection( + EditorState editorState, Attributes attributes) { + final selection = editorState.service.selectionService.currentSelection.value; + final nodes = editorState.service.selectionService.currentSelectedNodes; + if (selection == null || nodes.isEmpty) { + return false; + } + + final node = nodes.first; + if (node is TextNode && node.delta.isEmpty) { + formatTextNodes(editorState, attributes); + } else { + final next = selection.end.path.next; + final builder = TransactionBuilder(editorState); + builder + ..insertNode( + next, + TextNode.empty(attributes: attributes), + ) + ..afterSelection = Selection.collapsed( + Position(path: next, offset: 0), + ) + ..commit(); + } + + return true; +} + +void formatText(EditorState editorState) { + formatTextNodes(editorState, {}); +} + +void formatHeading(EditorState editorState, String heading) { + formatTextNodes(editorState, { + StyleKey.subtype: StyleKey.heading, + StyleKey.heading: heading, + }); +} + +void formatQuote(EditorState editorState) { + formatTextNodes(editorState, { + StyleKey.subtype: StyleKey.quote, + }); +} + +void formatCheckbox(EditorState editorState) { + formatTextNodes(editorState, { + StyleKey.subtype: StyleKey.checkbox, + StyleKey.checkbox: false, + }); +} + +void formatBulletedList(EditorState editorState) { + formatTextNodes(editorState, { + StyleKey.subtype: StyleKey.bulletedList, + }); +} + +bool formatTextNodes(EditorState editorState, Attributes attributes) { + final nodes = editorState.service.selectionService.currentSelectedNodes; + final textNodes = nodes.whereType().toList(); + + if (textNodes.isEmpty) { + return false; + } + + final builder = TransactionBuilder(editorState); + + for (final textNode in textNodes) { + builder + ..updateNode( + textNode, + Attributes.fromIterable( + StyleKey.globalStyleKeys, + value: (_) => null, + )..addAll(attributes), + ) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: textNode.toRawString().length, + ), + ); + } + + builder.commit(); + return true; +} + +bool formatBold(EditorState editorState) { + return formatRichTextPartialStyle(editorState, StyleKey.bold); +} + +bool formatItalic(EditorState editorState) { + return formatRichTextPartialStyle(editorState, StyleKey.italic); +} + +bool formatUnderline(EditorState editorState) { + return formatRichTextPartialStyle(editorState, StyleKey.underline); +} + +bool formatStrikethrough(EditorState editorState) { + return formatRichTextPartialStyle(editorState, StyleKey.strikethrough); +} + +bool formatHighlight(EditorState editorState) { + bool value = _allSatisfyInSelection( + editorState, StyleKey.backgroundColor, defaultHighlightColor); + return formatRichTextPartialStyle(editorState, StyleKey.backgroundColor, + customValue: value ? defaultBackgroundColor : defaultHighlightColor); +} + +bool formatRichTextPartialStyle(EditorState editorState, String styleKey, + {Object? customValue}) { + Attributes attributes = { + styleKey: customValue ?? + !_allSatisfyInSelection(editorState, styleKey, customValue ?? true), + }; + + return formatRichTextStyle(editorState, attributes); +} + +bool _allSatisfyInSelection( + EditorState editorState, String styleKey, dynamic value) { + final selection = editorState.service.selectionService.currentSelection.value; + final nodes = editorState.service.selectionService.currentSelectedNodes; + final textNodes = nodes.whereType().toList(growable: false); + + if (selection == null || textNodes.isEmpty) { + return false; + } + + return textNodes.allSatisfyInSelection(styleKey, selection, value); +} + +bool formatRichTextStyle(EditorState editorState, Attributes attributes) { + var selection = editorState.service.selectionService.currentSelection.value; + var nodes = editorState.service.selectionService.currentSelectedNodes; + + if (selection == null) { + return false; + } + + nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); + selection = selection.isBackward ? selection : selection.reversed; + + var textNodes = nodes.whereType().toList(); + if (textNodes.isEmpty) { + return false; + } + + final builder = TransactionBuilder(editorState); + + // 1. All nodes are text nodes. + // 2. The first node is not TextNode. + // 3. The last node is not TextNode. + if (nodes.length == textNodes.length && textNodes.length == 1) { + builder.formatText( + textNodes.first, + selection.start.offset, + selection.end.offset - selection.start.offset, + attributes, + ); + } else { + for (var i = 0; i < textNodes.length; i++) { + final textNode = textNodes[i]; + var index = 0; + var length = textNode.toRawString().length; + if (i == 0 && textNode == nodes.first) { + index = selection.start.offset; + length = textNode.toRawString().length - selection.start.offset; + } else if (i == textNodes.length - 1 && textNode == nodes.last) { + length = selection.end.offset; + } + builder.formatText( + textNode, + index, + length, + attributes, + ); + } + } + + builder.commit(); + + return true; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart new file mode 100644 index 0000000000..d7b4f33914 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -0,0 +1,114 @@ +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/render/editor/editor_entry.dart'; +import 'package:appflowy_editor/src/render/rich_text/bulleted_list_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/checkbox_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/heading_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/number_list_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/quoted_text.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text.dart'; +import 'package:appflowy_editor/src/service/input_service.dart'; +import 'package:appflowy_editor/src/service/keyboard_service.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:appflowy_editor/src/service/scroll_service.dart'; +import 'package:appflowy_editor/src/service/selection_service.dart'; +import 'package:appflowy_editor/src/service/toolbar_service.dart'; + +NodeWidgetBuilders defaultBuilders = { + 'editor': EditorEntryWidgetBuilder(), + 'text': RichTextNodeWidgetBuilder(), + 'text/checkbox': CheckboxNodeWidgetBuilder(), + 'text/heading': HeadingTextNodeWidgetBuilder(), + 'text/bulleted-list': BulletedListTextNodeWidgetBuilder(), + 'text/number-list': NumberListTextNodeWidgetBuilder(), + 'text/quote': QuotedTextNodeWidgetBuilder(), +}; + +class AppFlowyEditor extends StatefulWidget { + const AppFlowyEditor({ + Key? key, + required this.editorState, + this.customBuilders = const {}, + this.keyEventHandlers = const [], + this.selectionMenuItems = const [], + }) : super(key: key); + + final EditorState editorState; + + /// Render plugins. + final NodeWidgetBuilders customBuilders; + + /// Keyboard event handlers. + final List keyEventHandlers; + + final List selectionMenuItems; + + @override + State createState() => _AppFlowyEditorState(); +} + +class _AppFlowyEditorState extends State { + EditorState get editorState => widget.editorState; + + @override + void initState() { + super.initState(); + + editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.service.renderPluginService = _createRenderPlugin(); + } + + @override + void didUpdateWidget(covariant AppFlowyEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (editorState.service != oldWidget.editorState.service) { + editorState.service.renderPluginService = _createRenderPlugin(); + } + } + + @override + Widget build(BuildContext context) { + return AppFlowyScroll( + key: editorState.service.scrollServiceKey, + child: AppFlowySelection( + key: editorState.service.selectionServiceKey, + editorState: editorState, + child: AppFlowyInput( + key: editorState.service.inputServiceKey, + editorState: editorState, + child: AppFlowyKeyboard( + key: editorState.service.keyboardServiceKey, + handlers: [ + ...defaultKeyEventHandlers, + ...widget.keyEventHandlers, + ], + editorState: editorState, + child: FlowyToolbar( + key: editorState.service.toolbarServiceKey, + editorState: editorState, + child: editorState.service.renderPluginService.buildPluginWidget( + NodeWidgetContext( + context: context, + node: editorState.document.root, + editorState: editorState, + ), + ), + ), + ), + ), + ), + ); + } + + AppFlowyRenderPlugin _createRenderPlugin() => AppFlowyRenderPlugin( + editorState: editorState, + builders: { + ...defaultBuilders, + ...widget.customBuilders, + }, + ); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart similarity index 81% rename from frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index 824fc07230..9aae2b5fcb 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart @@ -1,21 +1,47 @@ +import 'package:appflowy_editor/src/infra/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/extensions/node_extensions.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/extensions/node_extensions.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; -mixin FlowyInputService { +/// [AppFlowyInputService] is responsible for processing text input, +/// including text insertion, deletion and replacement. +/// +/// Usually, this service can be obtained by the following code. +/// ```dart +/// final inputService = editorState.service.inputService; +/// +/// /** update text editing value*/ +/// inputService?.attach(...); +/// +/// /** apply text editing deltas*/ +/// inputService?.apply(...); +/// ``` +/// +abstract class AppFlowyInputService { + /// Updates the [TextEditingValue] of the text currently being edited. + /// + /// Note that if there are IME-related requirements, + /// please config `composing` value within [TextEditingValue] void attach(TextEditingValue textEditingValue); + + /// Applies insertion, deletion and replacement + /// to the text currently being edited. + /// + /// For more information, please check [TextEditingDelta]. void apply(List deltas); + + /// Closes the editing state of the text currently being edited. void close(); } -/// process input -class FlowyInput extends StatefulWidget { - const FlowyInput({ +/// Processes text input +class AppFlowyInput extends StatefulWidget { + const AppFlowyInput({ Key? key, required this.editorState, required this.child, @@ -25,12 +51,11 @@ class FlowyInput extends StatefulWidget { final Widget child; @override - State createState() => _FlowyInputState(); + State createState() => _AppFlowyInputState(); } -class _FlowyInputState extends State - with FlowyInputService - implements DeltaTextInputClient { +class _AppFlowyInputState extends State + implements AppFlowyInputService, DeltaTextInputClient { TextInputConnection? _textInputConnection; TextRange? _composingTextRange; @@ -219,7 +244,8 @@ class _FlowyInputState extends State @override void updateEditingValueWithDeltas(List textEditingDeltas) { - debugPrint(textEditingDeltas.map((delta) => delta.toString()).toString()); + Log.input + .debug(textEditingDeltas.map((delta) => delta.toString()).toString()); apply(textEditingDeltas); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart similarity index 79% rename from frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart index 7fbdf669b5..ecaf3325e4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -1,5 +1,4 @@ -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,8 +11,8 @@ int _endOffsetOfNode(Node node) { extension on Position { Position? goLeft(EditorState editorState) { + final node = editorState.document.nodeAtPath(path)!; if (offset == 0) { - final node = editorState.document.nodeAtPath(path)!; final prevNode = node.previous; if (prevNode != null) { return Position( @@ -22,7 +21,11 @@ extension on Position { return null; } - return Position(path: path, offset: offset - 1); + if (node is TextNode) { + return Position(path: path, offset: node.delta.prevRunePosition(offset)); + } else { + return Position(path: path, offset: offset); + } } Position? goRight(EditorState editorState) { @@ -36,30 +39,34 @@ extension on Position { return null; } - return Position(path: path, offset: offset + 1); + if (node is TextNode) { + return Position(path: path, offset: node.delta.nextRunePosition(offset)); + } else { + return Position(path: path, offset: offset); + } } } Position? _goUp(EditorState editorState) { - final rects = editorState.service.selectionService.rects(); + final rects = editorState.service.selectionService.selectionRects; if (rects.isEmpty) { return null; } final first = rects.first; final firstOffset = Offset(first.left, first.top); final hitOffset = firstOffset - Offset(0, first.height * 0.5); - return editorState.service.selectionService.hitTest(hitOffset); + return editorState.service.selectionService.getPositionInOffset(hitOffset); } Position? _goDown(EditorState editorState) { - final rects = editorState.service.selectionService.rects(); + final rects = editorState.service.selectionService.selectionRects; if (rects.isEmpty) { return null; } final first = rects.last; final firstOffset = Offset(first.right, first.bottom); final hitOffset = firstOffset + Offset(0, first.height * 0.5); - return editorState.service.selectionService.hitTest(hitOffset); + return editorState.service.selectionService.getPositionInOffset(hitOffset); } KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { @@ -96,7 +103,7 @@ KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { return KeyEventResult.ignored; } -FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { +AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) { if (event.isShiftPressed) { return _handleShiftKey(editorState, event); } @@ -113,8 +120,9 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { editorState.updateCursorSelection(Selection.collapsed(leftPosition)); } } else { - editorState - .updateCursorSelection(currentSelection.collapse(atStart: true)); + editorState.updateCursorSelection( + currentSelection.collapse(atStart: currentSelection.isBackward), + ); } return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { @@ -124,7 +132,9 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { editorState.updateCursorSelection(Selection.collapsed(rightPosition)); } } else { - editorState.updateCursorSelection(currentSelection.collapse()); + editorState.updateCursorSelection( + currentSelection.collapse(atStart: !currentSelection.isBackward), + ); } return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart similarity index 59% rename from frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index d3eeb073b9..3f49a4b566 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,58 +1,13 @@ -import 'package:html/dom.dart' as html; -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/infra/html_converter.dart'; -import 'package:flowy_editor/document/node_iterator.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/infra/html_converter.dart'; +import 'package:appflowy_editor/src/document/node_iterator.dart'; +import 'package:appflowy_editor/src/infra/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; -class _HTMLNormalizer { - final List nodes; - html.Element? _pendingList; - - _HTMLNormalizer(this.nodes); - - List normalize() { - final result = []; - - for (final item in nodes) { - if (item is Text) { - result.add(item); - continue; - } - - if (item is html.Element) { - if (item.localName == "li") { - if (_pendingList != null) { - _pendingList!.append(item); - } else { - final ulItem = html.Element.tag("ul"); - ulItem.append(item); - - _pendingList = ulItem; - } - } else { - _pushList(result); - result.add(item); - } - } - } - - return result; - } - - _pushList(List result) { - if (_pendingList == null) { - return; - } - result.add(_pendingList!); - _pendingList = null; - } -} - _handleCopy(EditorState editorState) async { - final selection = editorState.cursorSelection; + final selection = editorState.cursorSelection?.normalize(); if (selection == null || selection.isCollapsed) { return; } @@ -60,64 +15,48 @@ _handleCopy(EditorState editorState) async { final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!; if (nodeAtPath.type == "text") { final textNode = nodeAtPath as TextNode; - final delta = - textNode.delta.slice(selection.start.offset, selection.end.offset); - - final htmlString = stringify(deltaToHtml(delta)); - debugPrint('copy html: $htmlString'); + final htmlString = NodesToHTMLConverter( + nodes: [textNode], + startOffset: selection.start.offset, + endOffset: selection.end.offset) + .toHTMLString(); + Log.keyboard.debug('copy html: $htmlString'); RichClipboard.setData(RichClipboardData(html: htmlString)); } else { - debugPrint("unimplemented: copy non-text"); + Log.keyboard.debug('unimplemented: copy non-text'); } return; } final beginNode = editorState.document.nodeAtPath(selection.start.path)!; final endNode = editorState.document.nodeAtPath(selection.end.path)!; - final traverser = NodeIterator(editorState.document, beginNode, endNode); - final nodes = []; - while (traverser.moveNext()) { - final node = traverser.current; - if (node.type == "text") { - final textNode = node as TextNode; - String? subType = textNode.attributes["subtype"]; - if (node == beginNode) { - final htmlElement = - deltaToHtml(textNode.delta.slice(selection.start.offset), subType); - nodes.add(htmlElement); - } else if (node == endNode) { - final htmlElement = - deltaToHtml(textNode.delta.slice(0, selection.end.offset), subType); - nodes.add(htmlElement); - } else { - final htmlElement = deltaToHtml(textNode.delta, subType); - nodes.add(htmlElement); - } - } - // TODO: handle image and other blocks - } + final nodes = NodeIterator(editorState.document, beginNode, endNode).toList(); - final copyString = _HTMLNormalizer(nodes).normalize().fold( - "", ((previousValue, element) => previousValue + stringify(element))); - debugPrint('copy html: $copyString'); + final copyString = NodesToHTMLConverter( + nodes: nodes, + startOffset: selection.start.offset, + endOffset: selection.end.offset) + .toHTMLString(); + Log.keyboard.debug('copy html: $copyString'); RichClipboard.setData(RichClipboardData(html: copyString)); } _pasteHTML(EditorState editorState, String html) { - final selection = editorState.cursorSelection; + final selection = editorState.cursorSelection?.normalize(); if (selection == null) { return; } + assert(selection.isCollapsed); + final path = [...selection.end.path]; if (path.isEmpty) { return; } - debugPrint('paste html: $html'); - final converter = HTMLConverter(html); - final nodes = converter.toNodes(); + Log.keyboard.debug('paste html: $html'); + final nodes = HTMLToNodesConverter(html).toNodes(); if (nodes.isEmpty) { return; @@ -130,7 +69,7 @@ _pasteHTML(EditorState editorState, String html) { final textNodeAtPath = nodeAtPath as TextNode; final firstTextNode = firstNode as TextNode; tb.textEdit(textNodeAtPath, - () => Delta().retain(startOffset).concat(firstTextNode.delta)); + () => (Delta()..retain(startOffset)) + firstTextNode.delta); tb.setAfterSelection(Selection.collapsed(Position( path: path, offset: startOffset + firstTextNode.delta.length))); tb.commit(); @@ -156,18 +95,19 @@ _pasteMultipleLinesInText( tb.textEdit( textNodeAtPath, - () => Delta() - .retain(offset) - .delete(remain.length) - .concat(firstTextNode.delta)); + () => + (Delta() + ..retain(offset) + ..delete(remain.length)) + + firstTextNode.delta); final tailNodes = nodes.sublist(1); path[path.length - 1]++; if (tailNodes.isNotEmpty) { if (tailNodes.last.type == "text") { final tailTextNode = tailNodes.last as TextNode; - tailTextNode.delta = tailTextNode.delta.concat(remain); - } else if (remain.length > 0) { + tailTextNode.delta = tailTextNode.delta + remain; + } else if (remain.isNotEmpty) { tailNodes.add(TextNode(type: "text", delta: remain)); } } else { @@ -186,6 +126,20 @@ _pasteMultipleLinesInText( _handlePaste(EditorState editorState) async { final data = await RichClipboard.getData(); + + if (editorState.cursorSelection?.isCollapsed ?? false) { + _pastRichClipboard(editorState, data); + return; + } + + _deleteSelectedContent(editorState); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _pastRichClipboard(editorState, data); + }); +} + +_pastRichClipboard(EditorState editorState, RichClipboardData data) { if (data.html != null) { _pasteHTML(editorState, data.html!); return; @@ -196,8 +150,48 @@ _handlePaste(EditorState editorState) async { } } +_pasteSingleLine(EditorState editorState, Selection selection, String line) { + final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode; + final beginOffset = selection.end.offset; + TransactionBuilder(editorState) + ..textEdit( + node, + () => Delta() + ..retain(beginOffset) + ..addAll(_lineContentToDelta(line))) + ..setAfterSelection(Selection.collapsed( + Position(path: selection.end.path, offset: beginOffset + line.length))) + ..commit(); +} + +/// parse url from the line text +/// reference: https://stackoverflow.com/questions/59444837/flutter-dart-regex-to-extract-urls-from-a-string +Delta _lineContentToDelta(String lineContent) { + final exp = RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'); + final Iterable matches = exp.allMatches(lineContent); + + final delta = Delta(); + + var lastUrlEndOffset = 0; + + for (final match in matches) { + if (lastUrlEndOffset < match.start) { + delta.insert(lineContent.substring(lastUrlEndOffset, match.start)); + } + final linkContent = lineContent.substring(match.start, match.end); + delta.insert(linkContent, {"href": linkContent}); + lastUrlEndOffset = match.end; + } + + if (lastUrlEndOffset < lineContent.length) { + delta.insert(lineContent.substring(lastUrlEndOffset, lineContent.length)); + } + + return delta; +} + _handlePastePlainText(EditorState editorState, String plainText) { - final selection = editorState.cursorSelection; + final selection = editorState.cursorSelection?.normalize(); if (selection == null) { return; } @@ -210,14 +204,8 @@ _handlePastePlainText(EditorState editorState, String plainText) { if (lines.isEmpty) { return; } else if (lines.length == 1) { - final node = - editorState.document.nodeAtPath(selection.end.path)! as TextNode; - final beginOffset = selection.end.offset; - TransactionBuilder(editorState) - ..textEdit(node, () => Delta().retain(beginOffset).insert(lines[0])) - ..setAfterSelection(Selection.collapsed(Position( - path: selection.end.path, offset: beginOffset + lines[0].length))) - ..commit(); + // single line + _pasteSingleLine(editorState, selection, lines.first); } else { final firstLine = lines[0]; final beginOffset = selection.end.offset; @@ -239,17 +227,17 @@ _handlePastePlainText(EditorState editorState, String plainText) { if (index++ == remains.length - 1) { return TextNode( type: "text", - delta: Delta().insert(e).addAll(insertedLineSuffix.operations)); + delta: _lineContentToDelta(e)..addAll(insertedLineSuffix)); } - return TextNode(type: "text", delta: Delta().insert(e)); + return TextNode(type: "text", delta: _lineContentToDelta(e)); }).toList(); // insert first line tb.textEdit( node, () => Delta() - .retain(beginOffset) - .insert(firstLine) - .delete(node.delta.length - beginOffset)); + ..retain(beginOffset) + ..insert(firstLine) + ..delete(node.delta.length - beginOffset)); // insert remains tb.insertNodes(path, nodes); tb.commit(); @@ -263,23 +251,13 @@ _handlePastePlainText(EditorState editorState, String plainText) { /// 1. copy the selected content /// 2. delete selected content _handleCut(EditorState editorState) { - debugPrint('cut'); - final selection = editorState.cursorSelection; - if (selection == null) { - return; - } - - if (selection.isCollapsed) { - return; - } - _handleCopy(editorState); _deleteSelectedContent(editorState); } _deleteSelectedContent(EditorState editorState) { - final selection = editorState.cursorSelection; - if (selection == null) { + final selection = editorState.cursorSelection?.normalize(); + if (selection == null || selection.isCollapsed) { return; } final beginNode = editorState.document.nodeAtPath(selection.start.path)!; @@ -290,7 +268,10 @@ _deleteSelectedContent(EditorState editorState) { final tb = TransactionBuilder(editorState); final len = selection.end.offset - selection.start.offset; tb.textEdit( - textItem, () => Delta().retain(selection.start.offset).delete(len)); + textItem, + () => Delta() + ..retain(selection.start.offset) + ..delete(len)); tb.setAfterSelection(Selection.collapsed(selection.start)); tb.commit(); return; @@ -304,25 +285,26 @@ _deleteSelectedContent(EditorState editorState) { final textItem = item as TextNode; final deleteLen = textItem.delta.length - selection.start.offset; tb.textEdit(textItem, () { - final delta = Delta(); - delta.retain(selection.start.offset).delete(deleteLen); + final delta = Delta() + ..retain(selection.start.offset) + ..delete(deleteLen); if (endNode is TextNode) { final remain = endNode.delta.slice(selection.end.offset); - delta.addAll(remain.operations); + delta.addAll(remain); } return delta; }); - tb.setAfterSelection(Selection.collapsed(selection.start)); } else { tb.deleteNode(item); } } + tb.setAfterSelection(Selection.collapsed(selection.start)); tb.commit(); } -FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { +AppFlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) { _handleCopy(editorState); return KeyEventResult.handled; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart new file mode 100644 index 0000000000..468eda4e98 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart @@ -0,0 +1,24 @@ +import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/page_up_down_handler.dart'; +import 'package:appflowy_editor/src/service/keyboard_service.dart'; + +List defaultKeyEventHandlers = [ + deleteTextHandler, + slashShortcutHandler, + arrowKeysHandler, + copyPasteKeysHandler, + redoUndoKeysHandler, + enterWithoutShiftInTextNodesHandler, + updateTextStyleByCommandXHandler, + whiteSpaceHandler, + selectAllHandler, + pageUpDownHandler, +]; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart new file mode 100644 index 0000000000..b931ee3d61 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:appflowy_editor/appflowy_editor.dart'; + +KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { + var selection = editorState.service.selectionService.currentSelection.value; + if (selection == null) { + return KeyEventResult.ignored; + } + var nodes = editorState.service.selectionService.currentSelectedNodes; + nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); + selection = selection.isBackward ? selection : selection.reversed; + // make sure all nodes is [TextNode]. + final textNodes = nodes.whereType().toList(); + if (textNodes.length != nodes.length) { + return KeyEventResult.ignored; + } + + final transactionBuilder = TransactionBuilder(editorState); + if (textNodes.length == 1) { + final textNode = textNodes.first; + final index = textNode.delta.prevRunePosition(selection.start.offset); + if (index < 0 && selection.isCollapsed) { + // 1. style + if (textNode.subtype != null) { + transactionBuilder + ..updateNode(textNode, { + 'subtype': null, + }) + ..afterSelection = Selection.collapsed( + Position( + path: textNode.path, + offset: 0, + ), + ); + } else { + // 2. non-style + // find previous text node. + while (textNode.previous != null) { + if (textNode.previous is TextNode) { + final previous = textNode.previous as TextNode; + transactionBuilder + ..mergeText(previous, textNode) + ..deleteNode(textNode) + ..afterSelection = Selection.collapsed( + Position( + path: previous.path, + offset: previous.toRawString().length, + ), + ); + break; + } + } + } + } else { + if (selection.isCollapsed) { + transactionBuilder.deleteText( + textNode, + index, + selection.start.offset - index, + ); + } else { + transactionBuilder.deleteText( + textNode, + selection.start.offset, + selection.end.offset - selection.start.offset, + ); + } + } + } else { + _deleteNodes(transactionBuilder, textNodes, selection); + } + + if (transactionBuilder.operations.isNotEmpty) { + transactionBuilder.commit(); + } + + return KeyEventResult.handled; +} + +KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) { + var selection = editorState.service.selectionService.currentSelection.value; + if (selection == null) { + return KeyEventResult.ignored; + } + var nodes = editorState.service.selectionService.currentSelectedNodes; + nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); + selection = selection.isBackward ? selection : selection.reversed; + // make sure all nodes is [TextNode]. + final textNodes = nodes.whereType().toList(); + if (textNodes.length != nodes.length) { + return KeyEventResult.ignored; + } + + final transactionBuilder = TransactionBuilder(editorState); + if (textNodes.length == 1) { + final textNode = textNodes.first; + if (selection.start.offset >= textNode.delta.length) { + final nextNode = textNode.next; + if (nextNode == null) { + return KeyEventResult.ignored; + } + if (nextNode is TextNode) { + transactionBuilder.mergeText(textNode, nextNode); + } + transactionBuilder.deleteNode(nextNode); + } else { + final index = textNode.delta.nextRunePosition(selection.start.offset); + if (selection.isCollapsed) { + transactionBuilder.deleteText( + textNode, + selection.start.offset, + index - selection.start.offset, + ); + } else { + transactionBuilder.deleteText( + textNode, + selection.start.offset, + selection.end.offset - selection.start.offset, + ); + } + } + } else { + _deleteNodes(transactionBuilder, textNodes, selection); + } + + transactionBuilder.commit(); + + return KeyEventResult.handled; +} + +void _deleteNodes(TransactionBuilder transactionBuilder, + List textNodes, Selection selection) { + final first = textNodes.first; + final last = textNodes.last; + var content = textNodes.last.toRawString(); + content = content.substring(selection.end.offset, content.length); + // Merge the fist and the last text node content, + // and delete the all nodes expect for the first. + transactionBuilder + ..deleteNodes(textNodes.sublist(1)) + ..mergeText( + first, + last, + firstOffset: selection.start.offset, + secondOffset: selection.end.offset, + ); +} + +// Handle delete text. +AppFlowyKeyEventHandler deleteTextHandler = (editorState, event) { + if (event.logicalKey == LogicalKeyboardKey.backspace) { + return _handleBackspace(editorState, event); + } + if (event.logicalKey == LogicalKeyboardKey.delete) { + return _handleDelete(editorState, event); + } + + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart similarity index 54% rename from frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart index cf71830386..04540b8754 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/extensions/path_extensions.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/service/keyboard_service.dart'; /// Handle some cases where enter is pressed and shift is not pressed. /// @@ -17,17 +18,24 @@ import 'package:flowy_editor/service/keyboard_service.dart'; /// 2. Single selection and the selected node is [TextNode] /// 2.1 split the node into two nodes with style /// 2.2 or insert a empty text node before. -FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = +AppFlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = (editorState, event) { if (event.logicalKey != LogicalKeyboardKey.enter || event.isShiftPressed) { return KeyEventResult.ignored; } - final nodes = editorState.service.selectionService.currentSelectedNodes; + var selection = editorState.service.selectionService.currentSelection.value; + var nodes = editorState.service.selectionService.currentSelectedNodes; + if (selection == null) { + return KeyEventResult.ignored; + } + if (selection.isForward) { + selection = selection.reversed; + nodes = nodes.reversed.toList(growable: false); + } final textNodes = nodes.whereType().toList(growable: false); - final selection = editorState.service.selectionService.currentSelection.value; - if (selection == null || nodes.length != textNodes.length) { + if (nodes.length != textNodes.length) { return KeyEventResult.ignored; } @@ -35,7 +43,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = if (!selection.isSingle) { final length = textNodes.length; final List subTextNodes = - length >= 3 ? textNodes.sublist(1, textNodes.length - 2) : []; + length >= 3 ? textNodes.sublist(1, textNodes.length - 1) : []; final afterSelection = Selection.collapsed( Position(path: textNodes.first.path.next, offset: 0), ); @@ -66,16 +74,31 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = // If selection is collapsed and position.start.offset == 0, // insert a empty text node before. if (selection.isCollapsed && selection.start.offset == 0) { - final afterSelection = Selection.collapsed( - Position(path: textNode.path.next, offset: 0), - ); - TransactionBuilder(editorState) - ..insertNode( - textNode.path, - TextNode.empty(), - ) - ..afterSelection = afterSelection - ..commit(); + if (textNode.toRawString().isEmpty && textNode.subtype != null) { + final afterSelection = Selection.collapsed( + Position(path: textNode.path, offset: 0), + ); + TransactionBuilder(editorState) + ..updateNode( + textNode, + Attributes.fromIterable( + StyleKey.globalStyleKeys, + value: (_) => null, + )) + ..afterSelection = afterSelection + ..commit(); + } else { + final afterSelection = Selection.collapsed( + Position(path: textNode.path.next, offset: 0), + ); + TransactionBuilder(editorState) + ..insertNode( + textNode.path, + TextNode.empty(), + ) + ..afterSelection = afterSelection + ..commit(); + } return KeyEventResult.handled; } @@ -84,6 +107,13 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = final needCopyAttributes = StyleKey.globalStyleKeys .where((key) => key != StyleKey.heading) .contains(textNode.subtype); + Attributes attributes = {}; + if (needCopyAttributes) { + attributes = Attributes.from(textNode.attributes); + if (attributes.check) { + attributes[StyleKey.checkbox] = false; + } + } final afterSelection = Selection.collapsed( Position(path: textNode.path.next, offset: 0), ); @@ -91,7 +121,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler = ..insertNode( textNode.path.next, textNode.copyWith( - attributes: needCopyAttributes ? textNode.attributes : {}, + attributes: attributes, delta: textNode.delta.slice(selection.end.offset), ), ) diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart new file mode 100644 index 0000000000..d33e290d8e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/page_up_down_handler.dart @@ -0,0 +1,22 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +AppFlowyKeyEventHandler pageUpDownHandler = (editorState, event) { + if (event.logicalKey == LogicalKeyboardKey.pageUp) { + final scrollHeight = editorState.service.scrollService?.onePageHeight; + final scrollService = editorState.service.scrollService; + if (scrollHeight != null && scrollService != null) { + scrollService.scrollTo(scrollService.dy - scrollHeight); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.pageDown) { + final scrollHeight = editorState.service.scrollService?.onePageHeight; + final scrollService = editorState.service.scrollService; + if (scrollHeight != null && scrollService != null) { + scrollService.scrollTo(scrollService.dy + scrollHeight); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart new file mode 100644 index 0000000000..268697640a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/redo_undo_handler.dart @@ -0,0 +1,15 @@ +import 'package:appflowy_editor/src/service/keyboard_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +AppFlowyKeyEventHandler redoUndoKeysHandler = (editorState, event) { + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyZ) { + if (event.isShiftPressed) { + editorState.undoManager.redo(); + } else { + editorState.undoManager.undo(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart new file mode 100644 index 0000000000..8ffd2d176e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/select_all_handler.dart @@ -0,0 +1,26 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +KeyEventResult _selectAll(EditorState editorState) { + if (editorState.document.root.children.isEmpty) { + return KeyEventResult.handled; + } + final firstNode = editorState.document.root.children.first; + final lastNode = editorState.document.root.children.last; + var offset = 0; + if (lastNode is TextNode) { + offset = lastNode.delta.length; + } + editorState.updateCursorSelection(Selection( + start: Position(path: firstNode.path, offset: 0), + end: Position(path: lastNode.path, offset: offset))); + return KeyEventResult.handled; +} + +AppFlowyKeyEventHandler selectAllHandler = (editorState, event) { + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyA) { + return _selectAll(editorState); + } + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart new file mode 100644 index 0000000000..32e27a808a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart @@ -0,0 +1,44 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; +import 'package:appflowy_editor/src/service/keyboard_service.dart'; +import 'package:appflowy_editor/src/extensions/node_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +SelectionMenuService? _selectionMenuService; +AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) { + if (event.logicalKey != LogicalKeyboardKey.slash) { + return KeyEventResult.ignored; + } + + final textNodes = editorState.service.selectionService.currentSelectedNodes + .whereType(); + if (textNodes.length != 1) { + return KeyEventResult.ignored; + } + + final selection = editorState.service.selectionService.currentSelection.value; + final textNode = textNodes.first; + final context = textNode.context; + final selectable = textNode.selectable; + if (selection == null || context == null || selectable == null) { + return KeyEventResult.ignored; + } + final selectionRects = editorState.service.selectionService.selectionRects; + if (selectionRects.isEmpty) { + return KeyEventResult.ignored; + } + TransactionBuilder(editorState) + ..replaceText(textNode, selection.start.offset, + selection.end.offset - selection.start.offset, event.character ?? '') + ..commit(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _selectionMenuService = + SelectionMenu(context: context, editorState: editorState); + _selectionMenuService?.show(); + }); + + return KeyEventResult.handled; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart new file mode 100644 index 0000000000..0eb926525b --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; +import 'package:appflowy_editor/src/service/keyboard_service.dart'; +import 'package:flutter/services.dart'; + +AppFlowyKeyEventHandler updateTextStyleByCommandXHandler = + (editorState, event) { + if (!event.isMetaPressed) { + return KeyEventResult.ignored; + } + + final selection = editorState.service.selectionService.currentSelection.value; + final nodes = editorState.service.selectionService.currentSelectedNodes; + final textNodes = nodes.whereType().toList(growable: false); + + if (selection == null || textNodes.isEmpty) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.keyB) { + formatBold(editorState); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyI) { + formatItalic(editorState); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyU) { + formatUnderline(editorState); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyS && + event.isShiftPressed) { + formatStrikethrough(editorState); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyH && + event.isShiftPressed) { + formatHighlight(editorState); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; +}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/whitespace_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart similarity index 71% rename from frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/whitespace_handler.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart index b3642cc1a1..68e706128c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/whitespace_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart @@ -1,19 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/service/keyboard_service.dart'; + +@visibleForTesting +List get checkboxListSymbols => _checkboxListSymbols; +@visibleForTesting +List get unCheckboxListSymbols => _unCheckboxListSymbols; +@visibleForTesting +List get bulletedListSymbols => _bulletedListSymbols; const _bulletedListSymbols = ['*', '-']; const _checkboxListSymbols = ['[x]', '-[x]']; const _unCheckboxListSymbols = ['[]', '-[]']; -FlowyKeyEventHandler whiteSpaceHandler = (editorState, event) { +AppFlowyKeyEventHandler whiteSpaceHandler = (editorState, event) { if (event.logicalKey != LogicalKeyboardKey.space) { return KeyEventResult.ignored; } @@ -39,8 +46,8 @@ FlowyKeyEventHandler whiteSpaceHandler = (editorState, event) { return _toCheckboxList(editorState, textNode); } else if (_bulletedListSymbols.any(text.startsWith)) { return _toBulletedList(editorState, textNode); - } else if (_countOfSign(text) != 0) { - return _toHeadingStyle(editorState, textNode); + } else if (_countOfSign(text, selection) != 0) { + return _toHeadingStyle(editorState, textNode, selection); } return KeyEventResult.ignored; @@ -99,8 +106,12 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) { return KeyEventResult.handled; } -KeyEventResult _toHeadingStyle(EditorState editorState, TextNode textNode) { - final x = _countOfSign(textNode.toRawString()); +KeyEventResult _toHeadingStyle( + EditorState editorState, TextNode textNode, Selection selection) { + final x = _countOfSign( + textNode.toRawString(), + selection, + ); final hX = 'h$x'; if (textNode.attributes.heading == hX) { return KeyEventResult.ignored; @@ -121,9 +132,9 @@ KeyEventResult _toHeadingStyle(EditorState editorState, TextNode textNode) { return KeyEventResult.handled; } -int _countOfSign(String text) { +int _countOfSign(String text, Selection selection) { for (var i = 6; i >= 0; i--) { - if (text.startsWith('#' * i)) { + if (text.substring(0, selection.end.offset).startsWith('#' * i)) { return i; } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart new file mode 100644 index 0000000000..1867574993 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -0,0 +1,137 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter/material.dart'; + +/// [AppFlowyKeyboardService] is responsible for processing shortcut keys, +/// like command, shift, control keys. +/// +/// Usually, this service can be obtained by the following code. +/// ```dart +/// final keyboardService = editorState.service.keyboardService; +/// +/// /** Simulates shortcut key input*/ +/// keyboardService?.onKey(...); +/// +/// /** Enables or disables this service */ +/// keyboardService?.enable(); +/// keyboardService?.disable(); +/// ``` +/// +abstract class AppFlowyKeyboardService { + /// Processes shortcut key input. + KeyEventResult onKey(RawKeyEvent event); + + /// Enables shortcuts service. + void enable(); + + /// Disables shortcuts service. + /// + /// In some cases, if your custom component needs to monitor + /// keyboard events separately, + /// you can disable the keyboard service of flowy_editor. + /// But you need to call the `enable` function to restore after exiting + /// your custom component, otherwise the keyboard service will fails. + void disable(); +} + +typedef AppFlowyKeyEventHandler = KeyEventResult Function( + EditorState editorState, + RawKeyEvent event, +); + +/// Process keyboard events +class AppFlowyKeyboard extends StatefulWidget { + const AppFlowyKeyboard({ + Key? key, + required this.handlers, + required this.editorState, + required this.child, + }) : super(key: key); + + final EditorState editorState; + final Widget child; + final List handlers; + + @override + State createState() => _AppFlowyKeyboardState(); +} + +class _AppFlowyKeyboardState extends State + implements AppFlowyKeyboardService { + final FocusNode _focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); + + bool isFocus = true; + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + onKey: _onKey, + onFocusChange: _onFocusChange, + child: widget.child, + ); + } + + @override + void initState() { + super.initState(); + + enable(); + } + + @override + void dispose() { + _focusNode.dispose(); + + super.dispose(); + } + + @override + void enable() { + isFocus = true; + _focusNode.requestFocus(); + } + + @override + void disable() { + isFocus = false; + _focusNode.unfocus(); + } + + @override + KeyEventResult onKey(RawKeyEvent event) { + if (!isFocus) { + return KeyEventResult.ignored; + } + + Log.keyboard.debug('on keyboard event $event'); + + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + + for (final handler in widget.handlers) { + KeyEventResult result = handler(widget.editorState, event); + + switch (result) { + case KeyEventResult.handled: + return KeyEventResult.handled; + case KeyEventResult.skipRemainingHandlers: + return KeyEventResult.skipRemainingHandlers; + case KeyEventResult.ignored: + continue; + } + } + + return KeyEventResult.ignored; + } + + void _onFocusChange(bool value) { + Log.keyboard.debug('on keyboard event focus change $value'); + } + + KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { + return onKey(event); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart similarity index 84% rename from frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart index 8ac32ac66c..2ad2989207 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/render_plugin_service.dart @@ -1,5 +1,6 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/infra/log.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,11 +14,11 @@ abstract class NodeWidgetBuilder { typedef NodeWidgetBuilders = Map; -abstract class FlowyRenderPluginService { +abstract class AppFlowyRenderPluginService { /// Register render plugin with specified [name]. /// /// [name] should be [Node].type - /// or [Node].type + '/' + [Node].attributes['subtype']. + /// or `[Node].type + '/' + [Node].attributes['subtype']`. /// /// e.g. 'text', 'text/checkbox', or 'text/heading' /// @@ -55,8 +56,8 @@ class NodeWidgetContext { } } -class FlowyRenderPlugin extends FlowyRenderPluginService { - FlowyRenderPlugin({ +class AppFlowyRenderPlugin extends AppFlowyRenderPluginService { + AppFlowyRenderPlugin({ required this.editorState, required NodeWidgetBuilders builders, }) { @@ -77,7 +78,8 @@ class FlowyRenderPlugin extends FlowyRenderPluginService { node.key = key; return _autoUpdateNodeWidget(builder, context); } else { - assert(false, 'Could not query the builder with this $name'); + assert(false, + 'Could not query the builder with this $name, or nodeValidator return false.'); // TODO: return a placeholder widget with tips. return Container(); } @@ -85,7 +87,7 @@ class FlowyRenderPlugin extends FlowyRenderPluginService { @override void register(String name, NodeWidgetBuilder builder) { - debugPrint('[Plugins] registering $name...'); + Log.editor.info('registers plugin($name)...'); _validatePlugin(name); _builders[name] = builder; } @@ -110,7 +112,7 @@ class FlowyRenderPlugin extends FlowyRenderPluginService { builder: (_, child) { return Consumer( builder: ((_, value, child) { - debugPrint('Text Node is rebuilding...'); + Log.ui.debug('TextNode is rebuilding...'); return builder.build(context); }), ); @@ -121,7 +123,7 @@ class FlowyRenderPlugin extends FlowyRenderPluginService { builder: (_, child) { return Consumer( builder: ((_, value, child) { - debugPrint('Node is rebuilding...'); + Log.ui.debug('Node is rebuilding...'); return builder.build(context); }), ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/scroll_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/scroll_service.dart new file mode 100644 index 0000000000..d68e686d61 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/scroll_service.dart @@ -0,0 +1,132 @@ +import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:appflowy_editor/src/extensions/object_extensions.dart'; + +/// [AppFlowyScrollService] is responsible for processing document scrolling. +/// +/// Usually, this service can be obtained by the following code. +/// ```dart +/// final keyboardService = editorState.service.scrollService; +/// ``` +/// +abstract class AppFlowyScrollService { + /// Returns the offset of the current document on the vertical axis. + double get dy; + + /// Returns the height of the current document. + double? get onePageHeight; + + /// Returns the number of pages in the current document. + int? get page; + + /// Returns the maximum scroll height on the vertical axis. + double get maxScrollExtent; + + /// Returns the minimum scroll height on the vertical axis. + double get minScrollExtent; + + /// Scrolls to the specified position. + /// + /// This function will filter illegal values. + /// Only within the range of minScrollExtent and maxScrollExtent are legal values. + void scrollTo(double dy); + + /// Enables scroll service. + void enable(); + + /// Disables scroll service. + /// + /// In some cases, you can disable scroll service of flowy_editor + /// when your custom component appears, + /// + /// But you need to call the `enable` function to restore after exiting + /// your custom component, otherwise the scroll service will fails. + void disable(); +} + +class AppFlowyScroll extends StatefulWidget { + const AppFlowyScroll({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + State createState() => _AppFlowyScrollState(); +} + +class _AppFlowyScrollState extends State + implements AppFlowyScrollService { + final _scrollController = ScrollController(); + final _scrollViewKey = GlobalKey(); + + bool _scrollEnabled = true; + + @override + double get dy => _scrollController.position.pixels; + + @override + double? get onePageHeight { + final renderBox = context.findRenderObject()?.unwrapOrNull(); + return renderBox?.size.height; + } + + @override + double get maxScrollExtent => _scrollController.position.maxScrollExtent; + + @override + double get minScrollExtent => _scrollController.position.minScrollExtent; + + @override + int? get page { + if (onePageHeight != null) { + final scrollExtent = maxScrollExtent - minScrollExtent; + return (scrollExtent / onePageHeight!).ceil(); + } + return null; + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerSignal: _onPointerSignal, + child: SingleChildScrollView( + key: _scrollViewKey, + physics: const NeverScrollableScrollPhysics(), + controller: _scrollController, + child: widget.child, + ), + ); + } + + @override + void scrollTo(double dy) { + _scrollController.position.jumpTo( + dy.clamp( + _scrollController.position.minScrollExtent, + _scrollController.position.maxScrollExtent, + ), + ); + } + + @override + void disable() { + _scrollEnabled = false; + Log.scroll.debug('disable scroll service'); + } + + @override + void enable() { + _scrollEnabled = true; + Log.scroll.debug('enable scroll service'); + } + + void _onPointerSignal(PointerSignalEvent event) { + if (event is PointerScrollEvent && _scrollEnabled) { + final dy = (_scrollController.position.pixels + event.scrollDelta.dy); + scrollTo(dy); + } + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart new file mode 100644 index 0000000000..11a6326d26 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection/selection_gesture.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] +/// for a while. So we need to implement our own GestureDetector. +@immutable +class SelectionGestureDetector extends StatefulWidget { + const SelectionGestureDetector({ + Key? key, + this.child, + this.onTapDown, + this.onDoubleTapDown, + this.onTripleTapDown, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + }) : super(key: key); + + @override + State createState() => + SelectionGestureDetectorState(); + + final Widget? child; + + final GestureTapDownCallback? onTapDown; + final GestureTapDownCallback? onDoubleTapDown; + final GestureTapDownCallback? onTripleTapDown; + final GestureDragStartCallback? onPanStart; + final GestureDragUpdateCallback? onPanUpdate; + final GestureDragEndCallback? onPanEnd; +} + +class SelectionGestureDetectorState extends State { + bool _isDoubleTap = false; + Timer? _doubleTapTimer; + int _tripleTabCount = 0; + Timer? _tripleTabTimer; + + final kTripleTapTimeout = const Duration(milliseconds: 500); + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..onStart = widget.onPanStart + ..onUpdate = widget.onPanUpdate + ..onEnd = widget.onPanEnd; + }, + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recognizer) { + recognizer.onTapDown = _tapDownDelegate; + }, + ), + }, + child: widget.child, + ); + } + + _tapDownDelegate(TapDownDetails tapDownDetails) { + if (_tripleTabCount == 2) { + _tripleTabCount = 0; + _tripleTabTimer?.cancel(); + _tripleTabTimer = null; + if (widget.onTripleTapDown != null) { + widget.onTripleTapDown!(tapDownDetails); + } + } else if (_isDoubleTap) { + _isDoubleTap = false; + _doubleTapTimer?.cancel(); + _doubleTapTimer = null; + if (widget.onDoubleTapDown != null) { + widget.onDoubleTapDown!(tapDownDetails); + } + _tripleTabCount++; + } else { + if (widget.onTapDown != null) { + widget.onTapDown!(tapDownDetails); + } + + _isDoubleTap = true; + _doubleTapTimer?.cancel(); + _doubleTapTimer = Timer(kDoubleTapTimeout, () { + _isDoubleTap = false; + _doubleTapTimer = null; + }); + + _tripleTabCount = 1; + _tripleTabTimer?.cancel(); + _tripleTabTimer = Timer(kTripleTapTimeout, () { + _tripleTabCount = 0; + _tripleTabTimer = null; + }); + } + } + + @override + void dispose() { + _doubleTapTimer?.cancel(); + _tripleTabTimer?.cancel(); + super.dispose(); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart new file mode 100644 index 0000000000..c5e351059c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart @@ -0,0 +1,550 @@ +import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/node_iterator.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/extensions/node_extensions.dart'; +import 'package:appflowy_editor/src/extensions/object_extensions.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; +import 'package:appflowy_editor/src/render/selection/cursor_widget.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:appflowy_editor/src/render/selection/selection_widget.dart'; +import 'package:appflowy_editor/src/service/selection/selection_gesture.dart'; + +/// [AppFlowySelectionService] is responsible for processing +/// the [Selection] changes and updates. +/// +/// Usually, this service can be obtained by the following code. +/// ```dart +/// final selectionService = editorState.service.selectionService; +/// +/// /** get current selection value*/ +/// final selection = selectionService.currentSelection.value; +/// +/// /** get current selected nodes*/ +/// final nodes = selectionService.currentSelectedNodes; +/// ``` +/// +abstract class AppFlowySelectionService { + /// The current [Selection] in editor. + /// + /// The value is null if there is no nodes are selected. + ValueNotifier get currentSelection; + + /// The current selected [Node]s in editor. + /// + /// The order of the result is determined according to the [currentSelection]. + /// The result are ordered from back to front if the selection is forward. + /// The result are ordered from front to back if the selection is backward. + /// + /// For example, Here is an array of selected nodes, `[n1, n2, n3]`. + /// The result will be `[n3, n2, n1]` if the selection is forward, + /// and `[n1, n2, n3]` if the selection is backward. + /// + /// Returns empty result if there is no nodes are selected. + List get currentSelectedNodes; + + /// Updates the selection. + /// + /// The editor will update selection area and toolbar area + /// if the [selection] is not collapsed, + /// otherwise, will update the cursor area. + void updateSelection(Selection? selection); + + /// Clears the selection area, cursor area and the popup list area. + void clearSelection(); + + /// Returns the [Node]s in [Selection]. + List getNodesInSelection(Selection selection); + + /// Returns the [Node] containing to the [offset]. + /// + /// [offset] must be under the global coordinate system. + Node? getNodeInOffset(Offset offset); + + /// Returns the [Position] closest to the [offset]. + /// + /// Returns null if there is no nodes are selected. + /// + /// [offset] must be under the global coordinate system. + Position? getPositionInOffset(Offset offset); + + /// The current selection areas's rect in editor. + List get selectionRects; +} + +class AppFlowySelection extends StatefulWidget { + const AppFlowySelection({ + Key? key, + this.cursorColor = Colors.black, + this.selectionColor = const Color.fromARGB(53, 111, 201, 231), + required this.editorState, + required this.child, + }) : super(key: key); + + final EditorState editorState; + final Widget child; + final Color cursorColor; + final Color selectionColor; + + @override + State createState() => _AppFlowySelectionState(); +} + +class _AppFlowySelectionState extends State + with WidgetsBindingObserver + implements AppFlowySelectionService { + final _cursorKey = GlobalKey(debugLabel: 'cursor'); + + @override + final List selectionRects = []; + final List _selectionAreas = []; + final List _cursorAreas = []; + + // OverlayEntry? _debugOverlay; + + /// Pan + Offset? _panStartOffset; + double? _panStartScrollDy; + + EditorState get editorState => widget.editorState; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + currentSelection.addListener(_onSelectionChange); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + + // Need to refresh the selection when the metrics changed. + if (currentSelection.value != null) { + updateSelection(currentSelection.value!); + } + } + + @override + void dispose() { + clearSelection(); + WidgetsBinding.instance.removeObserver(this); + currentSelection.removeListener(_onSelectionChange); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionGestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onTapDown: _onTapDown, + onDoubleTapDown: _onDoubleTapDown, + onTripleTapDown: _onTripleTapDown, + child: widget.child, + ); + } + + @override + ValueNotifier currentSelection = ValueNotifier(null); + + @override + List currentSelectedNodes = []; + + @override + List getNodesInSelection(Selection selection) { + final start = + selection.isBackward ? selection.start.path : selection.end.path; + final end = + selection.isBackward ? selection.end.path : selection.start.path; + assert(start <= end); + final startNode = editorState.document.nodeAtPath(start); + final endNode = editorState.document.nodeAtPath(end); + if (startNode != null && endNode != null) { + final nodes = + NodeIterator(editorState.document, startNode, endNode).toList(); + if (selection.isBackward) { + return nodes; + } else { + return nodes.reversed.toList(growable: false); + } + } + return []; + } + + @override + void updateSelection(Selection? selection) { + selectionRects.clear(); + clearSelection(); + + if (selection != null) { + if (selection.isCollapsed) { + // updates cursor area. + Log.selection.debug('update cursor area, $selection'); + _updateCursorAreas(selection.start); + } else { + // updates selection area. + Log.selection.debug('update cursor area, $selection'); + _updateSelectionAreas(selection); + } + } + + currentSelection.value = selection; + editorState.updateCursorSelection(selection, CursorUpdateReason.uiEvent); + } + + @override + void clearSelection() { + currentSelectedNodes = []; + currentSelection.value = null; + + // clear selection areas + _selectionAreas + ..forEach((overlay) => overlay.remove()) + ..clear(); + // clear cursor areas + _cursorAreas + ..forEach((overlay) => overlay.remove()) + ..clear(); + // hide toolbar + editorState.service.toolbarService?.hide(); + } + + @override + Node? getNodeInOffset(Offset offset) { + final sortedNodes = + editorState.document.root.children.toList(growable: false); + return _getNodeInOffset( + sortedNodes, + offset, + 0, + sortedNodes.length - 1, + ); + } + + @override + Position? getPositionInOffset(Offset offset) { + final node = getNodeInOffset(offset); + final selectable = node?.selectable; + if (selectable == null) { + clearSelection(); + return null; + } + return selectable.getPositionInOffset(offset); + } + + void _onTapDown(TapDownDetails details) { + // clear old state. + _panStartOffset = null; + + final position = getPositionInOffset(details.globalPosition); + if (position == null) { + return; + } + final selection = Selection.collapsed(position); + updateSelection(selection); + + _enableInteraction(); + + _showDebugLayerIfNeeded(offset: details.globalPosition); + } + + void _onDoubleTapDown(TapDownDetails details) { + final offset = details.globalPosition; + final node = getNodeInOffset(offset); + final selection = node?.selectable?.getWorldBoundaryInOffset(offset); + if (selection == null) { + clearSelection(); + return; + } + updateSelection(selection); + + _enableInteraction(); + } + + void _onTripleTapDown(TapDownDetails details) { + final offset = details.globalPosition; + final node = getNodeInOffset(offset); + final selectable = node?.selectable; + if (selectable == null) { + clearSelection(); + return; + } + Selection selection = Selection( + start: selectable.start(), + end: selectable.end(), + ); + updateSelection(selection); + + _enableInteraction(); + } + + void _onPanStart(DragStartDetails details) { + clearSelection(); + + _panStartOffset = details.globalPosition; + _panStartScrollDy = editorState.service.scrollService?.dy; + + _enableInteraction(); + } + + void _onPanUpdate(DragUpdateDetails details) { + if (_panStartOffset == null || _panStartScrollDy == null) { + return; + } + + _enableInteraction(); + + final panEndOffset = details.globalPosition; + final dy = editorState.service.scrollService?.dy; + final panStartOffset = dy == null + ? _panStartOffset! + : _panStartOffset!.translate(0, _panStartScrollDy! - dy); + + final first = getNodeInOffset(panStartOffset)?.selectable; + final last = getNodeInOffset(panEndOffset)?.selectable; + + // compute the selection in range. + if (first != null && last != null) { + final start = + first.getSelectionInRange(panStartOffset, panEndOffset).start; + final end = last.getSelectionInRange(panStartOffset, panEndOffset).end; + final selection = Selection(start: start, end: end); + updateSelection(selection); + } + + _showDebugLayerIfNeeded(offset: panEndOffset); + } + + void _onPanEnd(DragEndDetails details) { + // do nothing + } + + void _updateSelectionAreas(Selection selection) { + final nodes = getNodesInSelection(selection); + + currentSelectedNodes = nodes; + + // TODO: need to be refactored. + Rect? topmostRect; + LayerLink? layerLink; + + final backwardNodes = + selection.isBackward ? nodes : nodes.reversed.toList(growable: false); + final backwardSelection = selection.isBackward + ? selection + : selection.copyWith(start: selection.end, end: selection.start); + assert(backwardSelection.isBackward); + + for (var i = 0; i < backwardNodes.length; i++) { + final node = backwardNodes[i]; + final selectable = node.selectable; + if (selectable == null) { + continue; + } + + var newSelection = backwardSelection.copy(); + + /// In the case of multiple selections, + /// we need to return a new selection for each selected node individually. + /// + /// < > means selected. + /// text: abcdopqr + /// + if (!backwardSelection.isSingle) { + if (i == 0) { + newSelection = newSelection.copyWith(end: selectable.end()); + } else if (i == nodes.length - 1) { + newSelection = newSelection.copyWith(start: selectable.start()); + } else { + newSelection = Selection( + start: selectable.start(), + end: selectable.end(), + ); + } + } + + final rects = selectable.getRectsInSelection(newSelection); + for (final rect in rects) { + // TODO: Need to compute more precise location. + topmostRect ??= rect; + layerLink ??= node.layerLink; + + selectionRects.add(_transformRectToGlobal(selectable, rect)); + + final overlay = OverlayEntry( + builder: (context) => SelectionWidget( + color: widget.selectionColor, + layerLink: node.layerLink, + rect: rect, + ), + ); + _selectionAreas.add(overlay); + } + } + + Overlay.of(context)?.insertAll(_selectionAreas); + + if (topmostRect != null && layerLink != null) { + editorState.service.toolbarService + ?.showInOffset(topmostRect.topLeft, layerLink); + } + } + + void _updateCursorAreas(Position position) { + final node = editorState.document.root.childAtPath(position.path); + + if (node == null) { + assert(false); + return; + } + + currentSelectedNodes = [node]; + + _showCursor(node, position); + } + + void _showCursor(Node node, Position position) { + final selectable = node.selectable; + final cursorRect = selectable?.getCursorRectInPosition(position); + if (selectable != null && cursorRect != null) { + final cursorArea = OverlayEntry( + builder: (context) => CursorWidget( + key: _cursorKey, + rect: cursorRect, + color: widget.cursorColor, + layerLink: node.layerLink, + ), + ); + + _cursorAreas.add(cursorArea); + selectionRects.add(_transformRectToGlobal(selectable, cursorRect)); + Overlay.of(context)?.insertAll(_cursorAreas); + + _forceShowCursor(); + } + } + + void _forceShowCursor() { + _cursorKey.currentState?.unwrapOrNull()?.show(); + } + + void _scrollUpOrDownIfNeeded() { + final dy = editorState.service.scrollService?.dy; + final selectNodes = currentSelectedNodes; + final selection = currentSelection.value; + if (dy == null || selection == null || selectNodes.isEmpty) { + return; + } + + final rect = selectNodes.last.rect; + + final size = MediaQuery.of(context).size.height; + final topLimit = size * 0.3; + final bottomLimit = size * 0.8; + + // TODO: It is necessary to calculate the relative speed + // according to the gap and move forward more gently. + if (rect.top >= bottomLimit) { + if (selection.isSingle) { + editorState.service.scrollService?.scrollTo(dy + size * 0.2); + } else if (selection.isBackward) { + editorState.service.scrollService?.scrollTo(dy + 10.0); + } + } else if (rect.bottom <= topLimit) { + if (selection.isForward) { + editorState.service.scrollService?.scrollTo(dy - 10.0); + } + } + } + + Node? _getNodeInOffset( + List sortedNodes, Offset offset, int start, int end) { + if (start < 0 && end >= sortedNodes.length) { + return null; + } + var min = start; + var max = end; + while (min <= max) { + final mid = min + ((max - min) >> 1); + final rect = sortedNodes[mid].rect; + if (rect.bottom <= offset.dy) { + min = mid + 1; + } else { + max = mid - 1; + } + } + min = min.clamp(start, end); + final node = sortedNodes[min]; + if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { + final children = node.children.toList(growable: false); + return _getNodeInOffset( + children, + offset, + 0, + children.length - 1, + ); + } + return node; + } + + void _enableInteraction() { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + } + + Rect _transformRectToGlobal(Selectable selectable, Rect r) { + final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top)); + return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height); + } + + void _onSelectionChange() { + _scrollUpOrDownIfNeeded(); + } + + void _showDebugLayerIfNeeded({Offset? offset}) { + // remove false to show debug overlay. + // if (kDebugMode && false) { + // _debugOverlay?.remove(); + // if (offset != null) { + // _debugOverlay = OverlayEntry( + // builder: (context) => Positioned.fromRect( + // rect: Rect.fromPoints(offset, offset.translate(20, 20)), + // child: Container( + // color: Colors.red.withOpacity(0.2), + // ), + // ), + // ); + // Overlay.of(context)?.insert(_debugOverlay!); + // } else if (_panStartOffset != null) { + // _debugOverlay = OverlayEntry( + // builder: (context) => Positioned.fromRect( + // rect: Rect.fromPoints( + // _panStartOffset?.translate( + // 0, + // -(editorState.service.scrollService!.dy - + // _panStartScrollDy!), + // ) ?? + // Offset.zero, + // offset ?? Offset.zero), + // child: Container( + // color: Colors.red.withOpacity(0.2), + // ), + // ), + // ); + // Overlay.of(context)?.insert(_debugOverlay!); + // } else { + // _debugOverlay = null; + // } + // } + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart similarity index 51% rename from frontend/app_flowy/packages/flowy_editor/lib/service/service.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart index e36312d7f3..e3436ea7ee 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart @@ -1,35 +1,38 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/service/toolbar_service.dart'; import 'package:flutter/material.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flowy_editor/service/scroll_service.dart'; -import 'package:flowy_editor/service/selection_service.dart'; -import 'package:flowy_editor/service/toolbar_service.dart'; - class FlowyService { // selection service final selectionServiceKey = GlobalKey(debugLabel: 'flowy_selection_service'); - FlowySelectionService get selectionService { + AppFlowySelectionService get selectionService { assert(selectionServiceKey.currentState != null && - selectionServiceKey.currentState is FlowySelectionService); - return selectionServiceKey.currentState! as FlowySelectionService; + selectionServiceKey.currentState is AppFlowySelectionService); + return selectionServiceKey.currentState! as AppFlowySelectionService; } // keyboard service final keyboardServiceKey = GlobalKey(debugLabel: 'flowy_keyboard_service'); - FlowyKeyboardService? get keyboardService { + AppFlowyKeyboardService? get keyboardService { if (keyboardServiceKey.currentState != null && - keyboardServiceKey.currentState is FlowyKeyboardService) { - return keyboardServiceKey.currentState! as FlowyKeyboardService; + keyboardServiceKey.currentState is AppFlowyKeyboardService) { + return keyboardServiceKey.currentState! as AppFlowyKeyboardService; } return null; } // input service final inputServiceKey = GlobalKey(debugLabel: 'flowy_input_service'); + AppFlowyInputService? get inputService { + if (inputServiceKey.currentState != null && + inputServiceKey.currentState is AppFlowyInputService) { + return inputServiceKey.currentState! as AppFlowyInputService; + } + return null; + } // render plugin service - late FlowyRenderPlugin renderPluginService; + late AppFlowyRenderPlugin renderPluginService; // toolbar service final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service'); @@ -43,10 +46,10 @@ class FlowyService { // scroll service final scrollServiceKey = GlobalKey(debugLabel: 'flowy_scroll_service'); - FlowyScrollService? get scrollService { + AppFlowyScrollService? get scrollService { if (scrollServiceKey.currentState != null && - scrollServiceKey.currentState is FlowyScrollService) { - return scrollServiceKey.currentState! as FlowyScrollService; + scrollServiceKey.currentState is AppFlowyScrollService) { + return scrollServiceKey.currentState! as AppFlowyScrollService; } return null; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart similarity index 65% rename from frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart index f2026acb23..bf380290f9 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/render/selection/toolbar_widget.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/selection/toolbar_widget.dart'; +import 'package:appflowy_editor/src/extensions/object_extensions.dart'; -mixin FlowyToolbarService { +abstract class FlowyToolbarService { /// Show the toolbar widget beside the offset. void showInOffset(Offset offset, LayerLink layerLink); @@ -25,14 +26,18 @@ class FlowyToolbar extends StatefulWidget { State createState() => _FlowyToolbarState(); } -class _FlowyToolbarState extends State with FlowyToolbarService { +class _FlowyToolbarState extends State + implements FlowyToolbarService { OverlayEntry? _toolbarOverlay; + final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget'); @override void showInOffset(Offset offset, LayerLink layerLink) { - _toolbarOverlay?.remove(); + hide(); + _toolbarOverlay = OverlayEntry( builder: (context) => ToolbarWidget( + key: _toolbarWidgetKey, editorState: widget.editorState, layerLink: layerLink, offset: offset.translate(0, -37.0), @@ -44,6 +49,7 @@ class _FlowyToolbarState extends State with FlowyToolbarService { @override void hide() { + _toolbarWidgetKey.currentState?.unwrapOrNull()?.hide(); _toolbarOverlay?.remove(); _toolbarOverlay = null; } @@ -54,4 +60,11 @@ class _FlowyToolbarState extends State with FlowyToolbarService { child: widget.child, ); } + + @override + void dispose() { + hide(); + + super.dispose(); + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart similarity index 81% rename from frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart index 5b543f03a1..cfa3f75688 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart @@ -1,11 +1,11 @@ import 'dart:collection'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/operation/operation.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/operation/transaction.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flutter/foundation.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/operation/transaction.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; /// A [HistoryItem] contains list of operations committed by users. /// If a [HistoryItem] is not sealed, operations can be added sequentially. @@ -18,6 +18,11 @@ class HistoryItem extends LinkedListEntry { HistoryItem(); + /// Seal the history item. + /// When an item is sealed, no more operations can be added + /// to the item. + /// + /// The caller should create a new [HistoryItem]. seal() { _sealed = true; } @@ -32,6 +37,7 @@ class HistoryItem extends LinkedListEntry { operations.addAll(iterable); } + /// Create a new [Transaction] by inverting the operations. Transaction toTransaction(EditorState state) { final builder = TransactionBuilder(state); for (var i = operations.length - 1; i >= 0; i--) { @@ -106,7 +112,7 @@ class UndoManager { } undo() { - debugPrint('undo'); + Log.editor.debug('undo'); final s = state; if (s == null) { return; @@ -125,7 +131,7 @@ class UndoManager { } redo() { - debugPrint('redo'); + Log.editor.debug('redo'); final s = state; if (s == null) { return; diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml similarity index 87% rename from frontend/app_flowy/packages/flowy_editor/pubspec.yaml rename to frontend/app_flowy/packages/appflowy_editor/pubspec.yaml index 05c87f8e33..33f443d066 100644 --- a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml @@ -1,7 +1,7 @@ -name: flowy_editor -description: A new Flutter package project. -version: 0.0.1 -homepage: +name: appflowy_editor +description: A highly customizable rich-text editor for Flutter +version: 0.0.2 +homepage: https://github.com/AppFlowy-IO/AppFlowy environment: sdk: ">=2.17.0 <3.0.0" @@ -15,6 +15,8 @@ dependencies: html: ^0.15.0 flutter_svg: ^1.1.1+1 provider: ^6.0.3 + url_launcher: ^6.1.5 + logging: ^1.0.2 dev_dependencies: flutter_test: @@ -29,11 +31,8 @@ flutter: # To add assets to your package, add an assets section, like this: assets: - assets/images/toolbar/ - - assets/images/popup_list/ + - assets/images/selection_menu/ - assets/images/ - - assets/document.json - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg # # For details regarding assets in packages, see # https://flutter.dev/assets-and-images/#from-packages diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/log_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/log_test.dart new file mode 100644 index 0000000000..f49a32c130 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/log_test.dart @@ -0,0 +1,169 @@ +import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'test_editor.dart'; + +void main() async { + group('log.dart', () { + testWidgets('test LogConfiguration in EditorState', (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + + final editor = tester.editor; + editor.editorState.logConfiguration + ..level = LogLevel.all + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + expect(logs.last.contains('DEBUG'), true); + expect(logs.length, 1); + }); + + test('test LogLevel.all', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.all + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + expect(logs.last.contains('DEBUG'), true); + Log.editor.info(text); + expect(logs.last.contains('INFO'), true); + Log.editor.warn(text); + expect(logs.last.contains('WARN'), true); + Log.editor.error(text); + expect(logs.last.contains('ERROR'), true); + + expect(logs.length, 4); + }); + + test('test LogLevel.off', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.off + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + Log.editor.info(text); + Log.editor.warn(text); + Log.editor.error(text); + + expect(logs.length, 0); + }); + + test('test LogLevel.error', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.error + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + Log.editor.info(text); + Log.editor.warn(text); + Log.editor.error(text); + + expect(logs.length, 1); + }); + + test('test LogLevel.warn', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.warn + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + Log.editor.info(text); + Log.editor.warn(text); + Log.editor.error(text); + + expect(logs.length, 2); + }); + + test('test LogLevel.info', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.info + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + Log.editor.info(text); + Log.editor.warn(text); + Log.editor.error(text); + + expect(logs.length, 3); + }); + + test('test LogLevel.debug', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.debug + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + Log.editor.info(text); + Log.editor.warn(text); + Log.editor.error(text); + + expect(logs.length, 4); + }); + + test('test logger', () { + const text = 'Welcome to Appflowy 😁'; + + final List logs = []; + LogConfiguration() + ..level = LogLevel.all + ..handler = (message) { + logs.add(message); + }; + + Log.editor.debug(text); + expect(logs.last.contains('editor'), true); + + Log.selection.debug(text); + expect(logs.last.contains('selection'), true); + + Log.keyboard.debug(text); + expect(logs.last.contains('keyboard'), true); + + Log.input.debug(text); + expect(logs.last.contains('input'), true); + + Log.scroll.debug(text); + expect(logs.last.contains('scroll'), true); + + Log.ui.debug(text); + expect(logs.last.contains('ui'), true); + + expect(logs.length, 6); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart new file mode 100644 index 0000000000..8c89b603aa --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart @@ -0,0 +1,153 @@ +import 'dart:collection'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_raw_key_event.dart'; + +class EditorWidgetTester { + EditorWidgetTester({ + required this.tester, + }); + + final WidgetTester tester; + late EditorState _editorState; + + EditorState get editorState => _editorState; + Node get root => _editorState.document.root; + + int get documentLength => _editorState.document.root.children.length; + Selection? get documentSelection => + _editorState.service.selectionService.currentSelection.value; + + Future startTesting() async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppFlowyEditor( + editorState: _editorState, + ), + ), + ), + ); + return this; + } + + void initialize() { + _editorState = _createEmptyDocument(); + } + + void insert(T node) { + _editorState.document.root.insert(node); + } + + void insertEmptyTextNode() { + insert(TextNode.empty()); + } + + void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) { + insert( + TextNode( + type: 'text', + delta: delta ?? Delta([TextInsert(text ?? 'Test')]), + attributes: attributes, + ), + ); + } + + Node? nodeAtPath(Path path) { + return root.childAtPath(path); + } + + Future updateSelection(Selection? selection) async { + if (selection == null) { + _editorState.service.selectionService.clearSelection(); + } else { + _editorState.service.selectionService.updateSelection(selection); + } + await tester.pumpAndSettle(); + + expect(_editorState.service.selectionService.currentSelection.value, + selection); + } + + Future insertText(TextNode textNode, String text, int offset, + {Selection? selection}) async { + await apply([ + TextEditingDeltaInsertion( + oldText: textNode.toRawString(), + textInserted: text, + insertionOffset: offset, + selection: selection != null + ? TextSelection( + baseOffset: selection.start.offset, + extentOffset: selection.end.offset) + : TextSelection.collapsed(offset: offset), + composing: TextRange.empty, + ) + ]); + } + + Future apply(List deltas) async { + _editorState.service.inputService?.apply(deltas); + await tester.pumpAndSettle(); + } + + Future pressLogicKey( + LogicalKeyboardKey key, { + bool isControlPressed = false, + bool isShiftPressed = false, + bool isAltPressed = false, + bool isMetaPressed = false, + }) async { + if (!isControlPressed && + !isShiftPressed && + !isAltPressed && + !isMetaPressed) { + await tester.sendKeyDownEvent(key); + } else { + final testRawKeyEventData = TestRawKeyEventData( + logicalKey: key, + isControlPressed: isControlPressed, + isShiftPressed: isShiftPressed, + isAltPressed: isAltPressed, + isMetaPressed: isMetaPressed, + ).toKeyEvent; + _editorState.service.keyboardService!.onKey(testRawKeyEventData); + } + await tester.pumpAndSettle(); + } + + Node _createEmptyEditorRoot() { + return Node( + type: 'editor', + children: LinkedList(), + attributes: {}, + ); + } + + EditorState _createEmptyDocument() { + return EditorState( + document: StateTree( + root: _createEmptyEditorRoot(), + ), + )..disableSealTimer = true; + } +} + +extension TestString on String { + String safeSubString([int start = 0, int? end]) { + end ??= length - 1; + end = end.clamp(start, length - 1); + final sRunes = runes; + return String.fromCharCodes(sRunes, start, end); + } +} + +extension TestEditorExtension on WidgetTester { + EditorWidgetTester get editor => + EditorWidgetTester(tester: this)..initialize(); + EditorState get editorState => editor.editorState; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart new file mode 100644 index 0000000000..150a3e2d00 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart @@ -0,0 +1,132 @@ +import 'package:flutter/services.dart'; + +class TestRawKeyEvent extends RawKeyDownEvent { + const TestRawKeyEvent({ + required super.data, + this.isControlPressed = false, + this.isShiftPressed = false, + this.isAltPressed = false, + this.isMetaPressed = false, + }); + + @override + final bool isControlPressed; + + @override + final bool isShiftPressed; + + @override + final bool isAltPressed; + + @override + final bool isMetaPressed; +} + +class TestRawKeyEventData extends RawKeyEventData { + const TestRawKeyEventData({ + required this.logicalKey, + this.isControlPressed = false, + this.isShiftPressed = false, + this.isAltPressed = false, + this.isMetaPressed = false, + }); + + @override + final bool isControlPressed; + + @override + final bool isShiftPressed; + + @override + final bool isAltPressed; + + @override + final bool isMetaPressed; + + @override + final LogicalKeyboardKey logicalKey; + + @override + PhysicalKeyboardKey get physicalKey => logicalKey.toPhysicalKey; + + @override + KeyboardSide? getModifierSide(ModifierKey key) { + throw UnimplementedError(); + } + + @override + bool isModifierPressed(ModifierKey key, + {KeyboardSide side = KeyboardSide.any}) { + throw UnimplementedError(); + } + + @override + String get keyLabel => throw UnimplementedError(); + + RawKeyEvent get toKeyEvent { + return TestRawKeyEvent( + data: this, + isAltPressed: isAltPressed, + isControlPressed: isControlPressed, + isMetaPressed: isMetaPressed, + isShiftPressed: isShiftPressed, + ); + } +} + +extension on LogicalKeyboardKey { + PhysicalKeyboardKey get toPhysicalKey { + if (this == LogicalKeyboardKey.enter) { + return PhysicalKeyboardKey.enter; + } + if (this == LogicalKeyboardKey.space) { + return PhysicalKeyboardKey.space; + } + if (this == LogicalKeyboardKey.backspace) { + return PhysicalKeyboardKey.backspace; + } + if (this == LogicalKeyboardKey.delete) { + return PhysicalKeyboardKey.delete; + } + if (this == LogicalKeyboardKey.arrowRight) { + return PhysicalKeyboardKey.arrowRight; + } + if (this == LogicalKeyboardKey.arrowLeft) { + return PhysicalKeyboardKey.arrowLeft; + } + if (this == LogicalKeyboardKey.pageDown) { + return PhysicalKeyboardKey.pageDown; + } + if (this == LogicalKeyboardKey.pageUp) { + return PhysicalKeyboardKey.pageUp; + } + if (this == LogicalKeyboardKey.slash) { + return PhysicalKeyboardKey.slash; + } + if (this == LogicalKeyboardKey.arrowDown) { + return PhysicalKeyboardKey.arrowDown; + } + if (this == LogicalKeyboardKey.keyA) { + return PhysicalKeyboardKey.keyA; + } + if (this == LogicalKeyboardKey.keyB) { + return PhysicalKeyboardKey.keyB; + } + if (this == LogicalKeyboardKey.keyI) { + return PhysicalKeyboardKey.keyI; + } + if (this == LogicalKeyboardKey.keyS) { + return PhysicalKeyboardKey.keyS; + } + if (this == LogicalKeyboardKey.keyU) { + return PhysicalKeyboardKey.keyU; + } + if (this == LogicalKeyboardKey.keyH) { + return PhysicalKeyboardKey.keyH; + } + if (this == LogicalKeyboardKey.keyZ) { + return PhysicalKeyboardKey.keyZ; + } + throw UnimplementedError(); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/delta_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/delta_test.dart new file mode 100644 index 0000000000..1540b18a21 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/legacy/delta_test.dart @@ -0,0 +1,329 @@ +import 'package:appflowy_editor/src/document/attributes.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy_editor/src/document/text_delta.dart'; + +void main() { + group('compose', () { + test('test delta', () { + final delta = Delta([ + TextInsert('Gandalf', { + 'bold': true, + }), + TextInsert(' the '), + TextInsert('Grey', { + 'color': '#ccc', + }) + ]); + + final death = Delta() + ..retain(12) + ..insert("White", { + 'color': '#fff', + }) + ..delete(4); + + final restores = delta.compose(death); + expect(restores.toList(), [ + TextInsert('Gandalf', {'bold': true}), + TextInsert(' the '), + TextInsert('White', {'color': '#fff'}), + ]); + }); + test('compose()', () { + final a = Delta()..insert('A'); + final b = Delta()..insert('B'); + final expected = Delta() + ..insert('B') + ..insert('A'); + expect(a.compose(b), expected); + }); + test('insert + retain', () { + final a = Delta()..insert('A'); + final b = Delta() + ..retain(1, { + 'bold': true, + 'color': 'red', + }); + final expected = Delta() + ..insert('A', { + 'bold': true, + 'color': 'red', + }); + expect(a.compose(b), expected); + }); + test('insert + delete', () { + final a = Delta()..insert('A'); + final b = Delta()..delete(1); + final expected = Delta(); + expect(a.compose(b), expected); + }); + test('delete + insert', () { + final a = Delta()..delete(1); + final b = Delta()..insert('B'); + final expected = Delta() + ..insert('B') + ..delete(1); + expect(a.compose(b), expected); + }); + test('delete + retain', () { + final a = Delta()..delete(1); + final b = Delta() + ..retain(1, { + 'bold': true, + 'color': 'red', + }); + final expected = Delta() + ..delete(1) + ..retain(1, { + 'bold': true, + 'color': 'red', + }); + expect(a.compose(b), expected); + }); + test('delete + delete', () { + final a = Delta()..delete(1); + final b = Delta()..delete(1); + final expected = Delta()..delete(2); + expect(a.compose(b), expected); + }); + test('retain + insert', () { + final a = Delta()..retain(1, {'color': 'blue'}); + final b = Delta()..insert('B'); + final expected = Delta() + ..insert('B') + ..retain(1, { + 'color': 'blue', + }); + expect(a.compose(b), expected); + }); + test('retain + retain', () { + final a = Delta() + ..retain(1, { + 'color': 'blue', + }); + final b = Delta() + ..retain(1, { + 'bold': true, + 'color': 'red', + }); + final expected = Delta() + ..retain(1, { + 'bold': true, + 'color': 'red', + }); + expect(a.compose(b), expected); + }); + test('retain + delete', () { + final a = Delta() + ..retain(1, { + 'color': 'blue', + }); + final b = Delta()..delete(1); + final expected = Delta()..delete(1); + expect(a.compose(b), expected); + }); + test('insert in middle of text', () { + final a = Delta()..insert('Hello'); + final b = Delta() + ..retain(3) + ..insert('X'); + final expected = Delta()..insert('HelXlo'); + expect(a.compose(b), expected); + }); + test('insert and delete ordering', () { + final a = Delta()..insert('Hello'); + final b = Delta()..insert('Hello'); + final insertFirst = Delta() + ..retain(3) + ..insert('X') + ..delete(1); + final deleteFirst = Delta() + ..retain(3) + ..delete(1) + ..insert('X'); + final expected = Delta()..insert('HelXo'); + expect(a.compose(insertFirst), expected); + expect(b.compose(deleteFirst), expected); + }); + test('delete entire text', () { + final a = Delta() + ..retain(4) + ..insert('Hello'); + final b = Delta()..delete(9); + final expected = Delta()..delete(4); + expect(a.compose(b), expected); + }); + test('retain more than length of text', () { + final a = Delta()..insert('Hello'); + final b = Delta()..retain(10); + final expected = Delta()..insert('Hello'); + expect(a.compose(b), expected); + }); + test('retain start optimization', () { + final a = Delta() + ..insert('A', {'bold': true}) + ..insert('B') + ..insert('C', {'bold': true}) + ..delete(1); + final b = Delta() + ..retain(3) + ..insert('D'); + final expected = Delta() + ..insert('A', {'bold': true}) + ..insert('B') + ..insert('C', {'bold': true}) + ..insert('D') + ..delete(1); + expect(a.compose(b), expected); + }); + test('retain end optimization', () { + final a = Delta() + ..insert('A', {'bold': true}) + ..insert('B') + ..insert('C', {'bold': true}); + final b = Delta()..delete(1); + final expected = Delta() + ..insert('B') + ..insert('C', {'bold': true}); + expect(a.compose(b), expected); + }); + test('retain end optimization join', () { + final a = Delta() + ..insert('A', {'bold': true}) + ..insert('B') + ..insert('C', {'bold': true}) + ..insert('D') + ..insert('E', {'bold': true}) + ..insert('F'); + final b = Delta() + ..retain(1) + ..delete(1); + final expected = Delta() + ..insert('AC', {'bold': true}) + ..insert('D') + ..insert('E', {'bold': true}) + ..insert('F'); + expect(a.compose(b), expected); + }); + }); + group('invert', () { + test('insert', () { + final delta = Delta() + ..retain(2) + ..insert('A'); + final base = Delta()..insert('12346'); + final expected = Delta() + ..retain(2) + ..delete(1); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + test('delete', () { + final delta = Delta() + ..retain(2) + ..delete(3); + final base = Delta()..insert('123456'); + final expected = Delta() + ..retain(2) + ..insert('345'); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + test('retain', () { + final delta = Delta() + ..retain(2) + ..retain(3, {'bold': true}); + final base = Delta()..insert('123456'); + final expected = Delta() + ..retain(2) + ..retain(3, {'bold': null}); + final inverted = delta.invert(base); + expect(expected, inverted); + final t = base.compose(delta).compose(inverted); + expect(t, base); + }); + }); + group('json', () { + test('toJson()', () { + final delta = Delta() + ..retain(2) + ..insert('A') + ..delete(3); + expect(delta.toJson(), [ + {'retain': 2}, + {'insert': 'A'}, + {'delete': 3} + ]); + }); + test('attributes', () { + final delta = Delta() + ..retain(2, {'bold': true}) + ..insert('A', {'italic': true}); + expect(delta.toJson(), [ + { + 'retain': 2, + 'attributes': {'bold': true}, + }, + { + 'insert': 'A', + 'attributes': {'italic': true}, + }, + ]); + }); + test('fromJson()', () { + final delta = Delta.fromJson([ + {'retain': 2}, + {'insert': 'A'}, + {'delete': 3}, + ]); + final expected = Delta() + ..retain(2) + ..insert('A') + ..delete(3); + expect(delta, expected); + }); + }); + group('runes', () { + test("stringIndexes", () { + final indexes = stringIndexes('😊'); + expect(indexes[0], 0); + expect(indexes[1], 0); + }); + test("next rune 1", () { + final delta = Delta()..insert('😊'); + expect(delta.nextRunePosition(0), 2); + }); + test("next rune 2", () { + final delta = Delta()..insert('😊a'); + expect(delta.nextRunePosition(0), 2); + }); + test("next rune 3", () { + final delta = Delta()..insert('😊陈'); + expect(delta.nextRunePosition(2), 3); + }); + test("prev rune 1", () { + final delta = Delta()..insert('😊陈'); + expect(delta.prevRunePosition(2), 0); + }); + test("prev rune 2", () { + final delta = Delta()..insert('😊'); + expect(delta.prevRunePosition(2), 0); + }); + test("prev rune 3", () { + final delta = Delta()..insert('😊'); + expect(delta.prevRunePosition(0), -1); + }); + }); + group("attributes", () { + test("compose", () { + final attrs = composeAttributes({"a": null}, {"b": null}, true); + expect(attrs != null, true); + expect(attrs!.containsKey("a"), true); + expect(attrs.containsKey("b"), true); + expect(attrs["a"], null); + expect(attrs["b"], null); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart new file mode 100644 index 0000000000..ab37cfec0c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart @@ -0,0 +1,137 @@ +import 'package:appflowy_editor/src/document/path.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('create state tree', () async { + // final String response = await rootBundle.loadString('assets/document.json'); + // final data = Map.from(json.decode(response)); + // final stateTree = StateTree.fromJson(data); + // expect(stateTree.root.type, 'root'); + // expect(stateTree.root.toJson(), data['document']); + }); + + test('search node by Path in state tree', () async { + // final String response = await rootBundle.loadString('assets/document.json'); + // final data = Map.from(json.decode(response)); + // final stateTree = StateTree.fromJson(data); + // final checkBoxNode = stateTree.root.childAtPath([1, 0]); + // expect(checkBoxNode != null, true); + // final textType = checkBoxNode!.attributes['text-type']; + // expect(textType != null, true); + }); + + test('search node by Self in state tree', () async { + // final String response = await rootBundle.loadString('assets/document.json'); + // final data = Map.from(json.decode(response)); + // final stateTree = StateTree.fromJson(data); + // final checkBoxNode = stateTree.root.childAtPath([1, 0]); + // expect(checkBoxNode != null, true); + // final textType = checkBoxNode!.attributes['text-type']; + // expect(textType != null, true); + // final path = checkBoxNode.path; + // expect(pathEquals(path, [1, 0]), true); + }); + + test('insert node in state tree', () async { + // final String response = await rootBundle.loadString('assets/document.json'); + // final data = Map.from(json.decode(response)); + // final stateTree = StateTree.fromJson(data); + // final insertNode = Node.fromJson({ + // 'type': 'text', + // }); + // bool result = stateTree.insert([1, 1], [insertNode]); + // expect(result, true); + // expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true); + }); + + test('delete node in state tree', () async { + // final String response = await rootBundle.loadString('assets/document.json'); + // final data = Map.from(json.decode(response)); + // final stateTree = StateTree.fromJson(data); + // stateTree.delete([1, 1], 1); + // final node = stateTree.nodeAtPath([1, 1]); + // expect(node != null, true); + // expect(node!.attributes['tag'], '**'); + }); + + test('update node in state tree', () async { + // final String response = await rootBundle.loadString('assets/document.json'); + // final data = Map.from(json.decode(response)); + // final stateTree = StateTree.fromJson(data); + // final test = stateTree.update([1, 1], {'text-type': 'heading1'}); + // expect(test, true); + // final updatedNode = stateTree.nodeAtPath([1, 1]); + // expect(updatedNode != null, true); + // expect(updatedNode!.attributes['text-type'], 'heading1'); + }); + + test('test path utils 1', () { + final path1 = [1]; + final path2 = [1]; + expect(pathEquals(path1, path2), true); + + expect(hashList(path1), hashList(path2)); + }); + + test('test path utils 2', () { + final path1 = [1]; + final path2 = [2]; + expect(pathEquals(path1, path2), false); + + expect(hashList(path1) != hashList(path2), true); + }); + + test('test position comparator', () { + final pos1 = Position(path: [1], offset: 0); + final pos2 = Position(path: [1], offset: 0); + expect(pos1 == pos2, true); + expect(pos1.hashCode == pos2.hashCode, true); + }); + + test('test position comparator with offset', () { + final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); + final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 100); + expect(pos1, pos2); + expect(pos1.hashCode, pos2.hashCode); + }); + + test('test position comparator false', () { + final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); + final pos2 = Position(path: [1, 1, 2, 1, 1], offset: 100); + expect(pos1 == pos2, false); + expect(pos1.hashCode == pos2.hashCode, false); + }); + + test('test position comparator with offset false', () { + final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); + final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 101); + expect(pos1 == pos2, false); + expect(pos1.hashCode == pos2.hashCode, false); + }); + + test('test selection comparator', () { + final pos = Position(path: [0], offset: 0); + final sel = Selection.collapsed(pos); + expect(sel.start, sel.end); + expect(sel.isCollapsed, true); + }); + + test('test selection collapse', () { + final start = Position(path: [0], offset: 0); + final end = Position(path: [0], offset: 10); + final sel = Selection(start: start, end: end); + + final collapsedSelAtStart = sel.collapse(atStart: true); + expect(collapsedSelAtStart.start, start); + expect(collapsedSelAtStart.end, start); + + final collapsedSelAtEnd = sel.collapse(); + expect(collapsedSelAtEnd.start, end); + expect(collapsedSelAtEnd.end, end); + }); +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart similarity index 92% rename from frontend/app_flowy/packages/flowy_editor/test/operation_test.dart rename to frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart index 339807cea4..1f44ebfd3c 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart @@ -1,11 +1,11 @@ import 'dart:collection'; -import 'package:flowy_editor/document/node.dart'; +import 'package:appflowy_editor/src/document/node.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flowy_editor/operation/operation.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/document/state_tree.dart'; +import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/editor_state.dart'; +import 'package:appflowy_editor/src/document/state_tree.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart new file mode 100644 index 0000000000..f039c227d9 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart @@ -0,0 +1,73 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('delete_text_handler.dart', () { + testWidgets('Presses backspace key in empty document', (tester) async { + // Before + // + // [BIUS]Welcome to Appflowy 😁[BIUS] + // + // After + // + // [checkbox]Welcome to Appflowy 😁 + // + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode( + '', + attributes: { + StyleKey.subtype: StyleKey.checkbox, + StyleKey.checkbox: false, + }, + delta: Delta([ + TextInsert(text, { + StyleKey.bold: true, + StyleKey.italic: true, + StyleKey.underline: true, + StyleKey.strikethrough: true, + }), + ]), + ); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final selection = + Selection.single(path: [0], startOffset: 0, endOffset: text.length); + var node = editor.nodeAtPath([0]) as TextNode; + var state = node.key?.currentState as DefaultSelectable; + var checkboxWidget = find.byKey(state.iconKey!); + await tester.tap(checkboxWidget); + await tester.pumpAndSettle(); + + expect(node.attributes.check, true); + + expect(node.allSatisfyBoldInSelection(selection), true); + expect(node.allSatisfyItalicInSelection(selection), true); + expect(node.allSatisfyUnderlineInSelection(selection), true); + expect(node.allSatisfyStrikethroughInSelection(selection), true); + + node = editor.nodeAtPath([0]) as TextNode; + state = node.key?.currentState as DefaultSelectable; + await tester.ensureVisible(find.byKey(state.iconKey!)); + await tester.tap(find.byKey(state.iconKey!)); + await tester.pump(); + + expect(node.attributes.check, false); + expect(node.allSatisfyBoldInSelection(selection), true); + expect(node.allSatisfyItalicInSelection(selection), true); + expect(node.allSatisfyUnderlineInSelection(selection), true); + expect(node.allSatisfyStrikethroughInSelection(selection), true); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart new file mode 100644 index 0000000000..1488b15b18 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_item_widget_test.dart @@ -0,0 +1,49 @@ +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('selection_menu_item_widget.dart', () { + testWidgets('test selection menu item widget', (tester) async { + bool flag = false; + final editorState = tester.editor.editorState; + final menuService = _TestSelectionMenuService(); + const icon = Icon(Icons.abc); + final item = SelectionMenuItem( + name: 'example', + icon: icon, + keywords: ['example A', 'example B'], + handler: (editorState, menuService) { + flag = true; + }, + ); + final widget = SelectionMenuItemWidget( + editorState: editorState, + menuService: menuService, + item: item, + isSelected: true, + ); + await tester.pumpWidget(MaterialApp(home: widget)); + await tester.tap(find.byType(SelectionMenuItemWidget)); + expect(flag, true); + }); + }); +} + +class _TestSelectionMenuService implements SelectionMenuService { + @override + void dismiss() {} + + @override + void show() {} + + @override + Offset get topLeft => throw UnimplementedError(); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart new file mode 100644 index 0000000000..1efcfa640d --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/selection_menu/selection_menu_widget_test.dart @@ -0,0 +1,150 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('selection_menu_widget.dart', () { + for (var i = 0; i < defaultSelectionMenuItems.length; i++) { + testWidgets('Selects number.$i item in selection menu', (tester) async { + final editor = await _prepare(tester); + for (var j = 0; j < i; j++) { + await editor.pressLogicKey(LogicalKeyboardKey.arrowDown); + } + + await editor.pressLogicKey(LogicalKeyboardKey.enter); + expect( + find.byType(SelectionMenuWidget, skipOffstage: false), + findsNothing, + ); + await _testDefaultSelectionMenuItems(i, editor); + }); + } + }); + + testWidgets('Search item in selection menu util no results', (tester) async { + final editor = await _prepare(tester); + await editor.pressLogicKey(LogicalKeyboardKey.keyT); + await editor.pressLogicKey(LogicalKeyboardKey.keyE); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(2), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(3), + ); + await editor.pressLogicKey(LogicalKeyboardKey.keyE); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(2), + ); + await editor.pressLogicKey(LogicalKeyboardKey.keyX); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(1), + ); + await editor.pressLogicKey(LogicalKeyboardKey.keyT); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(1), + ); + await editor.pressLogicKey(LogicalKeyboardKey.keyT); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNothing, + ); + }); + + testWidgets('Search item in selection menu and presses esc', (tester) async { + final editor = await _prepare(tester); + await editor.pressLogicKey(LogicalKeyboardKey.keyT); + await editor.pressLogicKey(LogicalKeyboardKey.keyE); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(2), + ); + await editor.pressLogicKey(LogicalKeyboardKey.escape); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNothing, + ); + }); + + testWidgets('Search item in selection menu and presses backspace', + (tester) async { + final editor = await _prepare(tester); + await editor.pressLogicKey(LogicalKeyboardKey.keyT); + await editor.pressLogicKey(LogicalKeyboardKey.keyE); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNWidgets(2), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNothing, + ); + }); +} + +Future _prepare(WidgetTester tester) async { + const text = 'Welcome to Appflowy 😁'; + const lines = 3; + final editor = tester.editor; + for (var i = 0; i < lines; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); + await editor.pressLogicKey(LogicalKeyboardKey.slash); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + expect( + find.byType(SelectionMenuWidget, skipOffstage: false), + findsOneWidget, + ); + + for (final item in defaultSelectionMenuItems) { + expect(find.byWidget(item.icon), findsOneWidget); + } + + return Future.value(editor); +} + +Future _testDefaultSelectionMenuItems( + int index, EditorWidgetTester editor) async { + expect(editor.documentLength, 4); + expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); + final node = editor.nodeAtPath([2]); + final item = defaultSelectionMenuItems[index]; + if (item.name == 'Text') { + expect(node?.subtype == null, true); + } else if (item.name == 'Heading 1') { + expect(node?.subtype, StyleKey.heading); + expect(node?.attributes.heading, StyleKey.h1); + } else if (item.name == 'Heading 2') { + expect(node?.subtype, StyleKey.heading); + expect(node?.attributes.heading, StyleKey.h2); + } else if (item.name == 'Heading 3') { + expect(node?.subtype, StyleKey.heading); + expect(node?.attributes.heading, StyleKey.h3); + } else if (item.name == 'Bulleted list') { + expect(node?.subtype, StyleKey.bulletedList); + } else if (item.name == 'Checkbox') { + expect(node?.subtype, StyleKey.checkbox); + expect(node?.attributes.check, false); + } +} 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 new file mode 100644 index 0000000000..e4631b56ad --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart @@ -0,0 +1,84 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('arrow_keys_handler.dart', () { + testWidgets('Presses arrow right key, move the cursor from left to right', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (var i = 0; i < text.length; i++) { + await editor.pressLogicKey(LogicalKeyboardKey.arrowRight); + + if (i == text.length - 1) { + // Wrap to next node if the cursor is at the end of the current node. + expect( + editor.documentSelection, + Selection.single( + path: [1], + startOffset: 0, + ), + ); + } else { + expect( + editor.documentSelection, + Selection.single( + path: [0], + startOffset: textNode.delta.nextRunePosition(i), + ), + ); + } + } + }); + }); + + testWidgets( + 'Presses arrow left/right key since selection is not collapsed and backward', + (tester) async { + await _testPressArrowKeyInNotCollapsedSelection(tester, true); + }); + + testWidgets( + 'Presses arrow left/right key since selection is not collapsed and forward', + (tester) async { + await _testPressArrowKeyInNotCollapsedSelection(tester, false); + }); +} + +Future _testPressArrowKeyInNotCollapsedSelection( + WidgetTester tester, bool isBackward) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + final start = Position(path: [0], offset: 5); + final end = Position(path: [1], offset: 10); + final selection = Selection( + start: isBackward ? start : end, + end: isBackward ? end : start, + ); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.arrowLeft); + expect(editor.documentSelection?.start, start); + + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.arrowRight); + expect(editor.documentSelection?.end, end); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart new file mode 100644 index 0000000000..1e7bf4e842 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart @@ -0,0 +1,351 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('delete_text_handler.dart', () { + testWidgets('Presses backspace key in empty document', (tester) async { + // Before + // + // [Empty Line] + // + // After + // + // [Empty Line] + // + final editor = tester.editor..insertEmptyTextNode(); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + // Pressing the backspace key continuously. + for (int i = 1; i <= 1; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + ); + expect(editor.documentLength, 1); + expect(editor.documentSelection, + Selection.single(path: [0], startOffset: 0)); + } + }); + }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // Welcome t Appflowy 😁 + // Welcome Appflowy 😁 + // + // Then + // Welcome to Appflowy 😁 + // + testWidgets( + 'Presses backspace key in non-empty document and selection is backward', + (tester) async { + await _deleteTextByBackspace(tester, true); + }); + testWidgets( + 'Presses backspace key in non-empty document and selection is forward', + (tester) async { + await _deleteTextByBackspace(tester, false); + }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // Welcome t Appflowy 😁 + // Welcome Appflowy 😁 + // + // Then + // Welcome to Appflowy 😁 + // + testWidgets( + 'Presses delete key in non-empty document and selection is backward', + (tester) async { + await _deleteTextByDelete(tester, true); + }); + testWidgets( + 'Presses delete key in non-empty document and selection is forward', + (tester) async { + await _deleteTextByDelete(tester, false); + }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁Welcome Appflowy 😁 + testWidgets( + 'Presses delete key in non-empty document and selection is at the end of the text', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + // delete 'o' + await editor.updateSelection( + Selection.single(path: [0], startOffset: text.length), + ); + await editor.pressLogicKey(LogicalKeyboardKey.delete); + + expect(editor.documentLength, 1); + expect(editor.documentSelection, + Selection.single(path: [0], startOffset: text.length)); + expect((editor.nodeAtPath([0]) as TextNode).toRawString(), text * 2); + }); + + // Before + // + // Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁Welcome to Appflowy 😁 + // + testWidgets('Presses backspace key in styled text (checkbox)', + (tester) async { + await _deleteStyledTextByBackspace(tester, StyleKey.checkbox); + }); + testWidgets('Presses backspace key in styled text (bulletedList)', + (tester) async { + await _deleteStyledTextByBackspace(tester, StyleKey.bulletedList); + }); + testWidgets('Presses backspace key in styled text (heading)', (tester) async { + await _deleteStyledTextByBackspace(tester, StyleKey.heading); + }); + testWidgets('Presses backspace key in styled text (quote)', (tester) async { + await _deleteStyledTextByBackspace(tester, StyleKey.quote); + }); + + // Before + // + // Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // + testWidgets('Presses delete key in styled text (checkbox)', (tester) async { + await _deleteStyledTextByDelete(tester, StyleKey.checkbox); + }); + testWidgets('Presses delete key in styled text (bulletedList)', + (tester) async { + await _deleteStyledTextByDelete(tester, StyleKey.bulletedList); + }); + testWidgets('Presses delete key in styled text (heading)', (tester) async { + await _deleteStyledTextByDelete(tester, StyleKey.heading); + }); + testWidgets('Presses delete key in styled text (quote)', (tester) async { + await _deleteStyledTextByDelete(tester, StyleKey.quote); + }); +} + +Future _deleteStyledTextByBackspace( + WidgetTester tester, String style) async { + const text = 'Welcome to Appflowy 😁'; + Attributes attributes = { + StyleKey.subtype: style, + }; + if (style == StyleKey.checkbox) { + attributes[StyleKey.checkbox] = true; + } else if (style == StyleKey.numberList) { + attributes[StyleKey.number] = 1; + } else if (style == StyleKey.heading) { + attributes[StyleKey.heading] = StyleKey.h1; + } + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text, attributes: attributes) + ..insertTextNode(text, attributes: attributes); + + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [2], startOffset: 0), + ); + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + ); + expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); + + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + ); + expect(editor.documentLength, 2); + expect(editor.documentSelection, + Selection.single(path: [1], startOffset: text.length)); + expect(editor.nodeAtPath([1])?.subtype, style); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text * 2); + + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + await editor.pressLogicKey( + LogicalKeyboardKey.backspace, + ); + expect(editor.documentLength, 2); + expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0)); + expect(editor.nodeAtPath([1])?.subtype, null); +} + +Future _deleteStyledTextByDelete( + WidgetTester tester, String style) async { + const text = 'Welcome to Appflowy 😁'; + Attributes attributes = { + StyleKey.subtype: style, + }; + if (style == StyleKey.checkbox) { + attributes[StyleKey.checkbox] = true; + } else if (style == StyleKey.numberList) { + attributes[StyleKey.number] = 1; + } else if (style == StyleKey.heading) { + attributes[StyleKey.heading] = StyleKey.h1; + } + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text, attributes: attributes) + ..insertTextNode(text, attributes: attributes); + + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + for (var i = 1; i < text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.delete, + ); + expect( + editor.documentSelection, Selection.single(path: [1], startOffset: 0)); + expect(editor.nodeAtPath([1])?.subtype, style); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), + text.safeSubString(i)); + } + + await editor.pressLogicKey( + LogicalKeyboardKey.delete, + ); + expect(editor.documentLength, 2); + expect(editor.documentSelection, Selection.single(path: [1], startOffset: 0)); + expect(editor.nodeAtPath([1])?.subtype, style); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text); +} + +Future _deleteTextByBackspace( + WidgetTester tester, bool isBackwardSelection) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + // delete 'o' + await editor.updateSelection( + Selection.single(path: [1], startOffset: 10), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + + expect(editor.documentLength, 3); + expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9)); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), + 'Welcome t Appflowy 😁'); + + // delete 'to ' + await editor.updateSelection( + Selection.single(path: [2], startOffset: 8, endOffset: 11), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 3); + expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8)); + expect((editor.nodeAtPath([2]) as TextNode).toRawString(), + 'Welcome Appflowy 😁'); + + // delete 'Appflowy 😁 + // Welcome t Appflowy 😁 + // Welcome ' + final start = Position(path: [0], offset: 11); + final end = Position(path: [2], offset: 8); + await editor.updateSelection(Selection( + start: isBackwardSelection ? start : end, + end: isBackwardSelection ? end : start)); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 1); + expect( + editor.documentSelection, Selection.single(path: [0], startOffset: 11)); + expect((editor.nodeAtPath([0]) as TextNode).toRawString(), + 'Welcome to Appflowy 😁'); +} + +Future _deleteTextByDelete( + WidgetTester tester, bool isBackwardSelection) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + // delete 'o' + await editor.updateSelection( + Selection.single(path: [1], startOffset: 9), + ); + await editor.pressLogicKey(LogicalKeyboardKey.delete); + + expect(editor.documentLength, 3); + expect(editor.documentSelection, Selection.single(path: [1], startOffset: 9)); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), + 'Welcome t Appflowy 😁'); + + // delete 'to ' + await editor.updateSelection( + Selection.single(path: [2], startOffset: 8, endOffset: 11), + ); + await editor.pressLogicKey(LogicalKeyboardKey.delete); + expect(editor.documentLength, 3); + expect(editor.documentSelection, Selection.single(path: [2], startOffset: 8)); + expect((editor.nodeAtPath([2]) as TextNode).toRawString(), + 'Welcome Appflowy 😁'); + + // delete 'Appflowy 😁 + // Welcome t Appflowy 😁 + // Welcome ' + final start = Position(path: [0], offset: 11); + final end = Position(path: [2], offset: 8); + await editor.updateSelection(Selection( + start: isBackwardSelection ? start : end, + end: isBackwardSelection ? end : start)); + await editor.pressLogicKey(LogicalKeyboardKey.delete); + expect(editor.documentLength, 1); + expect( + editor.documentSelection, Selection.single(path: [0], startOffset: 11)); + expect((editor.nodeAtPath([0]) as TextNode).toRawString(), + 'Welcome to Appflowy 😁'); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart new file mode 100644 index 0000000000..ee21dfa455 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart @@ -0,0 +1,198 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('enter_without_shift_in_text_node_handler.dart', () { + testWidgets('Presses enter key in empty document', (tester) async { + // Before + // + // [Empty Line] + // + // After + // + // [Empty Line] * 10 + // + final editor = tester.editor..insertEmptyTextNode(); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + // Pressing the enter key continuously. + for (int i = 1; i <= 10; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect(editor.documentLength, i + 1); + expect(editor.documentSelection, + Selection.single(path: [i], startOffset: 0)); + } + }); + + testWidgets('Presses enter key in non-empty document', (tester) async { + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // [Empty Line] + // Welcome to Appflowy 😁 + // + const text = 'Welcome to Appflowy 😁'; + var lines = 3; + + final editor = tester.editor; + for (var i = 1; i <= lines; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + + expect(editor.documentLength, lines); + + // Presses the enter key in last line. + await editor.updateSelection( + Selection.single(path: [lines - 1], startOffset: 0), + ); + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + lines += 1; + expect(editor.documentLength, lines); + expect(editor.documentSelection, + Selection.single(path: [lines - 1], startOffset: 0)); + var lastNode = editor.nodeAtPath([lines - 1]); + expect(lastNode != null, true); + expect(lastNode is TextNode, true); + lastNode = lastNode as TextNode; + expect(lastNode.delta.toRawString(), text); + expect((lastNode.previous as TextNode).delta.toRawString(), ''); + expect( + (lastNode.previous!.previous as TextNode).delta.toRawString(), text); + }); + + // Before + // + // Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // [Empty Line] + // [Style] Welcome to Appflowy 😁 + // [Style] Welcome to Appflowy 😁 + // [Style] + testWidgets('Presses enter key in bulleted list', (tester) async { + await _testStyleNeedToBeCopy(tester, StyleKey.bulletedList); + }); + testWidgets('Presses enter key in numbered list', (tester) async { + await _testStyleNeedToBeCopy(tester, StyleKey.numberList); + }); + testWidgets('Presses enter key in checkbox styled text', (tester) async { + await _testStyleNeedToBeCopy(tester, StyleKey.checkbox); + }); + testWidgets('Presses enter key in quoted text', (tester) async { + await _testStyleNeedToBeCopy(tester, StyleKey.quote); + }); + + testWidgets('Presses enter key in multiple selection from top to bottom', + (tester) async { + _testMultipleSelection(tester, true); + }); + + testWidgets('Presses enter key in multiple selection from bottom to top', + (tester) async { + _testMultipleSelection(tester, false); + }); + }); +} + +Future _testStyleNeedToBeCopy(WidgetTester tester, String style) async { + const text = 'Welcome to Appflowy 😁'; + Attributes attributes = { + StyleKey.subtype: style, + }; + if (style == StyleKey.checkbox) { + attributes[StyleKey.checkbox] = true; + } else if (style == StyleKey.numberList) { + attributes[StyleKey.number] = 1; + } + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text, attributes: attributes) + ..insertTextNode(text, attributes: attributes); + + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect(editor.documentSelection, Selection.single(path: [2], startOffset: 0)); + + await editor.updateSelection( + Selection.single(path: [3], startOffset: text.length), + ); + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); + expect(editor.nodeAtPath([4])?.subtype, style); + + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0)); + expect(editor.nodeAtPath([4])?.subtype, null); +} + +Future _testMultipleSelection( + WidgetTester tester, bool isBackwardSelection) async { + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome + // to Appflowy 😁 + // + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + var lines = 4; + + for (var i = 1; i <= lines; i++) { + editor.insertTextNode(text); + } + + await editor.startTesting(); + final start = Position(path: [0], offset: 7); + final end = Position(path: [3], offset: 8); + await editor.updateSelection(Selection( + start: isBackwardSelection ? start : end, + end: isBackwardSelection ? end : start, + )); + await editor.pressLogicKey( + LogicalKeyboardKey.enter, + ); + + expect(editor.documentLength, 2); + expect((editor.nodeAtPath([0]) as TextNode).toRawString(), 'Welcome'); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), 'to Appflowy 😁'); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/page_up_down_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/page_up_down_handler_test.dart new file mode 100644 index 0000000000..eb221ddf2c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/page_up_down_handler_test.dart @@ -0,0 +1,75 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('page_up_down_handler_test.dart', () { + testWidgets('Presses PageUp and pageDown key in large document', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 0; i < 1000; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final scrollService = editor.editorState.service.scrollService; + + expect(scrollService != null, true); + + if (scrollService == null) { + return; + } + + final page = scrollService.page; + final onePageHeight = scrollService.onePageHeight; + expect(page != null, true); + expect(onePageHeight != null, true); + + // Pressing the pageDown key continuously. + var currentOffsetY = 0.0; + for (int i = 1; i <= page!; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.pageDown, + ); + currentOffsetY += onePageHeight!; + final dy = scrollService.dy; + expect(dy, currentOffsetY); + } + + for (int i = 1; i <= 5; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.pageDown, + ); + final dy = scrollService.dy; + expect(dy == scrollService.maxScrollExtent, true); + } + + // Pressing the pageUp key continuously. + for (int i = page; i >= 1; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.pageUp, + ); + currentOffsetY -= onePageHeight!; + final dy = editor.editorState.service.scrollService?.dy; + expect(dy, currentOffsetY); + } + + for (int i = 1; i <= 5; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.pageUp, + ); + final dy = scrollService.dy; + expect(dy == scrollService.minScrollExtent, 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 new file mode 100644 index 0000000000..0f91e0b1d9 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/redo_undo_handler_test.dart @@ -0,0 +1,60 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('redo_undo_handler_test.dart', () { + // TODO: need to test more cases. + testWidgets('Redo, Undo for backspace key, and selection is downward', + (tester) async { + await _testBackspaceUndoRedo(tester, true); + }); + + testWidgets('Redo, Undo for backspace key, and selection is forward', + (tester) async { + await _testBackspaceUndoRedo(tester, false); + }); + }); +} + +Future _testBackspaceUndoRedo( + WidgetTester tester, bool isDownwardSelection) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + final start = Position(path: [0], offset: text.length); + final end = Position(path: [1], offset: text.length); + final selection = Selection( + start: isDownwardSelection ? start : end, + end: isDownwardSelection ? end : start, + ); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 2); + + await editor.pressLogicKey( + LogicalKeyboardKey.keyZ, + isMetaPressed: true, + ); + + expect(editor.documentLength, 3); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text); + expect(editor.documentSelection, selection); + + await editor.pressLogicKey( + LogicalKeyboardKey.keyZ, + isMetaPressed: true, + isShiftPressed: true, + ); + + expect(editor.documentLength, 2); +} 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 new file mode 100644 index 0000000000..6f4f9d0ce6 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/select_all_handler_test.dart @@ -0,0 +1,38 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('select_all_handler_test.dart', () { + testWidgets('Presses Command + A in small document', (tester) async { + await _testSelectAllHandler(tester, 10); + }); + + testWidgets('Presses Command + A in small document', (tester) async { + await _testSelectAllHandler(tester, 1000); + }); + }); +} + +Future _testSelectAllHandler(WidgetTester tester, int lines) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 0; i < lines; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + await editor.pressLogicKey(LogicalKeyboardKey.keyA, isMetaPressed: true); + + expect( + editor.documentSelection, + Selection( + start: Position(path: [0], offset: 0), + end: Position(path: [lines - 1], offset: text.length), + ), + ); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/slash_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/slash_handler_test.dart new file mode 100644 index 0000000000..4d5492e620 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/slash_handler_test.dart @@ -0,0 +1,47 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_item_widget.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('slash_handler.dart', () { + testWidgets('Presses / to trigger selection menu', (tester) async { + const text = 'Welcome to Appflowy 😁'; + const lines = 3; + final editor = tester.editor; + for (var i = 0; i < lines; i++) { + editor.insertTextNode(text); + } + await editor.startTesting(); + await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); + await editor.pressLogicKey(LogicalKeyboardKey.slash); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + expect( + find.byType(SelectionMenuWidget, skipOffstage: false), + findsOneWidget, + ); + + for (final item in defaultSelectionMenuItems) { + expect(find.byWidget(item.icon), findsOneWidget); + } + + await editor.updateSelection(Selection.single(path: [1], startOffset: 0)); + + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + expect( + find.byType(SelectionMenuItemWidget, skipOffstage: false), + findsNothing, + ); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart new file mode 100644 index 0000000000..2e93d4c5f5 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart @@ -0,0 +1,154 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('update_text_style_by_command_x_handler.dart', () { + testWidgets('Presses Command + B to update text style', (tester) async { + await _testUpdateTextStyleByCommandX( + tester, + StyleKey.bold, + true, + LogicalKeyboardKey.keyB, + ); + }); + testWidgets('Presses Command + I to update text style', (tester) async { + await _testUpdateTextStyleByCommandX( + tester, + StyleKey.italic, + true, + LogicalKeyboardKey.keyI, + ); + }); + testWidgets('Presses Command + U to update text style', (tester) async { + await _testUpdateTextStyleByCommandX( + tester, + StyleKey.underline, + true, + LogicalKeyboardKey.keyU, + ); + }); + testWidgets('Presses Command + Shift + S to update text style', + (tester) async { + await _testUpdateTextStyleByCommandX( + tester, + StyleKey.strikethrough, + true, + LogicalKeyboardKey.keyS, + ); + }); + + testWidgets('Presses Command + Shift + H to update text style', + (tester) async { + await _testUpdateTextStyleByCommandX( + tester, + StyleKey.backgroundColor, + defaultHighlightColor, + LogicalKeyboardKey.keyH, + ); + }); + }); +} + +Future _testUpdateTextStyleByCommandX( + WidgetTester tester, + String matchStyle, + dynamic matchValue, + LogicalKeyboardKey key, +) async { + final isShiftPressed = + key == LogicalKeyboardKey.keyS || key == LogicalKeyboardKey.keyH; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + var selection = + Selection.single(path: [1], startOffset: 2, endOffset: text.length - 2); + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: isShiftPressed, + isMetaPressed: true, + ); + var textNode = editor.nodeAtPath([1]) as TextNode; + expect( + textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true); + + selection = + Selection.single(path: [1], startOffset: 0, endOffset: text.length); + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: isShiftPressed, + isMetaPressed: true, + ); + textNode = editor.nodeAtPath([1]) as TextNode; + expect( + textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true); + + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: isShiftPressed, + isMetaPressed: true, + ); + textNode = editor.nodeAtPath([1]) as TextNode; + expect(textNode.allNotSatisfyInSelection(matchStyle, matchValue, selection), + true); + + selection = Selection( + start: Position(path: [0], offset: 0), + end: Position(path: [2], offset: text.length), + ); + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: isShiftPressed, + isMetaPressed: true, + ); + var nodes = editor.editorState.service.selectionService.currentSelectedNodes + .whereType(); + expect(nodes.length, 3); + for (final node in nodes) { + expect( + node.allSatisfyInSelection( + matchStyle, + matchValue, + Selection.single( + path: node.path, startOffset: 0, endOffset: text.length), + ), + true, + ); + } + + await editor.updateSelection(selection); + await editor.pressLogicKey( + key, + isShiftPressed: isShiftPressed, + isMetaPressed: true, + ); + nodes = editor.editorState.service.selectionService.currentSelectedNodes + .whereType(); + expect(nodes.length, 3); + for (final node in nodes) { + expect( + node.allNotSatisfyInSelection( + matchStyle, + matchValue, + Selection.single( + path: node.path, startOffset: 0, endOffset: text.length), + ), + true, + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart new file mode 100644 index 0000000000..21f9a5eb65 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/white_space_handler_test.dart @@ -0,0 +1,178 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('white_space_handler.dart', () { + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // [h1]Welcome to Appflowy 😁 + // [h2]Welcome to Appflowy 😁 + // [h3]Welcome to Appflowy 😁 + // [h4]Welcome to Appflowy 😁 + // [h5]Welcome to Appflowy 😁 + // [h6]Welcome to Appflowy 😁 + // + testWidgets('Presses whitespace key after #*', (tester) async { + const maxSignCount = 6; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 1; i <= maxSignCount; i++) { + editor.insertTextNode('${'#' * i}$text'); + } + await editor.startTesting(); + + for (var i = 1; i <= maxSignCount; i++) { + await editor.updateSelection( + Selection.single(path: [i - 1], startOffset: i), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + + final textNode = (editor.nodeAtPath([i - 1]) as TextNode); + + expect(textNode.subtype, StyleKey.heading); + // StyleKey.h1 ~ StyleKey.h6 + expect(textNode.attributes.heading, 'h$i'); + } + }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // [h1]##Welcome to Appflowy 😁 + // [h2]##Welcome to Appflowy 😁 + // [h3]##Welcome to Appflowy 😁 + // [h4]##Welcome to Appflowy 😁 + // [h5]##Welcome to Appflowy 😁 + // [h6]##Welcome to Appflowy 😁 + // + testWidgets('Presses whitespace key inside #*', (tester) async { + const maxSignCount = 6; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor; + for (var i = 1; i <= maxSignCount; i++) { + editor.insertTextNode('${'###' * i}$text'); + } + await editor.startTesting(); + + for (var i = 1; i <= maxSignCount; i++) { + await editor.updateSelection( + Selection.single(path: [i - 1], startOffset: i), + ); + await editor.pressLogicKey(LogicalKeyboardKey.space); + + final textNode = (editor.nodeAtPath([i - 1]) as TextNode); + + expect(textNode.subtype, StyleKey.heading); + // StyleKey.h1 ~ StyleKey.h6 + expect(textNode.attributes.heading, 'h$i'); + expect(textNode.toRawString().startsWith('##'), true); + } + }); + + // Before + // + // Welcome to Appflowy 😁 + // + // After + // [h1 ~ h6]##Welcome to Appflowy 😁 + // + testWidgets('Presses whitespace key in heading styled text', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + + await editor.startTesting(); + + const maxSignCount = 6; + for (var i = 1; i <= maxSignCount; i++) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final textNode = (editor.nodeAtPath([0]) as TextNode); + + await editor.insertText(textNode, '#' * i, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + + expect(textNode.subtype, StyleKey.heading); + // StyleKey.h2 ~ StyleKey.h6 + expect(textNode.attributes.heading, 'h$i'); + } + }); + + testWidgets('Presses whitespace key after (un)checkbox symbols', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (final symbol in unCheckboxListSymbols) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.insertText(textNode, symbol, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect(textNode.subtype, StyleKey.checkbox); + expect(textNode.attributes.check, false); + } + }); + + testWidgets('Presses whitespace key after checkbox symbols', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (final symbol in checkboxListSymbols) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.insertText(textNode, symbol, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect(textNode.subtype, StyleKey.checkbox); + expect(textNode.attributes.check, true); + } + }); + + testWidgets('Presses whitespace key after bulleted list', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + final textNode = editor.nodeAtPath([0]) as TextNode; + for (final symbol in bulletedListSymbols) { + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.insertText(textNode, symbol, 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect(textNode.subtype, StyleKey.bulletedList); + } + }); + }); +} diff --git a/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json b/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json deleted file mode 100644 index f27c363a13..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "example", - "cwd": "example", - "request": "launch", - "type": "dart" - }, - { - "name": "example (profile mode)", - "cwd": "example", - "request": "launch", - "type": "dart", - "flutterMode": "profile" - }, - { - "name": "example (release mode)", - "cwd": "example", - "request": "launch", - "type": "dart", - "flutterMode": "release" - }, - { - "name": "flowy_editor", - "request": "launch", - "type": "dart" - }, - { - "name": "flowy_editor (profile mode)", - "request": "launch", - "type": "dart", - "flutterMode": "profile" - }, - { - "name": "flowy_editor (release mode)", - "request": "launch", - "type": "dart", - "flutterMode": "release" - }, - ] -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_editor/CHANGELOG.md b/frontend/app_flowy/packages/flowy_editor/CHANGELOG.md deleted file mode 100644 index 41cc7d8192..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -## 0.0.1 - -* TODO: Describe initial release. diff --git a/frontend/app_flowy/packages/flowy_editor/LICENSE b/frontend/app_flowy/packages/flowy_editor/LICENSE deleted file mode 100644 index ba75c69f7f..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/LICENSE +++ /dev/null @@ -1 +0,0 @@ -TODO: Add your license here. diff --git a/frontend/app_flowy/packages/flowy_editor/README.md b/frontend/app_flowy/packages/flowy_editor/README.md deleted file mode 100644 index 8b55e735b5..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/README.md +++ /dev/null @@ -1,39 +0,0 @@ - - -TODO: Put a short description of the package here that helps potential users -know whether this package might be useful for them. - -## Features - -TODO: List what your package can do. Maybe include images, gifs, or videos. - -## Getting started - -TODO: List prerequisites and provide or point to information on how to -start using the package. - -## Usage - -TODO: Include short and useful examples for package users. Add longer examples -to `/example` folder. - -```dart -const like = 'sample'; -``` - -## Additional information - -TODO: Tell users more about the package: where to find more information, how to -contribute to the package, how to file issues, what response they can expect -from the package authors, and more. diff --git a/frontend/app_flowy/packages/flowy_editor/assets/document.json b/frontend/app_flowy/packages/flowy_editor/assets/document.json deleted file mode 100644 index fb3628de47..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/assets/document.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "document": { - "type": "root", - "children": [ - { - "type": "text", - "delta": [], - "attributes": { - "subtype": "with-heading" - } - }, - { - "type": "text", - "delta": [], - "attributes": { - "tag": "*" - }, - "children": [ - { - "type": "text", - "delta": [], - "attributes": { - "text-type": "heading2", - "check": true - } - }, - { - "type": "text", - "delta": [], - "attributes": { - "text-type": "checkbox", - "check": true - } - }, - { - "type": "text", - "delta": [], - "attributes": { - "tag": "**" - } - } - ] - }, - { - "type": "image", - "attributes": { - "url": "x.png" - } - }, - { - "type": "video", - "attributes": { - "url": "x.mp4" - } - } - ] - } -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json b/frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json deleted file mode 100644 index 091adbfb6b..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/.vscode/launch.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "example", - "request": "launch", - "type": "dart" - }, - { - "name": "example (profile mode)", - "request": "launch", - "type": "dart", - "flutterMode": "profile" - }, - { - "name": "example (release mode)", - "request": "launch", - "type": "dart", - "flutterMode": "release" - } - ] -} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json deleted file mode 100644 index 307b4bf92f..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "document": { - "type": "editor", - "attributes": {}, - "children": [ - { - "type": "image", - "attributes": { - "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "👋 Welcome to AppFlowy!", - "attributes": { - "href": "https://www.appflowy.io/", - "heading": "h1" - } - } - ], - "attributes": { - "heading": "h1" - } - }, - { - "type": "text", - "delta": [ - { "insert": "Here are the basics", "attributes": { "heading": "h2" } } - ], - "attributes": { - "heading": "h2" - } - }, - { - "type": "text", - "delta": [{ "insert": "Click anywhere and just start typing." }], - "attributes": { - "list": "todo", - "todo": false - } - }, - { - "type": "text", - "delta": [{ "insert": "Click anywhere and just start typing." }], - "attributes": { - "list": "bullet" - } - }, - { - "type": "text", - "delta": [{ "insert": "Click anywhere and just start typing." }], - "attributes": { - "list": "bullet" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Highlight", - "attributes": { "highlight": "0xFFFFFF00" } - }, - { "insert": " Click anywhere and just start typing" }, - { "insert": " any text, and use the menu at the bottom to " }, - { "insert": "style", "attributes": { "italic": true } }, - { "insert": " your ", "attributes": { "bold": true } }, - { "insert": "writing", "attributes": { "underline": true } }, - { - "insert": " however you like.", - "attributes": { "strikethrough": true } - } - ], - "attributes": { - "checkbox": false - } - }, - { - "type": "text", - "delta": [ - { "insert": "Have a question? ", "attributes": { "heading": "h2" } } - ], - "attributes": { - "heading": "h2" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "1. Click the '?' at the bottom right for help and support." - } - ], - "attributes": { - "quotes": true - } - }, - { - "type": "text", - "delta": [ - { - "insert": "2. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "3. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "4. Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "5. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "6. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "7. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "8. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "9. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "10. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "11. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - } - ] - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock deleted file mode 100644 index f86e34c312..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock +++ /dev/null @@ -1,551 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "3.3.1" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.2" - csslib: - dependency: transitive - description: - name: csslib - url: "https://pub.dartlang.org" - source: hosted - version: "0.17.2" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - ffi: - dependency: transitive - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - flowy_editor: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_inappwebview: - dependency: "direct main" - description: - name: flutter_inappwebview - url: "https://pub.dartlang.org" - source: hosted - version: "5.4.3+7" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - flutter_svg: - dependency: transitive - description: - name: flutter_svg - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1+1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - get: - dependency: transitive - description: - name: get - url: "https://pub.dartlang.org" - source: hosted - version: "4.6.5" - html: - dependency: transitive - description: - name: html - url: "https://pub.dartlang.org" - source: hosted - version: "0.15.0" - http: - dependency: transitive - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.5" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.6.0" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - lottie: - dependency: transitive - description: - name: lottie - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.4" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - nested: - dependency: transitive - description: - name: nested - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - path_drawing: - dependency: transitive - description: - name: path_drawing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - path_parsing: - dependency: transitive - description: - name: path_parsing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - pod_player: - dependency: "direct main" - description: - name: pod_player - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.8" - provider: - dependency: "direct main" - description: - name: provider - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.3" - rich_clipboard: - dependency: transitive - description: - name: rich_clipboard - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_android: - dependency: transitive - description: - name: rich_clipboard_android - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_ios: - dependency: transitive - description: - name: rich_clipboard_ios - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_linux: - dependency: transitive - description: - name: rich_clipboard_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_macos: - dependency: transitive - description: - name: rich_clipboard_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - rich_clipboard_platform_interface: - dependency: transitive - description: - name: rich_clipboard_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_web: - dependency: transitive - description: - name: rich_clipboard_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - rich_clipboard_windows: - dependency: transitive - description: - name: rich_clipboard_windows - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.9" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - universal_html: - dependency: transitive - description: - name: universal_html - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - universal_io: - dependency: transitive - description: - name: universal_io - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.4" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.5" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.17" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.17" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.12" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - video_player: - dependency: "direct main" - description: - name: video_player - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.5" - video_player_android: - dependency: transitive - description: - name: video_player_android - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.8" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.5" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.3" - video_player_web: - dependency: transitive - description: - name: video_player_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.12" - wakelock: - dependency: transitive - description: - name: wakelock - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.2" - wakelock_macos: - dependency: transitive - description: - name: wakelock_macos - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.0" - wakelock_platform_interface: - dependency: transitive - description: - name: wakelock_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - wakelock_web: - dependency: transitive - description: - name: wakelock_web - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.0" - wakelock_windows: - dependency: transitive - description: - name: wakelock_windows - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - win32: - dependency: transitive - description: - name: win32 - url: "https://pub.dartlang.org" - source: hosted - version: "2.6.1" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" - youtube_explode_dart: - dependency: transitive - description: - name: youtube_explode_dart - url: "https://pub.dartlang.org" - source: hosted - version: "1.12.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" diff --git a/frontend/app_flowy/packages/flowy_editor/example/test/widget_test.dart b/frontend/app_flowy/packages/flowy_editor/example/test/widget_test.dart deleted file mode 100644 index 092d222f7e..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart deleted file mode 100644 index 29e90784ae..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/document/text_delta.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; - -extension TextNodeExtension on TextNode { - bool allSatisfyBoldInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.bold, selection); - - bool allSatisfyItalicInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.italic, selection); - - bool allSatisfyUnderlineInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.underline, selection); - - bool allSatisfyStrikethroughInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.strikethrough, selection); - - bool allSatisfyInSelection(String styleKey, Selection selection) { - final ops = delta.operations.whereType(); - var start = 0; - for (final op in ops) { - if (start >= selection.end.offset) { - break; - } - final length = op.length; - if (start < selection.end.offset && - start + length > selection.start.offset) { - if (op.attributes == null || - !op.attributes!.containsKey(styleKey) || - op.attributes![styleKey] == false) { - return false; - } - } - start += length; - } - return true; - } -} - -extension TextNodesExtension on List { - bool allSatisfyBoldInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.bold, selection); - - bool allSatisfyItalicInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.italic, selection); - - bool allSatisfyUnderlineInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.underline, selection); - - bool allSatisfyStrikethroughInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.strikethrough, selection); - - bool allSatisfyInSelection(String styleKey, Selection selection) { - if (isEmpty) { - return false; - } - if (length == 1) { - return first.allSatisfyInSelection(styleKey, selection); - } else { - for (var i = 0; i < length; i++) { - final node = this[i]; - final Selection newSelection; - if (i == 0 && pathEquals(node.path, selection.start.path)) { - newSelection = selection.copyWith( - end: Position(path: node.path, offset: node.toRawString().length), - ); - } else if (i == length - 1 && - pathEquals(node.path, selection.end.path)) { - newSelection = selection.copyWith( - start: Position(path: node.path, offset: 0), - ); - } else { - newSelection = Selection( - start: Position(path: node.path, offset: 0), - end: Position(path: node.path, offset: node.toRawString().length), - ); - } - if (!node.allSatisfyInSelection(styleKey, newSelection)) { - return false; - } - } - return true; - } - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart deleted file mode 100644 index c3e15959a6..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ /dev/null @@ -1,15 +0,0 @@ -library flowy_editor; - -export 'package:flowy_editor/document/state_tree.dart'; -export 'package:flowy_editor/document/node.dart'; -export 'package:flowy_editor/document/path.dart'; -export 'package:flowy_editor/document/text_delta.dart'; -export 'package:flowy_editor/render/selection/selectable.dart'; -export 'package:flowy_editor/operation/transaction.dart'; -export 'package:flowy_editor/operation/transaction_builder.dart'; -export 'package:flowy_editor/operation/operation.dart'; -export 'package:flowy_editor/editor_state.dart'; -export 'package:flowy_editor/service/editor_service.dart'; -export 'package:flowy_editor/document/selection.dart'; -export 'package:flowy_editor/document/position.dart'; -export 'package:flowy_editor/service/render_plugin_service.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart deleted file mode 100644 index 708e47cb85..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'dart:collection'; - -import 'package:flowy_editor/document/attributes.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/text_delta.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:html/parser.dart' show parse; -import 'package:html/dom.dart' as html; - -const String tagH1 = "h1"; -const String tagH2 = "h2"; -const String tagH3 = "h3"; -const String tagUnorderedList = "ul"; -const String tagList = "li"; -const String tagParagraph = "p"; -const String tagImage = "img"; -const String tagAnchor = "a"; -const String tagBold = "b"; -const String tagStrong = "strong"; -const String tagSpan = "span"; -const String tagCode = "code"; - -class HTMLConverter { - final html.Document _document; - bool _inParagraph = false; - - HTMLConverter(String htmlString) : _document = parse(htmlString); - - List toNodes() { - final result = []; - - final childNodes = _document.body?.nodes.toList() ?? []; - _handleContainer(result, childNodes); - - return result; - } - - _handleContainer(List nodes, List childNodes) { - final delta = Delta(); - for (final child in childNodes) { - if (child is html.Element) { - if (child.localName == tagAnchor || - child.localName == tagSpan || - child.localName == tagCode || - child.localName == tagStrong) { - _handleRichTextElement(delta, child); - } else if (child.localName == tagBold) { - // Google docs wraps the the content inside the tag. - // It's strange - if (!_inParagraph) { - _handleBTag(nodes, child); - } else { - _handleRichText(nodes, child); - } - } else { - _handleElement(nodes, child); - } - } else { - delta.insert(child.text ?? ""); - } - } - if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta)); - } - } - - _handleBTag(List nodes, html.Element element) { - final childNodes = element.nodes; - _handleContainer(nodes, childNodes); - } - - _handleElement(List nodes, html.Element element, - [Map? attributes]) { - if (element.localName == tagH1) { - _handleHeadingElement(nodes, element, tagH1); - } else if (element.localName == tagH2) { - _handleHeadingElement(nodes, element, tagH2); - } else if (element.localName == tagH3) { - _handleHeadingElement(nodes, element, tagH3); - } else if (element.localName == tagUnorderedList) { - _handleUnorderedList(nodes, element); - } else if (element.localName == tagList) { - _handleListElement(nodes, element); - } else if (element.localName == tagParagraph) { - _handleParagraph(nodes, element, attributes); - } else { - final delta = Delta(); - delta.insert(element.text); - if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta)); - } - } - } - - _handleParagraph(List nodes, html.Element element, - [Map? attributes]) { - _inParagraph = true; - _handleRichText(nodes, element, attributes); - _inParagraph = false; - } - - Attributes? _getDeltaAttributesFromHtmlAttributes( - LinkedHashMap htmlAttributes) { - final attrs = {}; - final styleString = htmlAttributes["style"]; - if (styleString != null) { - final entries = styleString.split(";"); - for (final entry in entries) { - final tuples = entry.split(":"); - if (tuples.length < 2) { - continue; - } - if (tuples[0] == "font-weight") { - int? weight = int.tryParse(tuples[1]); - if (weight != null && weight > 500) { - attrs["bold"] = true; - } - } - } - } - - return attrs.isEmpty ? null : attrs; - } - - _handleRichTextElement(Delta delta, html.Element element) { - if (element.localName == tagSpan) { - delta.insert(element.text, - _getDeltaAttributesFromHtmlAttributes(element.attributes)); - } else if (element.localName == tagAnchor) { - final hyperLink = element.attributes["href"]; - Map? attributes; - if (hyperLink != null) { - attributes = {"href": hyperLink}; - } - delta.insert(element.text, attributes); - } else if (element.localName == tagStrong || element.localName == tagBold) { - delta.insert(element.text, {"bold": true}); - } else { - delta.insert(element.text); - } - } - - _handleRichText(List nodes, html.Element element, - [Map? attributes]) { - final image = element.querySelector(tagImage); - if (image != null) { - _handleImage(nodes, image); - return; - } - - var delta = Delta(); - - for (final child in element.nodes.toList()) { - if (child is html.Element) { - _handleRichTextElement(delta, child); - } else { - delta.insert(child.text ?? ""); - } - } - - if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta, attributes: attributes)); - } - } - - _handleImage(List nodes, html.Element element) { - final src = element.attributes["src"]; - final attributes = {}; - if (src != null) { - attributes["image_src"] = src; - } - debugPrint("insert image: $src"); - nodes.add( - Node(type: "image", attributes: attributes, children: LinkedList())); - } - - _handleUnorderedList(List nodes, html.Element element) { - element.children.forEach((child) { - _handleListElement(nodes, child); - }); - } - - _handleHeadingElement( - List nodes, - html.Element element, - String headingStyle, - ) { - final delta = Delta(); - delta.insert(element.text); - if (delta.operations.isNotEmpty) { - nodes.add(TextNode( - type: "text", - attributes: {"subtype": "heading", "heading": headingStyle}, - delta: delta)); - } - } - - _handleListElement(List nodes, html.Element element) { - final childNodes = element.nodes.toList(); - for (final child in childNodes) { - if (child is html.Element) { - _handleElement(nodes, child, {"subtype": "bulleted-list"}); - } - } - } -} - -html.Element deltaToHtml(Delta delta, [String? subType]) { - final childNodes = []; - String tagName = tagParagraph; - - if (subType == "bulleted-list") { - tagName = tagList; - } - - for (final op in delta.operations) { - if (op is TextInsert) { - final attributes = op.attributes; - if (attributes != null && attributes["bold"] == true) { - final strong = html.Element.tag("strong"); - strong.append(html.Text(op.content)); - childNodes.add(strong); - } else { - childNodes.add(html.Text(op.content)); - } - } - } - - if (tagName != tagParagraph) { - final p = html.Element.tag(tagParagraph); - for (final node in childNodes) { - p.append(node); - } - final result = html.Element.tag("li"); - result.append(p); - return result; - } else { - final p = html.Element.tag(tagName); - for (final node in childNodes) { - p.append(node); - } - return p; - } -} - -String stringify(html.Node node) { - if (node is html.Element) { - String result = '<${node.localName}>'; - - for (final node in node.nodes) { - result += stringify(node); - } - - return result += ''; - } - - if (node is html.Text) { - return node.text; - } - - return ""; -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart deleted file mode 100644 index 75cde60e39..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/render/rich_text/default_selectable.dart'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; - -class BulletedListTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return BulletedListTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return true; - }); -} - -class BulletedListTextNodeWidget extends StatefulWidget { - const BulletedListTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - final TextNode textNode; - final EditorState editorState; - - @override - State createState() => - _BulletedListTextNodeWidgetState(); -} - -// customize - -class _BulletedListTextNodeWidgetState extends State - with Selectable, DefaultSelectable { - final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text'); - final leftPadding = 20.0; - - @override - Selectable get forward => - _richTextKey.currentState as Selectable; - - @override - Offset get baseOffset { - return Offset(leftPadding, 0); - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - FlowySvg( - size: Size.square(leftPadding), - name: 'point', - ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart deleted file mode 100644 index e9fed70c54..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/render/rich_text/default_selectable.dart'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; - -class NumberListTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return NumberListTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.attributes.number != null; - }); -} - -class NumberListTextNodeWidget extends StatefulWidget { - const NumberListTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - final TextNode textNode; - final EditorState editorState; - - @override - State createState() => - _NumberListTextNodeWidgetState(); -} - -// customize - -class _NumberListTextNodeWidgetState extends State - with Selectable, DefaultSelectable { - final _richTextKey = GlobalKey(debugLabel: 'number_list_text'); - final leftPadding = 20.0; - - @override - Selectable get forward => - _richTextKey.currentState as Selectable; - - @override - Offset get baseOffset { - return Offset(leftPadding, 0); - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - FlowySvg( - size: Size.square(leftPadding), - number: widget.textNode.attributes.number, - ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart deleted file mode 100644 index 00bb393652..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/render/rich_text/default_selectable.dart'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flutter/material.dart'; - -class QuotedTextNodeWidgetBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return QuotedTextNodeWidget( - key: context.node.key, - textNode: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return true; - }); -} - -class QuotedTextNodeWidget extends StatefulWidget { - const QuotedTextNodeWidget({ - Key? key, - required this.textNode, - required this.editorState, - }) : super(key: key); - - final TextNode textNode; - final EditorState editorState; - - @override - State createState() => _QuotedTextNodeWidgetState(); -} - -// customize - -class _QuotedTextNodeWidgetState extends State - with Selectable, DefaultSelectable { - final _richTextKey = GlobalKey(debugLabel: 'quoted_text'); - final leftPadding = 20.0; - - @override - Selectable get forward => - _richTextKey.currentState as Selectable; - - @override - Offset get baseOffset { - return Offset(leftPadding, 0); - } - - @override - Widget build(BuildContext context) { - return Row( - children: [ - FlowySvg( - size: Size( - leftPadding, - _quoteHeight, - ), - name: 'quote', - ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Quote', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ); - } - - double get _quoteHeight { - final lines = - widget.textNode.toRawString().characters.where((c) => c == '\n').length; - return (lines + 1) * leftPadding; - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart deleted file mode 100644 index 4423e674dc..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/toolbar_widget.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flutter/material.dart'; - -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart'; - -typedef ToolbarEventHandler = void Function(EditorState editorState); - -typedef ToolbarEventHandlers = Map; - -ToolbarEventHandlers defaultToolbarEventHandlers = { - 'bold': (editorState) => formatBold(editorState), - 'italic': (editorState) => formatItalic(editorState), - 'strikethrough': (editorState) => formatStrikethrough(editorState), - 'underline': (editorState) => formatUnderline(editorState), - 'quote': (editorState) => formatQuote(editorState), - 'number_list': (editorState) {}, - 'bulleted_list': (editorState) => formatBulletedList(editorState), - 'Text': (editorState) => formatText(editorState), - 'H1': (editorState) => formatHeading(editorState, StyleKey.h1), - 'H2': (editorState) => formatHeading(editorState, StyleKey.h2), - 'H3': (editorState) => formatHeading(editorState, StyleKey.h3), -}; - -List defaultListToolbarEventNames = [ - 'Text', - 'H1', - 'H2', - 'H3', - // 'B-List', - // 'N-List', -]; - -class ToolbarWidget extends StatefulWidget { - const ToolbarWidget({ - Key? key, - required this.editorState, - required this.layerLink, - required this.offset, - required this.handlers, - }) : super(key: key); - - final EditorState editorState; - final LayerLink layerLink; - final Offset offset; - final ToolbarEventHandlers handlers; - - @override - State createState() => _ToolbarWidgetState(); -} - -class _ToolbarWidgetState extends State { - final GlobalKey _listToolbarKey = GlobalKey(); - - final toolbarHeight = 32.0; - final topPadding = 5.0; - - final listToolbarWidth = 60.0; - final listToolbarHeight = 120.0; - - final cornerRadius = 8.0; - - OverlayEntry? _listToolbarOverlay; - - @override - void initState() { - super.initState(); - - widget.editorState.service.selectionService.currentSelection - .addListener(_onSelectionChange); - } - - @override - void dispose() { - widget.editorState.service.selectionService.currentSelection - .removeListener(_onSelectionChange); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Positioned( - top: widget.offset.dx, - left: widget.offset.dy, - child: CompositedTransformFollower( - link: widget.layerLink, - showWhenUnlinked: true, - offset: widget.offset, - child: _buildToolbar(context), - ), - ); - } - - Widget _buildToolbar(BuildContext context) { - return Material( - borderRadius: BorderRadius.circular(cornerRadius), - color: const Color(0xFF333333), - child: SizedBox( - height: toolbarHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _listToolbar(context), - _centerToolbarIcon('divider', width: 10), - _centerToolbarIcon('bold'), - _centerToolbarIcon('italic'), - _centerToolbarIcon('strikethrough'), - _centerToolbarIcon('underline'), - _centerToolbarIcon('divider', width: 10), - _centerToolbarIcon('quote'), - _centerToolbarIcon('number_list'), - _centerToolbarIcon('bulleted_list'), - ], - ), - ), - ); - } - - Widget _listToolbar(BuildContext context) { - return _centerToolbarIcon( - 'quote', - key: _listToolbarKey, - width: listToolbarWidth, - onTap: () => _onTapListToolbar(context), - ); - } - - Widget _centerToolbarIcon(String name, - {Key? key, double? width, VoidCallback? onTap}) { - return Tooltip( - key: key, - preferBelow: false, - message: name, - child: GestureDetector( - onTap: onTap ?? () => _onTap(name), - child: SizedBox.fromSize( - size: width != null - ? Size(width, toolbarHeight) - : Size.square(toolbarHeight), - child: Center( - child: FlowySvg( - name: 'toolbar/$name', - ), - ), - ), - ), - ); - } - - void _onTapListToolbar(BuildContext context) { - // TODO: implement more detailed UI. - final items = defaultListToolbarEventNames; - final renderBox = - _listToolbarKey.currentContext?.findRenderObject() as RenderBox; - final offset = renderBox - .localToGlobal(Offset.zero) - .translate(0, toolbarHeight - cornerRadius); - final rect = offset & Size(listToolbarWidth, listToolbarHeight); - - _listToolbarOverlay?.remove(); - _listToolbarOverlay = OverlayEntry(builder: (context) { - return Positioned.fromRect( - rect: rect, - child: Material( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(cornerRadius), - bottomRight: Radius.circular(cornerRadius), - ), - color: const Color(0xFF333333), - child: SingleChildScrollView( - child: ListView.builder( - itemExtent: toolbarHeight, - padding: const EdgeInsets.only(bottom: 10.0), - shrinkWrap: true, - itemCount: items.length, - itemBuilder: ((context, index) { - return ListTile( - contentPadding: const EdgeInsets.only( - left: 3.0, - right: 3.0, - ), - minVerticalPadding: 0.0, - title: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - items[index], - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - ), - ), - ), - onTap: () { - _onTap(items[index]); - }, - ); - }), - ), - ), - ), - ); - }); - // TODO: disable scrolling. - Overlay.of(context)?.insert(_listToolbarOverlay!); - } - - void _onTap(String eventName) { - if (defaultToolbarEventHandlers.containsKey(eventName)) { - defaultToolbarEventHandlers[eventName]!(widget.editorState); - return; - } - assert(false, 'Could not find the event handler for $eventName'); - } - - void _onSelectionChange() { - _listToolbarOverlay?.remove(); - _listToolbarOverlay = null; - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart deleted file mode 100644 index 46c7d3278f..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:flowy_editor/document/attributes.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/extensions/text_node_extensions.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; - -void formatText(EditorState editorState) { - formatTextNodes(editorState, {}); -} - -void formatHeading(EditorState editorState, String heading) { - formatTextNodes(editorState, { - StyleKey.subtype: StyleKey.heading, - StyleKey.heading: heading, - }); -} - -void formatQuote(EditorState editorState) { - formatTextNodes(editorState, { - StyleKey.subtype: StyleKey.quote, - }); -} - -void formatCheckbox(EditorState editorState) { - formatTextNodes(editorState, { - StyleKey.subtype: StyleKey.checkbox, - StyleKey.checkbox: false, - }); -} - -void formatBulletedList(EditorState editorState) { - formatTextNodes(editorState, { - StyleKey.subtype: StyleKey.bulletedList, - }); -} - -bool formatTextNodes(EditorState editorState, Attributes attributes) { - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(); - - if (textNodes.isEmpty) { - return false; - } - - final builder = TransactionBuilder(editorState); - - for (final textNode in textNodes) { - builder - ..updateNode( - textNode, - Attributes.fromIterable( - StyleKey.globalStyleKeys, - value: (_) => null, - )..addAll(attributes), - ) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: textNode.toRawString().length, - ), - ); - } - - builder.commit(); - return true; -} - -bool formatBold(EditorState editorState) { - return formatRichTextPartialStyle(editorState, StyleKey.bold); -} - -bool formatItalic(EditorState editorState) { - return formatRichTextPartialStyle(editorState, StyleKey.italic); -} - -bool formatUnderline(EditorState editorState) { - return formatRichTextPartialStyle(editorState, StyleKey.underline); -} - -bool formatStrikethrough(EditorState editorState) { - return formatRichTextPartialStyle(editorState, StyleKey.strikethrough); -} - -bool formatRichTextPartialStyle(EditorState editorState, String styleKey) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - - if (selection == null || textNodes.isEmpty) { - return false; - } - - bool value = !textNodes.allSatisfyInSelection(styleKey, selection); - Attributes attributes = { - styleKey: value, - }; - if (styleKey == StyleKey.underline && value) { - attributes[StyleKey.strikethrough] = null; - } else if (styleKey == StyleKey.strikethrough && value) { - attributes[StyleKey.underline] = null; - } - - return formatRichTextStyle(editorState, attributes); -} - -bool formatRichTextStyle(EditorState editorState, Attributes attributes) { - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(); - - if (selection == null || textNodes.isEmpty) { - return false; - } - - final builder = TransactionBuilder(editorState); - - // 1. All nodes are text nodes. - // 2. The first node is not TextNode. - // 3. The last node is not TextNode. - if (nodes.length == textNodes.length && textNodes.length == 1) { - builder.formatText( - textNodes.first, - selection.start.offset, - selection.end.offset - selection.start.offset, - attributes, - ); - } else { - for (var i = 0; i < textNodes.length; i++) { - final textNode = textNodes[i]; - if (i == 0 && textNode == nodes.first) { - builder.formatText( - textNode, - selection.start.offset, - textNode.toRawString().length - selection.start.offset, - attributes, - ); - } else if (i == textNodes.length - 1 && textNode == nodes.last) { - builder.formatText( - textNode, - 0, - selection.end.offset, - attributes, - ); - } else { - builder.formatText( - textNode, - 0, - textNode.toRawString().length, - attributes, - ); - } - } - } - - builder.commit(); - - return true; -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart deleted file mode 100644 index 78f7bb76fa..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/render/editor/editor_entry.dart'; -import 'package:flowy_editor/render/rich_text/bulleted_list_text.dart'; -import 'package:flowy_editor/render/rich_text/checkbox_text.dart'; -import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/rich_text/heading_text.dart'; -import 'package:flowy_editor/render/rich_text/number_list_text.dart'; -import 'package:flowy_editor/render/rich_text/quoted_text.dart'; -import 'package:flowy_editor/service/input_service.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/copy_paste_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/slash_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/whitespace_handler.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flowy_editor/service/scroll_service.dart'; -import 'package:flowy_editor/service/selection_service.dart'; -import 'package:flowy_editor/service/toolbar_service.dart'; - -NodeWidgetBuilders defaultBuilders = { - 'editor': EditorEntryWidgetBuilder(), - 'text': RichTextNodeWidgetBuilder(), - 'text/checkbox': CheckboxNodeWidgetBuilder(), - 'text/heading': HeadingTextNodeWidgetBuilder(), - 'text/bulleted-list': BulletedListTextNodeWidgetBuilder(), - 'text/number-list': NumberListTextNodeWidgetBuilder(), - 'text/quote': QuotedTextNodeWidgetBuilder(), -}; - -List defaultKeyEventHandler = [ - deleteTextHandler, - slashShortcutHandler, - flowyDeleteNodesHandler, - arrowKeysHandler, - copyPasteKeysHandler, - enterWithoutShiftInTextNodesHandler, - updateTextStyleByCommandXHandler, - whiteSpaceHandler, -]; - -class FlowyEditor extends StatefulWidget { - const FlowyEditor({ - Key? key, - required this.editorState, - this.customBuilders = const {}, - this.keyEventHandlers = const [], - }) : super(key: key); - - final EditorState editorState; - - /// Render plugins. - final NodeWidgetBuilders customBuilders; - - /// Keyboard event handlers. - final List keyEventHandlers; - - @override - State createState() => _FlowyEditorState(); -} - -class _FlowyEditorState extends State { - EditorState get editorState => widget.editorState; - - @override - void initState() { - super.initState(); - - editorState.service.renderPluginService = _createRenderPlugin(); - } - - @override - void didUpdateWidget(covariant FlowyEditor oldWidget) { - super.didUpdateWidget(oldWidget); - - if (editorState.service != oldWidget.editorState.service) { - editorState.service.renderPluginService = _createRenderPlugin(); - } - } - - @override - Widget build(BuildContext context) { - return FlowyScroll( - key: editorState.service.scrollServiceKey, - child: FlowySelection( - key: editorState.service.selectionServiceKey, - editorState: editorState, - child: FlowyInput( - key: editorState.service.inputServiceKey, - editorState: editorState, - child: FlowyKeyboard( - key: editorState.service.keyboardServiceKey, - handlers: [ - ...defaultKeyEventHandler, - ...widget.keyEventHandlers, - ], - editorState: editorState, - child: FlowyToolbar( - key: editorState.service.toolbarServiceKey, - editorState: editorState, - child: - editorState.service.renderPluginService.buildPluginWidget( - NodeWidgetContext( - context: context, - node: editorState.document.root, - editorState: editorState, - ), - ), - ), - ), - ), - )); - } - - FlowyRenderPlugin _createRenderPlugin() => FlowyRenderPlugin( - editorState: editorState, - builders: { - ...defaultBuilders, - ...widget.customBuilders, - }, - ); -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_nodes_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_nodes_handler.dart deleted file mode 100644 index dda52612e9..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_nodes_handler.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flutter/material.dart'; - -FlowyKeyEventHandler flowyDeleteNodesHandler = (editorState, event) { - // Handle delete nodes. - final nodes = editorState.selectedNodes; - if (nodes.length <= 1) { - return KeyEventResult.ignored; - } - - debugPrint('delete nodes = $nodes'); - - nodes - .fold( - TransactionBuilder(editorState), - (previousValue, node) => previousValue..deleteNode(node), - ) - .commit(); - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_text_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_text_handler.dart deleted file mode 100644 index e44f0002a0..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_text_handler.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:flowy_editor/flowy_editor.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; - -// Handle delete text. -FlowyKeyEventHandler deleteTextHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.backspace) { - return KeyEventResult.ignored; - } - - final selection = editorState.service.selectionService.currentSelection.value; - if (selection == null) { - return KeyEventResult.ignored; - } - - final nodes = editorState.service.selectionService.currentSelectedNodes; - // make sure all nodes is [TextNode]. - final textNodes = nodes.whereType().toList(); - if (textNodes.length != nodes.length) { - return KeyEventResult.ignored; - } - - TransactionBuilder transactionBuilder = TransactionBuilder(editorState); - if (textNodes.length == 1) { - final textNode = textNodes.first; - final index = selection.start.offset - 1; - if (index < 0) { - // 1. style - if (textNode.subtype != null) { - transactionBuilder - ..updateNode(textNode, { - 'subtype': null, - }) - ..afterSelection = Selection.collapsed( - Position( - path: textNode.path, - offset: 0, - ), - ); - } else { - // 2. non-style - // find previous text node. - while (textNode.previous != null) { - if (textNode.previous is TextNode) { - final previous = textNode.previous as TextNode; - transactionBuilder - ..mergeText(previous, textNode) - ..deleteNode(textNode) - ..afterSelection = Selection.collapsed( - Position( - path: previous.path, - offset: previous.toRawString().length, - ), - ); - break; - } - } - } - } else { - if (selection.isCollapsed) { - transactionBuilder.deleteText( - textNode, - selection.start.offset - 1, - 1, - ); - } else { - transactionBuilder.deleteText( - textNode, - selection.start.offset, - selection.end.offset - selection.start.offset, - ); - } - } - } else { - final first = textNodes.first; - final last = textNodes.last; - var content = textNodes.last.toRawString(); - content = content.substring(selection.end.offset, content.length); - // Merge the fist and the last text node content, - // and delete the all nodes expect for the first. - transactionBuilder - ..deleteNodes(textNodes.sublist(1)) - ..mergeText( - first, - last, - firstOffset: selection.start.offset, - secondOffset: selection.end.offset, - ); - } - - transactionBuilder.commit(); - - return KeyEventResult.handled; -}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart deleted file mode 100644 index 4d77ef1ebc..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/slash_handler.dart +++ /dev/null @@ -1,336 +0,0 @@ -import 'dart:math'; - -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/infra/flowy_svg.dart'; -import 'package:flowy_editor/operation/transaction_builder.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/extensions/node_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -final List _popupListItems = [ - PopupListItem( - text: 'Text', - icon: _popupListIcon('text'), - handler: (editorState) => formatText(editorState), - ), - PopupListItem( - text: 'Heading 1', - icon: _popupListIcon('h1'), - handler: (editorState) => formatHeading(editorState, StyleKey.h1), - ), - PopupListItem( - text: 'Heading 2', - icon: _popupListIcon('h2'), - handler: (editorState) => formatHeading(editorState, StyleKey.h2), - ), - PopupListItem( - text: 'Heading 3', - icon: _popupListIcon('h3'), - handler: (editorState) => formatHeading(editorState, StyleKey.h3), - ), - PopupListItem( - text: 'Bullets', - icon: _popupListIcon('bullets'), - handler: (editorState) => formatBulletedList(editorState), - ), - PopupListItem( - text: 'Numbered list', - icon: _popupListIcon('number'), - handler: (editorState) => debugPrint('Not implement yet!'), - ), - PopupListItem( - text: 'Checkboxes', - icon: _popupListIcon('checkbox'), - handler: (editorState) => formatCheckbox(editorState), - ), -]; - -OverlayEntry? _popupListOverlay; -EditorState? _editorState; -FlowyKeyEventHandler slashShortcutHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.slash) { - return KeyEventResult.ignored; - } - - final textNodes = editorState.service.selectionService.currentSelectedNodes - .whereType(); - if (textNodes.length != 1) { - return KeyEventResult.ignored; - } - - final selection = editorState.service.selectionService.currentSelection.value; - final textNode = textNodes.first; - final context = textNode.context; - final selectable = textNode.selectable; - if (selection == null || context == null || selectable == null) { - return KeyEventResult.ignored; - } - - final rect = selectable.getCursorRectInPosition(selection.start); - if (rect == null) { - return KeyEventResult.ignored; - } - final offset = selectable.localToGlobal(rect.topLeft); - - TransactionBuilder(editorState) - ..replaceText(textNode, selection.start.offset, - selection.end.offset - selection.start.offset, '/') - ..commit(); - - _editorState = editorState; - WidgetsBinding.instance.addPostFrameCallback((_) { - showPopupList(context, editorState, offset); - }); - - return KeyEventResult.handled; -}; - -void showPopupList( - BuildContext context, EditorState editorState, Offset offset) { - _popupListOverlay?.remove(); - _popupListOverlay = OverlayEntry( - builder: (context) => Positioned( - top: offset.dy + 15.0, - left: offset.dx + 5.0, - child: PopupListWidget( - editorState: editorState, - items: _popupListItems, - ), - ), - ); - - Overlay.of(context)?.insert(_popupListOverlay!); - - editorState.service.selectionService.currentSelection - .removeListener(clearPopupList); - editorState.service.selectionService.currentSelection - .addListener(clearPopupList); - - editorState.service.scrollService?.disable(); -} - -void clearPopupList() { - if (_popupListOverlay == null || _editorState == null) { - return; - } - _popupListOverlay?.remove(); - _popupListOverlay = null; - - _editorState?.service.keyboardService?.enable(); - _editorState?.service.scrollService?.enable(); - _editorState = null; -} - -class PopupListWidget extends StatefulWidget { - const PopupListWidget({ - Key? key, - required this.editorState, - required this.items, - this.maxItemInRow = 5, - }) : super(key: key); - - final EditorState editorState; - final List items; - final int maxItemInRow; - - @override - State createState() => _PopupListWidgetState(); -} - -class _PopupListWidgetState extends State { - final focusNode = FocusNode(debugLabel: 'popup_list_widget'); - var selectedIndex = 0; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - focusNode.requestFocus(); - }); - } - - @override - void dispose() { - focusNode.dispose(); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: focusNode, - onKey: _onKey, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - borderRadius: BorderRadius.circular(6.0), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: _buildColumns(widget.items, selectedIndex), - ), - ), - ); - } - - List _buildColumns(List items, int selectedIndex) { - List columns = []; - List itemWidgets = []; - for (var i = 0; i < items.length; i++) { - if (i != 0 && i % (widget.maxItemInRow) == 0) { - columns.add(Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: itemWidgets, - )); - itemWidgets = []; - } - itemWidgets.add(_PopupListItemWidget( - editorState: widget.editorState, - item: items[i], - highlight: selectedIndex == i, - )); - } - if (itemWidgets.isNotEmpty) { - columns.add(Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: itemWidgets, - )); - itemWidgets = []; - } - return columns; - } - - KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { - if (event is! RawKeyDownEvent) { - return KeyEventResult.ignored; - } - - if (event.logicalKey == LogicalKeyboardKey.enter) { - if (0 <= selectedIndex && selectedIndex < widget.items.length) { - _deleteSlash(); - widget.items[selectedIndex].handler(widget.editorState); - return KeyEventResult.handled; - } - } else if (event.logicalKey == LogicalKeyboardKey.escape) { - clearPopupList(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.backspace) { - clearPopupList(); - _deleteSlash(); - return KeyEventResult.handled; - } - - var newSelectedIndex = selectedIndex; - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - newSelectedIndex -= widget.maxItemInRow; - } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - newSelectedIndex += widget.maxItemInRow; - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - newSelectedIndex -= 1; - } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - newSelectedIndex += 1; - } - if (newSelectedIndex != selectedIndex) { - setState(() { - selectedIndex = max(0, min(widget.items.length - 1, newSelectedIndex)); - }); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - void _deleteSlash() { - final selection = - widget.editorState.service.selectionService.currentSelection.value; - final nodes = - widget.editorState.service.selectionService.currentSelectedNodes; - if (selection != null && nodes.length == 1) { - TransactionBuilder(widget.editorState) - ..deleteText( - nodes.first as TextNode, - selection.start.offset - 1, - 1, - ) - ..commit(); - } - } -} - -class _PopupListItemWidget extends StatelessWidget { - const _PopupListItemWidget({ - Key? key, - required this.highlight, - required this.item, - required this.editorState, - }) : super(key: key); - - final EditorState editorState; - final PopupListItem item; - final bool highlight; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(8.0, 5.0, 8.0, 5.0), - child: SizedBox( - width: 140, - child: TextButton.icon( - icon: item.icon, - style: ButtonStyle( - alignment: Alignment.centerLeft, - overlayColor: MaterialStateProperty.all( - const Color(0xFFE0F8FF), - ), - backgroundColor: highlight - ? MaterialStateProperty.all(const Color(0xFFE0F8FF)) - : MaterialStateProperty.all(Colors.transparent), - ), - label: Text( - item.text, - textAlign: TextAlign.left, - style: const TextStyle( - color: Colors.black, - fontSize: 14.0, - ), - ), - onPressed: () { - item.handler(editorState); - }, - ), - ), - ); - } -} - -class PopupListItem { - PopupListItem({ - required this.text, - this.message = '', - required this.icon, - required this.handler, - }); - - final String text; - final String message; - final Widget icon; - final void Function(EditorState editorState) handler; -} - -Widget _popupListIcon(String name) => FlowySvg( - name: 'popup_list/$name', - color: Colors.black, - size: const Size.square(18.0), - ); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart deleted file mode 100644 index 220643cf6f..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; - -FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) { - if (!event.isMetaPressed || event.character == null) { - return KeyEventResult.ignored; - } - - final selection = editorState.service.selectionService.currentSelection.value; - final nodes = editorState.service.selectionService.currentSelectedNodes; - final textNodes = nodes.whereType().toList(growable: false); - - if (selection == null || textNodes.isEmpty) { - return KeyEventResult.ignored; - } - - switch (event.character!) { - // bold - case 'B': - case 'b': - formatBold(editorState); - return KeyEventResult.handled; - default: - break; - } - - return KeyEventResult.ignored; -}; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart deleted file mode 100644 index 01cc0214a1..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/keyboard_service.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/services.dart'; - -import '../editor_state.dart'; -import 'package:flutter/material.dart'; - -mixin FlowyKeyboardService on State { - void enable(); - void disable(); -} - -typedef FlowyKeyEventHandler = KeyEventResult Function( - EditorState editorState, - RawKeyEvent event, -); - -/// Process keyboard events -class FlowyKeyboard extends StatefulWidget { - const FlowyKeyboard({ - Key? key, - required this.handlers, - required this.editorState, - required this.child, - }) : super(key: key); - - final EditorState editorState; - final Widget child; - final List handlers; - - @override - State createState() => _FlowyKeyboardState(); -} - -class _FlowyKeyboardState extends State - with FlowyKeyboardService { - final FocusNode _focusNode = FocusNode(debugLabel: 'flowy_keyboard_service'); - - bool isFocus = true; - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - onKey: _onKey, - onFocusChange: _onFocusChange, - child: widget.child, - ); - } - - @override - void dispose() { - _focusNode.dispose(); - - super.dispose(); - } - - @override - void enable() { - isFocus = true; - _focusNode.requestFocus(); - } - - @override - void disable() { - isFocus = false; - _focusNode.unfocus(); - } - - void _onFocusChange(bool value) { - debugPrint('[KeyBoard Service] focus change $value'); - } - - KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { - if (!isFocus) { - return KeyEventResult.ignored; - } - - debugPrint('on keyboard event $event'); - - if (event is! RawKeyDownEvent) { - return KeyEventResult.ignored; - } - - for (final handler in widget.handlers) { - // debugPrint('handle keyboard event $event by $handler'); - - KeyEventResult result = handler(widget.editorState, event); - - switch (result) { - case KeyEventResult.handled: - return KeyEventResult.handled; - case KeyEventResult.skipRemainingHandlers: - return KeyEventResult.skipRemainingHandlers; - case KeyEventResult.ignored: - continue; - } - } - - return KeyEventResult.ignored; - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart deleted file mode 100644 index af48a78c49..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -mixin FlowyScrollService on State { - double get dy; - - void scrollTo(double dy); - - void enable(); - void disable(); -} - -class FlowyScroll extends StatefulWidget { - const FlowyScroll({ - Key? key, - required this.child, - }) : super(key: key); - - final Widget child; - - @override - State createState() => _FlowyScrollState(); -} - -class _FlowyScrollState extends State with FlowyScrollService { - final _scrollController = ScrollController(); - final _scrollViewKey = GlobalKey(); - - bool _scrollEnabled = true; - - @override - double get dy => _scrollController.position.pixels; - - @override - Widget build(BuildContext context) { - return Listener( - onPointerSignal: _onPointerSignal, - child: SingleChildScrollView( - key: _scrollViewKey, - physics: const NeverScrollableScrollPhysics(), - controller: _scrollController, - child: widget.child, - ), - ); - } - - @override - void scrollTo(double dy) { - _scrollController.position.jumpTo( - dy.clamp( - _scrollController.position.minScrollExtent, - _scrollController.position.maxScrollExtent, - ), - ); - } - - @override - void disable() { - _scrollEnabled = false; - debugPrint('[scroll] $_scrollEnabled'); - } - - @override - void enable() { - _scrollEnabled = true; - debugPrint('[scroll] $_scrollEnabled'); - } - - void _onPointerSignal(PointerSignalEvent event) { - if (event is PointerScrollEvent && _scrollEnabled) { - final dy = (_scrollController.position.pixels + event.scrollDelta.dy); - scrollTo(dy); - } - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart deleted file mode 100644 index 82088561cb..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ /dev/null @@ -1,705 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/node_iterator.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/document/state_tree.dart'; -import 'package:flowy_editor/editor_state.dart'; -import 'package:flowy_editor/extensions/node_extensions.dart'; -import 'package:flowy_editor/render/selection/cursor_widget.dart'; -import 'package:flowy_editor/render/selection/selectable.dart'; -import 'package:flowy_editor/render/selection/selection_widget.dart'; - -/// Process selection and cursor -mixin FlowySelectionService on State { - /// Returns the current [Selection] - ValueNotifier get currentSelection; - - /// Returns the current selected [Node]s. - /// - /// The order of the return is determined according to the selected order. - List get currentSelectedNodes; - - /// Update the selection or cursor. - /// - /// If selection is collapsed, this method will - /// update the position of the cursor. - /// Otherwise, will update the selection. - void updateSelection(Selection selection); - - /// Clear the selection or cursor. - void clearSelection(); - - /// ------------------ Selection ------------------------ - - List rects(); - - Position? hitTest(Offset? offset); - - /// - List getNodesInSelection(Selection selection); - - /// ------------------ Selection ------------------------ - - /// ------------------ Offset ------------------------ - - /// Return the [Node] or [Null] in single selection. - /// - /// [offset] is under the global coordinate system. - Node? getNodeInOffset(Offset offset); - - /// Returns selected [Node]s. Empty list would be returned - /// if no nodes are in range. - /// - /// - /// [start] and [end] are under the global coordinate system. - /// - List getNodeInRange(Offset start, Offset end); - - /// Return [bool] to identify the [Node] is in Range or not. - /// - /// [start] and [end] are under the global coordinate system. - bool isNodeInRange( - Node node, - Offset start, - Offset end, - ); - - /// Return [bool] to identify the [Node] contains [Offset] or not. - /// - /// [offset] is under the global coordinate system. - bool isNodeInOffset(Node node, Offset offset); - - /// ------------------ Offset ------------------------ -} - -class FlowySelection extends StatefulWidget { - const FlowySelection({ - Key? key, - this.cursorColor = Colors.black, - this.selectionColor = const Color.fromARGB(60, 61, 61, 213), - required this.editorState, - required this.child, - }) : super(key: key); - - final EditorState editorState; - final Widget child; - final Color cursorColor; - final Color selectionColor; - - @override - State createState() => _FlowySelectionState(); -} - -class _FlowySelectionState extends State - with FlowySelectionService, WidgetsBindingObserver { - final _cursorKey = GlobalKey(debugLabel: 'cursor'); - - final List _selectionOverlays = []; - final List _cursorOverlays = []; - OverlayEntry? _debugOverlay; - - /// [Pan] and [Tap] must be mutually exclusive. - /// Pan - Offset? panStartOffset; - double? panStartScrollDy; - Offset? panEndOffset; - - /// Tap - Offset? tapOffset; - - final List _rects = []; - - EditorState get editorState => widget.editorState; - - @override - ValueNotifier currentSelection = ValueNotifier(null); - - @override - List currentSelectedNodes = []; - - @override - List getNodesInSelection(Selection selection) => - _selectedNodesInSelection(editorState.document, selection); - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addObserver(this); - } - - @override - void didChangeMetrics() { - super.didChangeMetrics(); - - // Need to refresh the selection when the metrics changed. - if (currentSelection.value != null) { - updateSelection(currentSelection.value!); - } - } - - @override - void dispose() { - clearSelection(); - WidgetsBinding.instance.removeObserver(this); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _SelectionGestureDetector( - onPanStart: _onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - onTapDown: _onTapDown, - onDoubleTapDown: _onDoubleTapDown, - onTripleTapDown: _onTripleTapDown, - child: widget.child, - ); - } - - @override - List rects() { - return _rects; - } - - @override - void updateSelection(Selection selection) { - _rects.clear(); - clearSelection(); - - // cursor - if (selection.isCollapsed) { - debugPrint('Update cursor'); - _updateCursor(selection.start); - } else { - debugPrint('Update selection'); - _updateSelection(selection); - } - } - - @override - void clearSelection() { - currentSelectedNodes = []; - currentSelection.value = null; - - // clear selection - _selectionOverlays - ..forEach((overlay) => overlay.remove()) - ..clear(); - // clear cursors - _cursorOverlays - ..forEach((overlay) => overlay.remove()) - ..clear(); - // clear toolbar - editorState.service.toolbarService?.hide(); - } - - @override - Node? getNodeInOffset(Offset offset) { - return _lowerBoundInDocument(offset); - } - - @override - List getNodeInRange(Offset start, Offset end) { - final startNode = _lowerBoundInDocument(start); - final endNode = _upperBoundInDocument(end); - return NodeIterator(editorState.document, startNode, endNode).toList(); - } - - @override - bool isNodeInOffset(Node node, Offset offset) { - final renderBox = node.renderBox; - if (renderBox != null) { - final boxOffset = renderBox.localToGlobal(Offset.zero); - final boxRect = boxOffset & renderBox.size; - return boxRect.contains(offset); - } - return false; - } - - @override - bool isNodeInRange(Node node, Offset start, Offset end) { - final renderBox = node.renderBox; - if (renderBox != null) { - final rect = Rect.fromPoints(start, end); - final boxOffset = renderBox.localToGlobal(Offset.zero); - final boxRect = boxOffset & renderBox.size; - return rect.overlaps(boxRect); - } - return false; - } - - void _onDoubleTapDown(TapDownDetails details) { - final offset = details.globalPosition; - final node = getNodeInOffset(offset); - if (node == null) { - editorState.updateCursorSelection(null); - return; - } - final selectable = node.selectable; - if (selectable == null) { - editorState.updateCursorSelection(null); - return; - } - editorState - .updateCursorSelection(selectable.getWorldBoundaryInOffset(offset)); - } - - void _onTripleTapDown(TapDownDetails details) { - final offset = details.globalPosition; - final node = getNodeInOffset(offset); - if (node == null) { - editorState.updateCursorSelection(null); - return; - } - Selection selection; - if (node is TextNode) { - final textLen = node.delta.length; - selection = Selection( - start: Position(path: node.path, offset: 0), - end: Position(path: node.path, offset: textLen)); - } else { - selection = Selection.collapsed(Position(path: node.path, offset: 0)); - } - editorState.updateCursorSelection(selection); - } - - void _onTapDown(TapDownDetails details) { - // clear old state. - panStartOffset = null; - panEndOffset = null; - - tapOffset = details.globalPosition; - - final position = hitTest(tapOffset); - if (position == null) { - return; - } - final selection = Selection.collapsed(position); - editorState.updateCursorSelection(selection); - - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); - } - - @override - Position? hitTest(Offset? offset) { - if (offset == null) { - editorState.updateCursorSelection(null); - return null; - } - final node = getNodeInOffset(offset); - if (node == null) { - editorState.updateCursorSelection(null); - return null; - } - final selectable = node.selectable; - if (selectable == null) { - editorState.updateCursorSelection(null); - return null; - } - return selectable.getPositionInOffset(offset); - } - - void _onPanStart(DragStartDetails details) { - // clear old state. - panEndOffset = null; - tapOffset = null; - clearSelection(); - - panStartOffset = details.globalPosition; - panStartScrollDy = editorState.service.scrollService?.dy; - - debugPrint('[_onPanStart] panStartOffset = $panStartOffset'); - } - - void _onPanUpdate(DragUpdateDetails details) { - if (panStartOffset == null || panStartScrollDy == null) { - return; - } - - panEndOffset = details.globalPosition; - final dy = editorState.service.scrollService?.dy; - var panStartOffsetWithScrollDyGap = panStartOffset!; - if (dy != null) { - panStartOffsetWithScrollDyGap = - panStartOffsetWithScrollDyGap.translate(0, panStartScrollDy! - dy); - } - - final first = - _lowerBoundInDocument(panStartOffsetWithScrollDyGap).selectable; - final last = _upperBoundInDocument(panEndOffset!).selectable; - - // compute the selection in range. - if (first != null && last != null) { - bool isDownward; - if (first == last) { - isDownward = panStartOffsetWithScrollDyGap.dx < panEndOffset!.dx; - } else { - isDownward = panStartOffsetWithScrollDyGap.dy < panEndOffset!.dy; - } - final start = first - .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!) - .start; - final end = last - .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!) - .end; - final selection = Selection( - start: isDownward ? start : end, end: isDownward ? end : start); - debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection'); - editorState.updateCursorSelection(selection); - } - - _scrollUpOrDownIfNeeded(panEndOffset!); - _showDebugLayerIfNeeded(); - } - - void _onPanEnd(DragEndDetails details) { - // do nothing - } - - void _updateSelection(Selection selection) { - final nodes = _selectedNodesInSelection(editorState.document, selection); - - currentSelectedNodes = nodes; - currentSelection.value = selection; - - Rect? topmostRect; - LayerLink? layerLink; - - var index = 0; - for (final node in nodes) { - final selectable = node.selectable; - if (selectable == null) { - continue; - } - - var newSelection = selection.copy(); - // In the case of multiple selections, - // we need to return a new selection for each selected node individually. - if (!selection.isSingle) { - // <> means selected. - // text: abcdopqr - if (index == 0) { - if (selection.isDownward) { - newSelection = selection.copyWith(end: selectable.end()); - } else { - newSelection = selection.copyWith(start: selectable.start()); - } - } else if (index == nodes.length - 1) { - if (selection.isDownward) { - newSelection = selection.copyWith(start: selectable.start()); - } else { - newSelection = selection.copyWith(end: selectable.end()); - } - } else { - newSelection = selection.copyWith( - start: selectable.start(), - end: selectable.end(), - ); - } - } - - final rects = selectable.getRectsInSelection(newSelection); - - for (final rect in rects) { - // FIXME: Need to compute more precise location. - topmostRect ??= rect; - layerLink ??= node.layerLink; - - _rects.add(_transformRectToGlobal(selectable, rect)); - final overlay = OverlayEntry( - builder: (context) => SelectionWidget( - color: widget.selectionColor, - layerLink: node.layerLink, - rect: rect, - ), - ); - _selectionOverlays.add(overlay); - } - index += 1; - } - Overlay.of(context)?.insertAll(_selectionOverlays); - - if (topmostRect != null && layerLink != null) { - editorState.service.toolbarService - ?.showInOffset(topmostRect.topLeft, layerLink); - } - } - - Rect _transformRectToGlobal(Selectable selectable, Rect r) { - final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top)); - return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height); - } - - void _updateCursor(Position position) { - final node = editorState.document.root.childAtPath(position.path); - - assert(node != null); - if (node == null) { - return; - } - - currentSelectedNodes = [node]; - currentSelection.value = Selection.collapsed(position); - - final selectable = node.selectable; - final rect = selectable?.getCursorRectInPosition(position); - if (rect != null) { - _rects.add(_transformRectToGlobal(selectable!, rect)); - final cursor = OverlayEntry( - builder: (context) => CursorWidget( - key: _cursorKey, - rect: rect, - color: widget.cursorColor, - layerLink: node.layerLink, - ), - ); - _cursorOverlays.add(cursor); - Overlay.of(context)?.insertAll(_cursorOverlays); - _forceShowCursor(); - } - } - - _forceShowCursor() { - final currentState = _cursorKey.currentState as CursorWidgetState?; - currentState?.show(); - } - - List _selectedNodesInSelection( - StateTree stateTree, Selection selection) { - final startNode = stateTree.nodeAtPath(selection.start.path)!; - final endNode = stateTree.nodeAtPath(selection.end.path)!; - return NodeIterator(stateTree, startNode, endNode).toList(); - } - - void _scrollUpOrDownIfNeeded(Offset offset) { - final dy = editorState.service.scrollService?.dy; - if (dy == null) { - assert(false, 'Dy could not be null'); - return; - } - final topLimit = MediaQuery.of(context).size.height * 0.2; - final bottomLimit = MediaQuery.of(context).size.height * 0.8; - - /// TODO: It is necessary to calculate the relative speed - /// according to the gap and move forward more gently. - final distance = 10.0; - if (offset.dy <= topLimit) { - // up - editorState.service.scrollService?.scrollTo(dy - distance); - } else if (offset.dy >= bottomLimit) { - //down - editorState.service.scrollService?.scrollTo(dy + distance); - } - } - - void _showDebugLayerIfNeeded() { - // remove false to show debug overlay. - if (kDebugMode && false) { - _debugOverlay?.remove(); - if (panStartOffset != null) { - _debugOverlay = OverlayEntry( - builder: (context) => Positioned.fromRect( - rect: Rect.fromPoints( - panStartOffset?.translate( - 0, - -(editorState.service.scrollService!.dy - - panStartScrollDy!), - ) ?? - Offset.zero, - panEndOffset ?? Offset.zero) - .translate(0, 0), - child: Container( - color: Colors.red.withOpacity(0.2), - ), - ), - ); - Overlay.of(context)?.insert(_debugOverlay!); - } else { - _debugOverlay = null; - } - } - } - - Node _lowerBoundInDocument(Offset offset) { - final sortedNodes = - editorState.document.root.children.toList(growable: false); - return _lowerBound(sortedNodes, offset, 0, sortedNodes.length - 1); - } - - Node _upperBoundInDocument(Offset offset) { - final sortedNodes = - editorState.document.root.children.toList(growable: false); - return _upperBound(sortedNodes, offset, 0, sortedNodes.length - 1); - } - - /// TODO: Supports multi-level nesting, - /// currently only single-level nesting is supported - // find the first node's rect.bottom <= offset.dy - Node _lowerBound(List sortedNodes, Offset offset, int start, int end) { - assert(start >= 0 && end < sortedNodes.length); - var min = start; - var max = end; - while (min <= max) { - final mid = min + ((max - min) >> 1); - if (sortedNodes[mid].rect.bottom <= offset.dy) { - min = mid + 1; - } else { - max = mid - 1; - } - } - final node = sortedNodes[min]; - if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { - final children = node.children.toList(growable: false); - return _lowerBound(children, offset, 0, children.length - 1); - } - return node; - } - - /// TODO: Supports multi-level nesting, - /// currently only single-level nesting is supported - // find the first node's rect.top < offset.dy - Node _upperBound( - List sortedNodes, - Offset offset, - int start, - int end, - ) { - assert(start >= 0 && end < sortedNodes.length); - var min = start; - var max = end; - while (min <= max) { - final mid = min + ((max - min) >> 1); - if (sortedNodes[mid].rect.top < offset.dy) { - min = mid + 1; - } else { - max = mid - 1; - } - } - final node = sortedNodes[max]; - if (node.children.isNotEmpty && node.children.first.rect.top <= offset.dy) { - final children = node.children.toList(growable: false); - return _lowerBound(children, offset, 0, children.length - 1); - } - return node; - } -} - -/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] -/// for a while. So we need to implement our own GestureDetector. -@immutable -class _SelectionGestureDetector extends StatefulWidget { - const _SelectionGestureDetector( - {Key? key, - this.child, - this.onTapDown, - this.onDoubleTapDown, - this.onTripleTapDown, - this.onPanStart, - this.onPanUpdate, - this.onPanEnd}) - : super(key: key); - - @override - State<_SelectionGestureDetector> createState() => - _SelectionGestureDetectorState(); - - final Widget? child; - - final GestureTapDownCallback? onTapDown; - final GestureTapDownCallback? onDoubleTapDown; - final GestureTapDownCallback? onTripleTapDown; - final GestureDragStartCallback? onPanStart; - final GestureDragUpdateCallback? onPanUpdate; - final GestureDragEndCallback? onPanEnd; -} - -const Duration kTripleTapTimeout = Duration(milliseconds: 500); - -class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> { - bool _isDoubleTap = false; - Timer? _doubleTapTimer; - int _tripleTabCount = 0; - Timer? _tripleTabTimer; - @override - Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (recognizer) { - recognizer - ..onStart = widget.onPanStart - ..onUpdate = widget.onPanUpdate - ..onEnd = widget.onPanEnd; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recognizer) { - recognizer.onTapDown = _tapDownDelegate; - }, - ), - }, - child: widget.child, - ); - } - - _tapDownDelegate(TapDownDetails tapDownDetails) { - if (_tripleTabCount == 2) { - _tripleTabCount = 0; - _tripleTabTimer?.cancel(); - _tripleTabTimer = null; - if (widget.onTripleTapDown != null) { - widget.onTripleTapDown!(tapDownDetails); - } - } else if (_isDoubleTap) { - _isDoubleTap = false; - _doubleTapTimer?.cancel(); - _doubleTapTimer = null; - if (widget.onDoubleTapDown != null) { - widget.onDoubleTapDown!(tapDownDetails); - } - _tripleTabCount++; - } else { - if (widget.onTapDown != null) { - widget.onTapDown!(tapDownDetails); - } - - _isDoubleTap = true; - _doubleTapTimer?.cancel(); - _doubleTapTimer = Timer(kDoubleTapTimeout, () { - _isDoubleTap = false; - _doubleTapTimer = null; - }); - - _tripleTabCount = 1; - _tripleTabTimer?.cancel(); - _tripleTabTimer = Timer(kTripleTapTimeout, () { - _tripleTabCount = 0; - _tripleTabTimer = null; - }); - } - } - - @override - void dispose() { - _doubleTapTimer?.cancel(); - _tripleTabTimer?.cancel(); - super.dispose(); - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart deleted file mode 100644 index 9a914888d4..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart +++ /dev/null @@ -1,233 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flowy_editor/document/text_delta.dart'; - -void main() { - group('compose', () { - test('test delta', () { - final delta = Delta([ - TextInsert('Gandalf', { - 'bold': true, - }), - TextInsert(' the '), - TextInsert('Grey', { - 'color': '#ccc', - }) - ]); - - final death = Delta().retain(12).insert("White", { - 'color': '#fff', - }).delete(4); - - final restores = delta.compose(death); - expect(restores.operations, [ - TextInsert('Gandalf', {'bold': true}), - TextInsert(' the '), - TextInsert('White', {'color': '#fff'}), - ]); - }); - test('compose()', () { - final a = Delta().insert('A'); - final b = Delta().insert('B'); - final expected = Delta().insert('B').insert('A'); - expect(a.compose(b), expected); - }); - test('insert + retain', () { - final a = Delta().insert('A'); - final b = Delta().retain(1, { - 'bold': true, - 'color': 'red', - }); - final expected = Delta().insert('A', { - 'bold': true, - 'color': 'red', - }); - expect(a.compose(b), expected); - }); - test('insert + delete', () { - final a = Delta().insert('A'); - final b = Delta().delete(1); - final expected = Delta(); - expect(a.compose(b), expected); - }); - test('delete + insert', () { - final a = Delta().delete(1); - final b = Delta().insert('B'); - final expected = Delta().insert('B').delete(1); - expect(a.compose(b), expected); - }); - test('delete + retain', () { - final a = Delta().delete(1); - final b = Delta().retain(1, { - 'bold': true, - 'color': 'red', - }); - final expected = Delta().delete(1).retain(1, { - 'bold': true, - 'color': 'red', - }); - expect(a.compose(b), expected); - }); - test('delete + delete', () { - final a = Delta().delete(1); - final b = Delta().delete(1); - final expected = Delta().delete(2); - expect(a.compose(b), expected); - }); - test('retain + insert', () { - final a = Delta().retain(1, {'color': 'blue'}); - final b = Delta().insert('B'); - final expected = Delta().insert('B').retain(1, { - 'color': 'blue', - }); - expect(a.compose(b), expected); - }); - test('retain + retain', () { - final a = Delta().retain(1, { - 'color': 'blue', - }); - final b = Delta().retain(1, { - 'bold': true, - 'color': 'red', - }); - final expected = Delta().retain(1, { - 'bold': true, - 'color': 'red', - }); - expect(a.compose(b), expected); - }); - test('retain + delete', () { - final a = Delta().retain(1, { - 'color': 'blue', - }); - final b = Delta().delete(1); - final expected = Delta().delete(1); - expect(a.compose(b), expected); - }); - test('insert in middle of text', () { - final a = Delta().insert('Hello'); - final b = Delta().retain(3).insert('X'); - final expected = Delta().insert('HelXlo'); - expect(a.compose(b), expected); - }); - test('insert and delete ordering', () { - final a = Delta().insert('Hello'); - final b = Delta().insert('Hello'); - final insertFirst = Delta().retain(3).insert('X').delete(1); - final deleteFirst = Delta().retain(3).delete(1).insert('X'); - final expected = Delta().insert('HelXo'); - expect(a.compose(insertFirst), expected); - expect(b.compose(deleteFirst), expected); - }); - test('delete entire text', () { - final a = Delta().retain(4).insert('Hello'); - final b = Delta().delete(9); - final expected = Delta().delete(4); - expect(a.compose(b), expected); - }); - test('retain more than length of text', () { - final a = Delta().insert('Hello'); - final b = Delta().retain(10); - final expected = Delta().insert('Hello'); - expect(a.compose(b), expected); - }); - test('retain start optimization', () { - final a = Delta() - .insert('A', {'bold': true}) - .insert('B') - .insert('C', {'bold': true}) - .delete(1); - final b = Delta().retain(3).insert('D'); - final expected = Delta() - .insert('A', {'bold': true}) - .insert('B') - .insert('C', {'bold': true}) - .insert('D') - .delete(1); - expect(a.compose(b), expected); - }); - test('retain end optimization', () { - final a = Delta() - .insert('A', {'bold': true}) - .insert('B') - .insert('C', {'bold': true}); - final b = Delta().delete(1); - final expected = Delta().insert('B').insert('C', {'bold': true}); - expect(a.compose(b), expected); - }); - test('retain end optimization join', () { - final a = Delta() - .insert('A', {'bold': true}) - .insert('B') - .insert('C', {'bold': true}) - .insert('D') - .insert('E', {'bold': true}) - .insert('F'); - final b = Delta().retain(1).delete(1); - final expected = Delta() - .insert('AC', {'bold': true}) - .insert('D') - .insert('E', {'bold': true}) - .insert('F'); - expect(a.compose(b), expected); - }); - }); - group('invert', () { - test('insert', () { - final delta = Delta().retain(2).insert('A'); - final base = Delta().insert('12346'); - final expected = Delta().retain(2).delete(1); - final inverted = delta.invert(base); - expect(expected, inverted); - expect(base.compose(delta).compose(inverted), base); - }); - test('delete', () { - final delta = Delta().retain(2).delete(3); - final base = Delta().insert('123456'); - final expected = Delta().retain(2).insert('345'); - final inverted = delta.invert(base); - expect(expected, inverted); - expect(base.compose(delta).compose(inverted), base); - }); - // test('retain', () { - // final delta = Delta().retain(2).retain(3, {'bold': true}); - // final base = Delta().insert('123456'); - // final expected = Delta().retain(2).retain(3, {'bold': null}); - // final inverted = delta.invert(base); - // expect(expected, inverted); - // expect(base.compose(delta).compose(inverted), base); - // }); - }); - group('json', () { - test('toJson()', () { - final delta = Delta().retain(2).insert('A').delete(3); - expect(delta.toJson(), [ - {'retain': 2}, - {'insert': 'A'}, - {'delete': 3} - ]); - }); - test('attributes', () { - final delta = - Delta().retain(2, {'bold': true}).insert('A', {'italic': true}); - expect(delta.toJson(), [ - { - 'retain': 2, - 'attributes': {'bold': true}, - }, - { - 'insert': 'A', - 'attributes': {'italic': true}, - }, - ]); - }); - test('fromJson()', () { - final delta = Delta.fromJson([ - {'retain': 2}, - {'insert': 'A'}, - {'delete': 3}, - ]); - final expected = Delta().retain(2).insert('A').delete(3); - expect(delta, expected); - }); - }); -} diff --git a/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart b/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart deleted file mode 100644 index 49d0fd00f5..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:convert'; - -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/state_tree.dart'; -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/position.dart'; -import 'package:flowy_editor/document/selection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('create state tree', () async { - final String response = await rootBundle.loadString('assets/document.json'); - final data = Map.from(json.decode(response)); - final stateTree = StateTree.fromJson(data); - expect(stateTree.root.type, 'root'); - expect(stateTree.root.toJson(), data['document']); - }); - - test('search node by Path in state tree', () async { - final String response = await rootBundle.loadString('assets/document.json'); - final data = Map.from(json.decode(response)); - final stateTree = StateTree.fromJson(data); - final checkBoxNode = stateTree.root.childAtPath([1, 0]); - expect(checkBoxNode != null, true); - final textType = checkBoxNode!.attributes['text-type']; - expect(textType != null, true); - }); - - test('search node by Self in state tree', () async { - final String response = await rootBundle.loadString('assets/document.json'); - final data = Map.from(json.decode(response)); - final stateTree = StateTree.fromJson(data); - final checkBoxNode = stateTree.root.childAtPath([1, 0]); - expect(checkBoxNode != null, true); - final textType = checkBoxNode!.attributes['text-type']; - expect(textType != null, true); - final path = checkBoxNode.path; - expect(pathEquals(path, [1, 0]), true); - }); - - test('insert node in state tree', () async { - final String response = await rootBundle.loadString('assets/document.json'); - final data = Map.from(json.decode(response)); - final stateTree = StateTree.fromJson(data); - final insertNode = Node.fromJson({ - 'type': 'text', - }); - bool result = stateTree.insert([1, 1], [insertNode]); - expect(result, true); - expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true); - }); - - test('delete node in state tree', () async { - final String response = await rootBundle.loadString('assets/document.json'); - final data = Map.from(json.decode(response)); - final stateTree = StateTree.fromJson(data); - stateTree.delete([1, 1], 1); - final node = stateTree.nodeAtPath([1, 1]); - expect(node != null, true); - expect(node!.attributes['tag'], '**'); - }); - - test('update node in state tree', () async { - final String response = await rootBundle.loadString('assets/document.json'); - final data = Map.from(json.decode(response)); - final stateTree = StateTree.fromJson(data); - final attributes = stateTree.update([1, 1], {'text-type': 'heading1'}); - expect(attributes != null, true); - expect(attributes!['text-type'], 'checkbox'); - final updatedNode = stateTree.nodeAtPath([1, 1]); - expect(updatedNode != null, true); - expect(updatedNode!.attributes['text-type'], 'heading1'); - }); - - test('test path utils 1', () { - final path1 = [1]; - final path2 = [1]; - expect(pathEquals(path1, path2), true); - - expect(hashList(path1), hashList(path2)); - }); - - test('test path utils 2', () { - final path1 = [1]; - final path2 = [2]; - expect(pathEquals(path1, path2), false); - - expect(hashList(path1) != hashList(path2), true); - }); - - test('test position comparator', () { - final pos1 = Position(path: [1], offset: 0); - final pos2 = Position(path: [1], offset: 0); - expect(pos1 == pos2, true); - expect(pos1.hashCode == pos2.hashCode, true); - }); - - test('test position comparator with offset', () { - final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); - final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 100); - expect(pos1, pos2); - expect(pos1.hashCode, pos2.hashCode); - }); - - test('test position comparator false', () { - final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); - final pos2 = Position(path: [1, 1, 2, 1, 1], offset: 100); - expect(pos1 == pos2, false); - expect(pos1.hashCode == pos2.hashCode, false); - }); - - test('test position comparator with offset false', () { - final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100); - final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 101); - expect(pos1 == pos2, false); - expect(pos1.hashCode == pos2.hashCode, false); - }); - - test('test selection comparator', () { - final pos = Position(path: [0], offset: 0); - final sel = Selection.collapsed(pos); - expect(sel.start, sel.end); - expect(sel.isCollapsed, true); - }); - - test('test selection collapse', () { - final start = Position(path: [0], offset: 0); - final end = Position(path: [0], offset: 10); - final sel = Selection(start: start, end: end); - - final collapsedSelAtStart = sel.collapse(atStart: true); - expect(collapsedSelAtStart.start, start); - expect(collapsedSelAtStart.end, start); - - final collapsedSelAtEnd = sel.collapse(); - expect(collapsedSelAtEnd.start, end); - expect(collapsedSelAtEnd.end, end); - }); -} diff --git a/frontend/app_flowy/packages/flowy_infra/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra/pubspec.yaml index e47fd1d58c..ebd7656af9 100644 --- a/frontend/app_flowy/packages/flowy_infra/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: time: '>=2.0.0' uuid: ">=2.2.2" textstyle_extensions: '2.0.0-nullsafety' - flutter_svg: ^0.22.0 + flutter_svg: ^1.1.1 dev_dependencies: flutter_test: diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart b/frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart index 24274999db..cea870a017 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/lib/overlay/overlay_screen.dart @@ -53,7 +53,8 @@ class OverlayScreen extends StatelessWidget { title: const Text('Overlay Demo'), ), body: ChangeNotifierProvider( - create: (context) => OverlayDemoConfiguration(AnchorDirection.rightWithTopAligned, OverlapBehaviour.stretch), + create: (context) => OverlayDemoConfiguration( + AnchorDirection.rightWithTopAligned, OverlapBehaviour.stretch), child: Builder(builder: (providerContext) { return Center( child: ConstrainedBox( @@ -77,7 +78,8 @@ class OverlayScreen extends StatelessWidget { child: GestureDetector( // ignore: avoid_print onTapDown: (_) => print('Hello Flutter'), - child: const Center(child: FlutterLogo(size: 100)), + child: + const Center(child: FlutterLogo(size: 100)), ), ), ), @@ -90,26 +92,38 @@ class OverlayScreen extends StatelessWidget { ), const SizedBox(height: 24.0), DropdownButton( - value: providerContext.watch().anchorDirection, + value: providerContext + .watch() + .anchorDirection, onChanged: (AnchorDirection? newValue) { if (newValue != null) { - providerContext.read().anchorDirection = newValue; + providerContext + .read() + .anchorDirection = newValue; } }, - items: AnchorDirection.values.map((AnchorDirection classType) { - return DropdownMenuItem(value: classType, child: Text(classType.toString())); + items: + AnchorDirection.values.map((AnchorDirection classType) { + return DropdownMenuItem( + value: classType, child: Text(classType.toString())); }).toList(), ), const SizedBox(height: 24.0), DropdownButton( - value: providerContext.watch().overlapBehaviour, + value: providerContext + .watch() + .overlapBehaviour, onChanged: (OverlapBehaviour? newValue) { if (newValue != null) { - providerContext.read().overlapBehaviour = newValue; + providerContext + .read() + .overlapBehaviour = newValue; } }, - items: OverlapBehaviour.values.map((OverlapBehaviour classType) { - return DropdownMenuItem(value: classType, child: Text(classType.toString())); + items: OverlapBehaviour.values + .map((OverlapBehaviour classType) { + return DropdownMenuItem( + value: classType, child: Text(classType.toString())); }).toList(), ), const SizedBox(height: 24.0), @@ -127,15 +141,20 @@ class OverlayScreen extends StatelessWidget { child: GestureDetector( // ignore: avoid_print onTapDown: (_) => print('Hello Flutter'), - child: const Center(child: FlutterLogo(size: 50)), + child: const Center( + child: FlutterLogo(size: 50)), ), ), ), identifier: 'overlay_anchored_card', delegate: null, anchorContext: buttonContext, - anchorDirection: providerContext.read().anchorDirection, - overlapBehaviour: providerContext.read().overlapBehaviour, + anchorDirection: providerContext + .read() + .anchorDirection, + overlapBehaviour: providerContext + .read() + .overlapBehaviour, ); }, child: const Text('Show Anchored Overlay'), @@ -155,7 +174,8 @@ class OverlayScreen extends StatelessWidget { child: GestureDetector( // ignore: avoid_print onTapDown: (_) => debugPrint('Hello Flutter'), - child: const Center(child: FlutterLogo(size: 100)), + child: + const Center(child: FlutterLogo(size: 100)), ), ), ), @@ -163,8 +183,12 @@ class OverlayScreen extends StatelessWidget { delegate: null, anchorPosition: Offset(0, windowSize.height - 200), anchorSize: Size.zero, - anchorDirection: providerContext.read().anchorDirection, - overlapBehaviour: providerContext.read().overlapBehaviour, + anchorDirection: providerContext + .read() + .anchorDirection, + overlapBehaviour: providerContext + .read() + .overlapBehaviour, ); }, child: const Text('Show Positioned Overlay'), @@ -176,18 +200,24 @@ class OverlayScreen extends StatelessWidget { ListOverlay.showWithAnchor( context, itemBuilder: (_, index) => Card( - margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0), + margin: const EdgeInsets.symmetric( + vertical: 8.0, horizontal: 12.0), elevation: 0, child: Text( 'Option $index', - style: const TextStyle(fontSize: 20.0, color: Colors.black), + style: const TextStyle( + fontSize: 20.0, color: Colors.black), ), ), itemCount: 10, identifier: 'overlay_list_menu', anchorContext: buttonContext, - anchorDirection: providerContext.read().anchorDirection, - overlapBehaviour: providerContext.read().overlapBehaviour, + anchorDirection: providerContext + .read() + .anchorDirection, + overlapBehaviour: providerContext + .read() + .overlapBehaviour, width: 200.0, height: 200.0, ); @@ -201,13 +231,28 @@ class OverlayScreen extends StatelessWidget { onPressed: () { OptionOverlay.showWithAnchor( context, - items: ['Alpha', 'Beta', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'], - onHover: (value, index) => debugPrint('Did hover option $index, value $value'), - onTap: (value, index) => debugPrint('Did tap option $index, value $value'), + items: [ + 'Alpha', + 'Beta', + 'Charlie', + 'Delta', + 'Echo', + 'Foxtrot', + 'Golf', + 'Hotel' + ], + onHover: (value, index) => debugPrint( + 'Did hover option $index, value $value'), + onTap: (value, index) => + debugPrint('Did tap option $index, value $value'), identifier: 'overlay_options', anchorContext: buttonContext, - anchorDirection: providerContext.read().anchorDirection, - overlapBehaviour: providerContext.read().overlapBehaviour, + anchorDirection: providerContext + .read() + .anchorDirection, + overlapBehaviour: providerContext + .read() + .overlapBehaviour, ); }, child: const Text('Show Options Overlay'), diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart index 7ed3c04673..ad04dc25c2 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/flowy_overlay/flowy_overlay.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flowy_infra_ui/src/flowy_overlay/layout.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; export './overlay_container.dart'; /// Specifies how overlay are anchored to the SourceWidget @@ -59,7 +60,8 @@ class FlowyOverlayStyle { final Color barrierColor; bool blur; - FlowyOverlayStyle({this.barrierColor = Colors.transparent, this.blur = false}); + FlowyOverlayStyle( + {this.barrierColor = Colors.transparent, this.blur = false}); } final GlobalKey _key = GlobalKey(); @@ -82,7 +84,8 @@ class FlowyOverlay extends StatefulWidget { final Widget child; - static FlowyOverlayState of(BuildContext context, {bool rootOverlay = false}) { + static FlowyOverlayState of(BuildContext context, + {bool rootOverlay = false}) { FlowyOverlayState? state = maybeOf(context, rootOverlay: rootOverlay); assert(() { if (state == null) { @@ -95,7 +98,8 @@ class FlowyOverlay extends StatefulWidget { return state!; } - static FlowyOverlayState? maybeOf(BuildContext context, {bool rootOverlay = false}) { + static FlowyOverlayState? maybeOf(BuildContext context, + {bool rootOverlay = false}) { FlowyOverlayState? state; if (rootOverlay) { state = context.findRootAncestorStateOfType(); @@ -113,20 +117,29 @@ class OverlayItem { Widget widget; String identifier; FlowyOverlayDelegate? delegate; + FocusNode focusNode; OverlayItem({ required this.widget, required this.identifier, + required this.focusNode, this.delegate, }); + + void dispose() { + focusNode.dispose(); + } } class FlowyOverlayState extends State { final List _overlayList = []; FlowyOverlayStyle style = FlowyOverlayStyle(); + final Map + _keyboardShortcutBindings = {}; + /// Insert a overlay widget which frame is set by the widget, not the component. - /// Be sure to specify the offset and size using a anchorable widget (like `Postition`, `CompositedTransformFollower`) + /// Be sure to specify the offset and size using a anchorable widget (like `Position`, `CompositedTransformFollower`) void insertCustom({ required Widget widget, required String identifier, @@ -192,9 +205,12 @@ class FlowyOverlayState extends State { void remove(String identifier) { setState(() { - final index = _overlayList.indexWhere((item) => item.identifier == identifier); + final index = + _overlayList.indexWhere((item) => item.identifier == identifier); if (index != -1) { - _overlayList.removeAt(index).delegate?.didRemove(); + final OverlayItem item = _overlayList.removeAt(index); + item.delegate?.didRemove(); + item.dispose(); } }); } @@ -210,6 +226,7 @@ class FlowyOverlayState extends State { _overlayList.remove(firstItem); if (firstItem.delegate != null) { firstItem.delegate!.didRemove(); + firstItem.dispose(); if (firstItem.delegate!.asBarrier()) { return; } @@ -220,6 +237,7 @@ class FlowyOverlayState extends State { return; } else { element.delegate?.didRemove(); + element.dispose(); _overlayList.remove(element); } } @@ -247,7 +265,7 @@ class FlowyOverlayState extends State { debugPrint("Show overlay: $identifier"); Widget overlay = widget; final offset = anchorOffset ?? Offset.zero; - + final focusNode = FocusNode(); if (shouldAnchor) { assert( anchorPosition != null || anchorContext != null, @@ -259,7 +277,7 @@ class FlowyOverlayState extends State { RenderObject renderObject = anchorContext.findRenderObject()!; assert( renderObject is RenderBox, - 'Unexpect non-RenderBox render object caught.', + 'Unexpected non-RenderBox render object caught.', ); final renderBox = renderObject as RenderBox; targetAnchorPosition = renderBox.localToGlobal(Offset.zero); @@ -271,13 +289,28 @@ class FlowyOverlayState extends State { targetAnchorSize.width, targetAnchorSize.height, ); + overlay = CustomSingleChildLayout( delegate: OverlayLayoutDelegate( anchorRect: anchorRect, - anchorDirection: anchorDirection ?? AnchorDirection.rightWithTopAligned, + anchorDirection: + anchorDirection ?? AnchorDirection.rightWithTopAligned, overlapBehaviour: overlapBehaviour ?? OverlapBehaviour.stretch, ), - child: widget, + child: Focus( + focusNode: focusNode, + onKey: (node, event) { + KeyEventResult result = KeyEventResult.ignored; + for (final ShortcutActivator activator + in _keyboardShortcutBindings.keys) { + if (activator.accepts(event, RawKeyboard.instance)) { + _keyboardShortcutBindings[activator]!.call(identifier); + result = KeyEventResult.handled; + } + } + return result; + }, + child: widget), ); } @@ -285,15 +318,27 @@ class FlowyOverlayState extends State { _overlayList.add(OverlayItem( widget: overlay, identifier: identifier, + focusNode: focusNode, delegate: delegate, )); }); } + @override + void initState() { + _keyboardShortcutBindings.addAll({ + LogicalKeySet(LogicalKeyboardKey.escape): (identifier) { + remove(identifier); + }, + }); + super.initState(); + } + @override Widget build(BuildContext context) { final overlays = _overlayList.map((item) { var widget = item.widget; + item.focusNode.requestFocus(); if (item.delegate?.asBarrier() ?? false) { widget = Container( color: style.barrierColor, 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 52348dab00..5db6afacef 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 @@ -3,12 +3,26 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/widget/dialog/dialog_size.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'dart:ui'; extension IntoDialog on Widget { Future show(BuildContext context) async { - await Dialogs.show(this, context); + FocusNode dialogFocusNode = FocusNode(); + await Dialogs.show( + RawKeyboardListener( + focusNode: dialogFocusNode, + onKey: (value) { + if (value.isKeyPressed(LogicalKeyboardKey.escape)) { + Navigator.of(context).pop(); + } + }, + child: this, + ), + context, + ); + dialogFocusNode.dispose(); } } @@ -45,7 +59,8 @@ class StyledDialog extends StatelessWidget { ); if (shrinkWrap) { - innerContent = IntrinsicWidth(child: IntrinsicHeight(child: innerContent)); + innerContent = + IntrinsicWidth(child: IntrinsicHeight(child: innerContent)); } return FocusTraversalGroup( @@ -80,7 +95,8 @@ class Dialogs { return await Navigator.of(context).push( StyledDialogRoute( barrier: DialogBarrier(color: Colors.black.withOpacity(0.4)), - pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { + pageBuilder: (BuildContext buildContext, Animation animation, + Animation secondaryAnimation) { return SafeArea(child: child); }, ), @@ -132,7 +148,8 @@ class StyledDialogRoute extends PopupRoute { final RouteTransitionsBuilder? _transitionBuilder; @override - Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + Widget buildPage(BuildContext context, Animation animation, + Animation secondaryAnimation) { return Semantics( child: _pageBuilder(context, animation, secondaryAnimation), scopesRoute: true, @@ -141,10 +158,12 @@ class StyledDialogRoute extends PopupRoute { } @override - Widget buildTransitions( - BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + Widget buildTransitions(BuildContext context, Animation animation, + Animation secondaryAnimation, Widget child) { if (_transitionBuilder == null) { - return FadeTransition(opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut), child: child); + return FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut), + child: child); } else { return _transitionBuilder!(context, animation, secondaryAnimation, child); } // Some default transition diff --git a/frontend/app_flowy/pubspec.lock b/frontend/app_flowy/pubspec.lock index 05f245a8cc..5bb7e7344f 100644 --- a/frontend/app_flowy/pubspec.lock +++ b/frontend/app_flowy/pubspec.lock @@ -29,6 +29,13 @@ packages: relative: true source: path version: "0.0.4" + appflowy_editor: + dependency: "direct main" + description: + path: "packages/appflowy_editor" + relative: true + source: path + version: "0.0.2" args: dependency: transitive description: @@ -273,7 +280,7 @@ packages: name: dbus url: "https://pub.dartlang.org" source: hosted - version: "0.7.3" + version: "0.7.4" device_info_plus: dependency: "direct main" description: @@ -490,12 +497,12 @@ packages: source: git version: "2.0.13" flutter_svg: - dependency: "direct main" + dependency: transitive description: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "0.22.0" + version: "1.1.4" flutter_test: dependency: "direct dev" description: flutter @@ -834,14 +841,14 @@ packages: name: path_drawing url: "https://pub.dartlang.org" source: hosted - version: "0.5.1+1" + version: "1.0.1" path_parsing: dependency: transitive description: name: path_parsing url: "https://pub.dartlang.org" source: hosted - version: "0.2.1" + version: "1.0.1" path_provider: dependency: "direct main" description: @@ -982,6 +989,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.5.0" + rich_clipboard: + dependency: transitive + description: + name: rich_clipboard + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + rich_clipboard_android: + dependency: transitive + description: + name: rich_clipboard_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_ios: + dependency: transitive + description: + name: rich_clipboard_ios + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_linux: + dependency: transitive + description: + name: rich_clipboard_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_macos: + dependency: transitive + description: + name: rich_clipboard_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + rich_clipboard_platform_interface: + dependency: transitive + description: + name: rich_clipboard_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_web: + dependency: transitive + description: + name: rich_clipboard_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_windows: + dependency: transitive + description: + name: rich_clipboard_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" shared_preferences: dependency: transitive description: @@ -1252,7 +1315,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.1.2" + version: "6.1.5" url_launcher_android: dependency: transitive description: @@ -1287,7 +1350,7 @@ packages: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.1.0" url_launcher_web: dependency: transitive description: @@ -1408,7 +1471,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.4.1" + version: "6.1.0" yaml: dependency: transitive description: diff --git a/frontend/app_flowy/pubspec.yaml b/frontend/app_flowy/pubspec.yaml index a73f86359f..77412464ff 100644 --- a/frontend/app_flowy/pubspec.yaml +++ b/frontend/app_flowy/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: path: packages/flowy_infra appflowy_board: path: packages/appflowy_board + appflowy_editor: + path: packages/appflowy_editor flutter_quill: git: url: https://github.com/appflowy/flutter-quill.git @@ -61,7 +63,6 @@ dependencies: sized_context: ^1.0.0+1 styled_widget: "^0.3.1" expandable: ^5.0.1 - flutter_svg: ^0.22.0 flutter_colorpicker: ^0.6.0 package_info_plus: ^1.3.0 url_launcher: ^6.0.2 @@ -121,6 +122,7 @@ flutter: - assets/images/home/ - assets/images/editor/ - assets/images/grid/ + - assets/images/emoji/ - assets/images/grid/field/ - assets/images/grid/setting/ - assets/translations/ diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 643237bfe6..7534492ab0 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -935,6 +935,7 @@ dependencies = [ "atomic_refcell", "bytes", "chrono", + "crossbeam-utils", "dart-notify", "dashmap", "diesel", @@ -981,6 +982,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "tracing", ] [[package]] @@ -1037,6 +1039,7 @@ dependencies = [ "lib-ot", "lib-ws", "serde", + "serde_json", "strum", "strum_macros", "tokio", diff --git a/frontend/rust-lib/flowy-database/migrations/2021-07-14-022241_user-add-col/down.sql b/frontend/rust-lib/flowy-database/migrations/2021-07-14-022241_user-add-col/down.sql index f1119aa25a..b125f69360 100644 --- a/frontend/rust-lib/flowy-database/migrations/2021-07-14-022241_user-add-col/down.sql +++ b/frontend/rust-lib/flowy-database/migrations/2021-07-14-022241_user-add-col/down.sql @@ -1,2 +1,2 @@ -- This file should undo anything in `up.sql` -DROP TABLE user_table; \ No newline at end of file +ALTER TABLE user_table DROP COLUMN workspace; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database/migrations/2022-06-11-090029_view-add-col/down.sql b/frontend/rust-lib/flowy-database/migrations/2022-06-11-090029_view-add-col/down.sql index 291a97c5ce..df82040abc 100644 --- a/frontend/rust-lib/flowy-database/migrations/2022-06-11-090029_view-add-col/down.sql +++ b/frontend/rust-lib/flowy-database/migrations/2022-06-11-090029_view-add-col/down.sql @@ -1 +1,2 @@ --- This file should undo anything in `up.sql` \ No newline at end of file +-- This file should undo anything in `up.sql` +ALTER TABLE view_table DROP COLUMN ext_data; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database/migrations/2022-08-08-110959_user-add-icon/down.sql b/frontend/rust-lib/flowy-database/migrations/2022-08-08-110959_user-add-icon/down.sql new file mode 100644 index 0000000000..505fbd4b2f --- /dev/null +++ b/frontend/rust-lib/flowy-database/migrations/2022-08-08-110959_user-add-icon/down.sql @@ -0,0 +1 @@ +ALTER TABLE user_table DROP COLUMN icon_url; diff --git a/frontend/rust-lib/flowy-database/migrations/2022-08-08-110959_user-add-icon/up.sql b/frontend/rust-lib/flowy-database/migrations/2022-08-08-110959_user-add-icon/up.sql new file mode 100644 index 0000000000..c2aee5e3de --- /dev/null +++ b/frontend/rust-lib/flowy-database/migrations/2022-08-08-110959_user-add-icon/up.sql @@ -0,0 +1 @@ +ALTER TABLE user_table ADD COLUMN icon_url TEXT NOT NULL DEFAULT ''; diff --git a/frontend/rust-lib/flowy-database/migrations/2022-08-15-020544_grid-view/down.sql b/frontend/rust-lib/flowy-database/migrations/2022-08-15-020544_grid-view/down.sql new file mode 100644 index 0000000000..aff09f3bb9 --- /dev/null +++ b/frontend/rust-lib/flowy-database/migrations/2022-08-15-020544_grid-view/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE grid_view_rev_table; \ No newline at end of file diff --git a/frontend/rust-lib/flowy-database/migrations/2022-08-15-020544_grid-view/up.sql b/frontend/rust-lib/flowy-database/migrations/2022-08-15-020544_grid-view/up.sql new file mode 100644 index 0000000000..e2b39801e1 --- /dev/null +++ b/frontend/rust-lib/flowy-database/migrations/2022-08-15-020544_grid-view/up.sql @@ -0,0 +1,11 @@ +-- Your SQL goes here + + +CREATE TABLE grid_view_rev_table ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + object_id TEXT NOT NULL DEFAULT '', + base_rev_id BIGINT NOT NULL DEFAULT 0, + rev_id BIGINT NOT NULL DEFAULT 0, + data BLOB NOT NULL DEFAULT (x''), + state INTEGER NOT NULL DEFAULT 0 +); diff --git a/frontend/rust-lib/flowy-database/src/macros.rs b/frontend/rust-lib/flowy-database/src/macros.rs index e1534bf25f..870938c1d0 100644 --- a/frontend/rust-lib/flowy-database/src/macros.rs +++ b/frontend/rust-lib/flowy-database/src/macros.rs @@ -177,20 +177,20 @@ macro_rules! impl_rev_state_map { } } - impl std::convert::From<$target> for RevisionState { + impl std::convert::From<$target> for crate::disk::RevisionState { fn from(s: $target) -> Self { match s { - $target::Sync => RevisionState::Sync, - $target::Ack => RevisionState::Ack, + $target::Sync => crate::disk::RevisionState::Sync, + $target::Ack => crate::disk::RevisionState::Ack, } } } - impl std::convert::From for $target { - fn from(s: RevisionState) -> Self { + impl std::convert::From for $target { + fn from(s: crate::disk::RevisionState) -> Self { match s { - RevisionState::Sync => $target::Sync, - RevisionState::Ack => $target::Ack, + crate::disk::RevisionState::Sync => $target::Sync, + crate::disk::RevisionState::Ack => $target::Ack, } } } diff --git a/frontend/rust-lib/flowy-database/src/schema.rs b/frontend/rust-lib/flowy-database/src/schema.rs index e41fd6d865..065a13b85f 100644 --- a/frontend/rust-lib/flowy-database/src/schema.rs +++ b/frontend/rust-lib/flowy-database/src/schema.rs @@ -42,6 +42,17 @@ table! { } } +table! { + grid_view_rev_table (id) { + id -> Integer, + object_id -> Text, + base_rev_id -> BigInt, + rev_id -> BigInt, + data -> Binary, + state -> Integer, + } +} + table! { kv_table (key) { key -> Text, @@ -88,6 +99,7 @@ table! { token -> Text, email -> Text, workspace -> Text, + icon_url -> Text, } } @@ -124,6 +136,7 @@ allow_tables_to_appear_in_same_query!( grid_block_index_table, grid_meta_rev_table, grid_rev_table, + grid_view_rev_table, kv_table, rev_snapshot, rev_table, diff --git a/frontend/rust-lib/flowy-error/src/errors.rs b/frontend/rust-lib/flowy-error/src/errors.rs index cd890fd02e..e19ebb15b2 100644 --- a/frontend/rust-lib/flowy-error/src/errors.rs +++ b/frontend/rust-lib/flowy-error/src/errors.rs @@ -93,6 +93,8 @@ impl fmt::Display for FlowyError { impl lib_dispatch::Error for FlowyError { fn as_response(&self) -> EventResponse { let bytes: Bytes = self.clone().try_into().unwrap(); + + println!("Serialize FlowyError: {:?} to event response", self); ResponseBuilder::Err().data(bytes).build() } } diff --git a/frontend/rust-lib/flowy-folder/src/entities/view.rs b/frontend/rust-lib/flowy-folder/src/entities/view.rs index 8f3a26c8fd..af66502907 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view.rs @@ -7,7 +7,7 @@ use crate::{ impl_def_and_def_mut, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use flowy_folder_data_model::revision::{gen_view_id, ViewDataTypeRevision, ViewRevision}; +use flowy_folder_data_model::revision::{gen_view_id, ViewDataTypeRevision, ViewLayoutTypeRevision, ViewRevision}; use std::convert::TryInto; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] @@ -16,13 +16,13 @@ pub struct ViewPB { pub id: String, #[pb(index = 2)] - pub belong_to_id: String, + pub app_id: String, #[pb(index = 3)] pub name: String, #[pb(index = 4)] - pub data_type: ViewDataType, + pub data_type: ViewDataTypePB, #[pb(index = 5)] pub modified_time: i64, @@ -31,49 +31,82 @@ pub struct ViewPB { pub create_time: i64, #[pb(index = 7)] - pub plugin_type: i32, + pub layout: ViewLayoutTypePB, } impl std::convert::From for ViewPB { fn from(rev: ViewRevision) -> Self { ViewPB { id: rev.id, - belong_to_id: rev.belong_to_id, + app_id: rev.app_id, name: rev.name, data_type: rev.data_type.into(), modified_time: rev.modified_time, create_time: rev.create_time, - plugin_type: rev.plugin_type, + layout: rev.layout.into(), } } } #[derive(Eq, PartialEq, Hash, Debug, ProtoBuf_Enum, Clone)] -pub enum ViewDataType { - TextBlock = 0, - Grid = 1, +pub enum ViewDataTypePB { + Text = 0, + Database = 1, } -impl std::default::Default for ViewDataType { +impl std::default::Default for ViewDataTypePB { fn default() -> Self { ViewDataTypeRevision::default().into() } } -impl std::convert::From for ViewDataType { +impl std::convert::From for ViewDataTypePB { fn from(rev: ViewDataTypeRevision) -> Self { match rev { - ViewDataTypeRevision::TextBlock => ViewDataType::TextBlock, - ViewDataTypeRevision::Grid => ViewDataType::Grid, + ViewDataTypeRevision::Text => ViewDataTypePB::Text, + ViewDataTypeRevision::Database => ViewDataTypePB::Database, } } } -impl std::convert::From for ViewDataTypeRevision { - fn from(ty: ViewDataType) -> Self { +impl std::convert::From for ViewDataTypeRevision { + fn from(ty: ViewDataTypePB) -> Self { match ty { - ViewDataType::TextBlock => ViewDataTypeRevision::TextBlock, - ViewDataType::Grid => ViewDataTypeRevision::Grid, + ViewDataTypePB::Text => ViewDataTypeRevision::Text, + ViewDataTypePB::Database => ViewDataTypeRevision::Database, + } + } +} + +#[derive(Eq, PartialEq, Hash, Debug, ProtoBuf_Enum, Clone)] +pub enum ViewLayoutTypePB { + Document = 0, + Grid = 3, + Board = 4, +} + +impl std::default::Default for ViewLayoutTypePB { + fn default() -> Self { + ViewLayoutTypePB::Grid + } +} + +impl std::convert::From for ViewLayoutTypePB { + fn from(rev: ViewLayoutTypeRevision) -> Self { + match rev { + ViewLayoutTypeRevision::Grid => ViewLayoutTypePB::Grid, + ViewLayoutTypeRevision::Board => ViewLayoutTypePB::Board, + ViewLayoutTypeRevision::Document => ViewLayoutTypePB::Document, + } + } +} + +impl std::convert::From for ViewLayoutTypeRevision { + fn from(rev: ViewLayoutTypePB) -> Self { + match rev { + ViewLayoutTypePB::Grid => ViewLayoutTypeRevision::Grid, + ViewLayoutTypePB::Board => ViewLayoutTypeRevision::Board, + ViewLayoutTypePB::Document => ViewLayoutTypeRevision::Document, } } } @@ -113,13 +146,13 @@ pub struct CreateViewPayloadPB { pub thumbnail: Option, #[pb(index = 5)] - pub data_type: ViewDataType, + pub data_type: ViewDataTypePB, #[pb(index = 6)] - pub plugin_type: i32, + pub layout: ViewLayoutTypePB, #[pb(index = 7)] - pub data: Vec, + pub view_content_data: Vec, } #[derive(Debug, Clone)] @@ -128,10 +161,10 @@ pub struct CreateViewParams { pub name: String, pub desc: String, pub thumbnail: String, - pub data_type: ViewDataType, + pub data_type: ViewDataTypePB, + pub layout: ViewLayoutTypePB, pub view_id: String, - pub data: Vec, - pub plugin_type: i32, + pub view_content_data: Vec, } impl TryInto for CreateViewPayloadPB { @@ -151,10 +184,10 @@ impl TryInto for CreateViewPayloadPB { name, desc: self.desc, data_type: self.data_type, + layout: self.layout, thumbnail, view_id, - data: self.data, - plugin_type: self.plugin_type, + view_content_data: self.view_content_data, }) } } diff --git a/frontend/rust-lib/flowy-folder/src/entities/view_info.rs b/frontend/rust-lib/flowy-folder/src/entities/view_info.rs index 92ec785821..42dbc42517 100644 --- a/frontend/rust-lib/flowy-folder/src/entities/view_info.rs +++ b/frontend/rust-lib/flowy-folder/src/entities/view_info.rs @@ -1,4 +1,4 @@ -use crate::entities::{RepeatedViewPB, ViewDataType}; +use crate::entities::{RepeatedViewPB, ViewDataTypePB}; use flowy_derive::ProtoBuf; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] @@ -16,7 +16,7 @@ pub struct ViewInfoPB { pub desc: String, #[pb(index = 5)] - pub data_type: ViewDataType, + pub data_type: ViewDataTypePB, #[pb(index = 6)] pub belongings: RepeatedViewPB, diff --git a/frontend/rust-lib/flowy-folder/src/manager.rs b/frontend/rust-lib/flowy-folder/src/manager.rs index df070d90f3..008476a397 100644 --- a/frontend/rust-lib/flowy-folder/src/manager.rs +++ b/frontend/rust-lib/flowy-folder/src/manager.rs @@ -1,4 +1,5 @@ -use crate::entities::view::ViewDataType; +use crate::entities::view::ViewDataTypePB; +use crate::entities::ViewLayoutTypePB; use crate::services::folder_editor::FolderRevisionCompactor; use crate::{ dart_notification::{send_dart_notification, FolderNotification}, @@ -221,7 +222,7 @@ impl DefaultFolderBuilder { }; let _ = view_controller.set_latest_view(&view.id); let _ = view_controller - .create_view(&view.id, ViewDataType::TextBlock, Bytes::from(view_data)) + .create_view(&view.id, ViewDataTypePB::Text, Bytes::from(view_data)) .await?; } } @@ -256,7 +257,12 @@ pub trait ViewDataProcessor { fn get_delta_data(&self, view_id: &str) -> FutureResult; - fn create_default_view(&self, user_id: &str, view_id: &str) -> FutureResult; + fn create_default_view( + &self, + user_id: &str, + view_id: &str, + layout: ViewLayoutTypePB, + ) -> FutureResult; fn create_view_from_delta_data( &self, @@ -265,7 +271,7 @@ pub trait ViewDataProcessor { data: Vec, ) -> FutureResult; - fn data_type(&self) -> ViewDataType; + fn data_type(&self) -> ViewDataTypePB; } -pub type ViewDataProcessorMap = Arc>>; +pub type ViewDataProcessorMap = Arc>>; diff --git a/frontend/rust-lib/flowy-folder/src/services/app/controller.rs b/frontend/rust-lib/flowy-folder/src/services/app/controller.rs index 112ad052ce..375e696bd1 100644 --- a/frontend/rust-lib/flowy-folder/src/services/app/controller.rs +++ b/frontend/rust-lib/flowy-folder/src/services/app/controller.rs @@ -68,7 +68,9 @@ impl AppController { let app = transaction.read_app(¶ms.value)?; let trash_ids = self.trash_controller.read_trash_ids(&transaction)?; if trash_ids.contains(&app.id) { - return Err(FlowyError::record_not_found()); + return Err( + FlowyError::record_not_found().context(format!("Can not find the app:{}", params.value)) + ); } Ok(app) }) diff --git a/frontend/rust-lib/flowy-folder/src/services/app/event_handler.rs b/frontend/rust-lib/flowy-folder/src/services/app/event_handler.rs index e2087c167e..1594351015 100644 --- a/frontend/rust-lib/flowy-folder/src/services/app/event_handler.rs +++ b/frontend/rust-lib/flowy-folder/src/services/app/event_handler.rs @@ -44,7 +44,7 @@ pub(crate) async fn update_app_handler( Ok(()) } -#[tracing::instrument(level = "trace", skip(data, app_controller, view_controller))] +#[tracing::instrument(level = "info", skip(data, app_controller, view_controller), err)] pub(crate) async fn read_app_handler( data: Data, app_controller: AppData>, diff --git a/frontend/rust-lib/flowy-folder/src/services/folder_editor.rs b/frontend/rust-lib/flowy-folder/src/services/folder_editor.rs index de461379e1..6f68497edd 100644 --- a/frontend/rust-lib/flowy-folder/src/services/folder_editor.rs +++ b/frontend/rust-lib/flowy-folder/src/services/folder_editor.rs @@ -4,13 +4,12 @@ use flowy_error::{FlowyError, FlowyResult}; use flowy_revision::{ RevisionCloudService, RevisionCompactor, RevisionManager, RevisionObjectBuilder, RevisionWebSocket, }; -use flowy_sync::util::make_delta_from_revisions; +use flowy_sync::util::make_text_delta_from_revisions; use flowy_sync::{ - client_folder::{FolderChange, FolderPad}, + client_folder::{FolderChangeset, FolderPad}, entities::{revision::Revision, ws_data::ServerRevisionWSData}, }; use lib_infra::future::FutureResult; -use lib_ot::core::PhantomAttributes; use parking_lot::RwLock; use std::sync::Arc; @@ -77,8 +76,8 @@ impl FolderEditor { Ok(()) } - pub(crate) fn apply_change(&self, change: FolderChange) -> FlowyResult<()> { - let FolderChange { delta, md5 } = change; + pub(crate) fn apply_change(&self, change: FolderChangeset) -> FlowyResult<()> { + let FolderChangeset { delta, md5 } = change; let (base_rev_id, rev_id) = self.rev_manager.next_rev_id_pair(); let delta_data = delta.json_bytes(); let revision = Revision::new( @@ -132,7 +131,7 @@ impl FolderEditor { pub struct FolderRevisionCompactor(); impl RevisionCompactor for FolderRevisionCompactor { fn bytes_from_revisions(&self, revisions: Vec) -> FlowyResult { - let delta = make_delta_from_revisions::(revisions)?; + let delta = make_text_delta_from_revisions(revisions)?; Ok(delta.json_bytes()) } } diff --git a/frontend/rust-lib/flowy-folder/src/services/persistence/migration.rs b/frontend/rust-lib/flowy-folder/src/services/persistence/migration.rs index d67d062d25..7b211fb54a 100644 --- a/frontend/rust-lib/flowy-folder/src/services/persistence/migration.rs +++ b/frontend/rust-lib/flowy-folder/src/services/persistence/migration.rs @@ -5,15 +5,18 @@ use crate::{ }; use flowy_database::kv::KV; use flowy_error::{FlowyError, FlowyResult}; - -use flowy_folder_data_model::revision::{AppRevision, ViewRevision, WorkspaceRevision}; +use flowy_folder_data_model::revision::{AppRevision, FolderRevision, ViewRevision, WorkspaceRevision}; use flowy_revision::disk::SQLiteTextBlockRevisionPersistence; -use flowy_revision::{RevisionLoader, RevisionPersistence}; +use flowy_revision::reset::{RevisionResettable, RevisionStructReset}; +use flowy_sync::client_folder::make_folder_rev_json_str; +use flowy_sync::entities::revision::Revision; use flowy_sync::{client_folder::FolderPad, entities::revision::md5}; use std::sync::Arc; const V1_MIGRATION: &str = "FOLDER_V1_MIGRATION"; const V2_MIGRATION: &str = "FOLDER_V2_MIGRATION"; +#[allow(dead_code)] +const V3_MIGRATION: &str = "FOLDER_V3_MIGRATION"; pub(crate) struct FolderMigration { user_id: String, @@ -29,7 +32,7 @@ impl FolderMigration { } pub fn run_v1_migration(&self) -> FlowyResult> { - let key = md5(format!("{}{}", self.user_id, V1_MIGRATION)); + let key = migration_flag_key(&self.user_id, V1_MIGRATION); if KV::get_bool(&key) { return Ok(None); } @@ -79,32 +82,63 @@ impl FolderMigration { Ok(Some(folder)) } - pub async fn run_v2_migration(&self, user_id: &str, folder_id: &FolderId) -> FlowyResult> { - let key = md5(format!("{}{}", self.user_id, V2_MIGRATION)); + pub async fn run_v2_migration(&self, folder_id: &FolderId) -> FlowyResult<()> { + let key = migration_flag_key(&self.user_id, V2_MIGRATION); if KV::get_bool(&key) { - return Ok(None); + return Ok(()); } - let pool = self.database.db_pool()?; - let disk_cache = SQLiteTextBlockRevisionPersistence::new(user_id, pool); - let rev_persistence = Arc::new(RevisionPersistence::new(user_id, folder_id.as_ref(), disk_cache)); - let (revisions, _) = RevisionLoader { - object_id: folder_id.as_ref().to_owned(), - user_id: self.user_id.clone(), - cloud: None, - rev_persistence, - } - .load() - .await?; - - if revisions.is_empty() { - tracing::trace!("Run folder v2 migration, but revision is empty"); - KV::set_bool(&key, true); - return Ok(None); - } - - let pad = FolderPad::from_revisions(revisions)?; + let _ = self.migration_folder_rev_struct(folder_id).await?; KV::set_bool(&key, true); tracing::trace!("Run folder v2 migration"); - Ok(Some(pad)) + Ok(()) + } + + #[allow(dead_code)] + pub async fn run_v3_migration(&self, folder_id: &FolderId) -> FlowyResult<()> { + let key = migration_flag_key(&self.user_id, V3_MIGRATION); + if KV::get_bool(&key) { + return Ok(()); + } + let _ = self.migration_folder_rev_struct(folder_id).await?; + KV::set_bool(&key, true); + tracing::trace!("Run folder v3 migration"); + Ok(()) + } + + pub async fn migration_folder_rev_struct(&self, folder_id: &FolderId) -> FlowyResult<()> { + let object = FolderRevisionResettable { + folder_id: folder_id.as_ref().to_owned(), + }; + + let pool = self.database.db_pool()?; + let disk_cache = SQLiteTextBlockRevisionPersistence::new(&self.user_id, pool); + let reset = RevisionStructReset::new(&self.user_id, object, Arc::new(disk_cache)); + reset.run().await + } +} + +fn migration_flag_key(user_id: &str, version: &str) -> String { + md5(format!("{}{}", user_id, version,)) +} + +pub struct FolderRevisionResettable { + folder_id: String, +} + +impl RevisionResettable for FolderRevisionResettable { + fn target_id(&self) -> &str { + &self.folder_id + } + + fn target_reset_rev_str(&self, revisions: Vec) -> FlowyResult { + let pad = FolderPad::from_revisions(revisions)?; + let json = pad.to_json()?; + Ok(json) + } + + fn default_target_rev_str(&self) -> FlowyResult { + let folder = FolderRevision::default(); + let json = make_folder_rev_json_str(&folder)?; + Ok(json) } } diff --git a/frontend/rust-lib/flowy-folder/src/services/persistence/mod.rs b/frontend/rust-lib/flowy-folder/src/services/persistence/mod.rs index ffa5219aa8..dcd28d1906 100644 --- a/frontend/rust-lib/flowy-folder/src/services/persistence/mod.rs +++ b/frontend/rust-lib/flowy-folder/src/services/persistence/mod.rs @@ -100,10 +100,9 @@ impl FolderPersistence { self.save_folder(user_id, folder_id, migrated_folder).await?; } - if let Some(migrated_folder) = migrations.run_v2_migration(user_id, folder_id).await? { - self.save_folder(user_id, folder_id, migrated_folder).await?; - } + let _ = migrations.run_v2_migration(folder_id).await?; + // let _ = migrations.run_v3_migration(folder_id).await?; Ok(()) } diff --git a/frontend/rust-lib/flowy-folder/src/services/persistence/version_1/view_sql.rs b/frontend/rust-lib/flowy-folder/src/services/persistence/version_1/view_sql.rs index 6223cd3366..c1b21cbe32 100644 --- a/frontend/rust-lib/flowy-folder/src/services/persistence/version_1/view_sql.rs +++ b/frontend/rust-lib/flowy-folder/src/services/persistence/version_1/view_sql.rs @@ -13,7 +13,7 @@ use flowy_database::{ SqliteConnection, }; -use flowy_folder_data_model::revision::{ViewDataTypeRevision, ViewRevision}; +use flowy_folder_data_model::revision::{ViewDataTypeRevision, ViewLayoutTypeRevision, ViewRevision}; use lib_infra::util::timestamp; pub struct ViewTableSql(); @@ -87,13 +87,13 @@ pub(crate) struct ViewTable { impl ViewTable { pub fn new(view_rev: ViewRevision) -> Self { let data_type = match view_rev.data_type { - ViewDataTypeRevision::TextBlock => SqlViewDataType::Block, - ViewDataTypeRevision::Grid => SqlViewDataType::Grid, + ViewDataTypeRevision::Text => SqlViewDataType::Block, + ViewDataTypeRevision::Database => SqlViewDataType::Grid, }; ViewTable { id: view_rev.id, - belong_to_id: view_rev.belong_to_id, + belong_to_id: view_rev.app_id, name: view_rev.name, desc: view_rev.desc, modified_time: view_rev.modified_time, @@ -110,13 +110,13 @@ impl ViewTable { impl std::convert::From for ViewRevision { fn from(table: ViewTable) -> Self { let data_type = match table.view_type { - SqlViewDataType::Block => ViewDataTypeRevision::TextBlock, - SqlViewDataType::Grid => ViewDataTypeRevision::Grid, + SqlViewDataType::Block => ViewDataTypeRevision::Text, + SqlViewDataType::Grid => ViewDataTypeRevision::Database, }; ViewRevision { id: table.id, - belong_to_id: table.belong_to_id, + app_id: table.belong_to_id, name: table.name, desc: table.desc, data_type, @@ -127,8 +127,8 @@ impl std::convert::From for ViewRevision { ext_data: "".to_string(), thumbnail: table.thumbnail, // Store the view in ViewTable was deprecated since v0.0.2. - // No need to worry about plugin_type. - plugin_type: 0, + // No need to worry about layout. + layout: ViewLayoutTypeRevision::Document, } } } diff --git a/frontend/rust-lib/flowy-folder/src/services/view/controller.rs b/frontend/rust-lib/flowy-folder/src/services/view/controller.rs index df1f668941..ab0783cec0 100644 --- a/frontend/rust-lib/flowy-folder/src/services/view/controller.rs +++ b/frontend/rust-lib/flowy-folder/src/services/view/controller.rs @@ -1,4 +1,4 @@ -pub use crate::entities::view::ViewDataType; +pub use crate::entities::view::ViewDataTypePB; use crate::entities::ViewInfoPB; use crate::manager::{ViewDataProcessor, ViewDataProcessorMap}; use crate::{ @@ -60,12 +60,14 @@ impl ViewController { ) -> Result { let processor = self.get_data_processor(params.data_type.clone())?; let user_id = self.user.user_id()?; - if params.data.is_empty() { - let view_data = processor.create_default_view(&user_id, ¶ms.view_id).await?; - params.data = view_data.to_vec(); + if params.view_content_data.is_empty() { + let view_data = processor + .create_default_view(&user_id, ¶ms.view_id, params.layout.clone()) + .await?; + params.view_content_data = view_data.to_vec(); } else { let delta_data = processor - .create_view_from_delta_data(&user_id, ¶ms.view_id, params.data.clone()) + .create_view_from_delta_data(&user_id, ¶ms.view_id, params.view_content_data.clone()) .await?; let _ = self .create_view(¶ms.view_id, params.data_type.clone(), delta_data) @@ -81,7 +83,7 @@ impl ViewController { pub(crate) async fn create_view( &self, view_id: &str, - data_type: ViewDataType, + data_type: ViewDataTypePB, delta_data: Bytes, ) -> Result<(), FlowyError> { if delta_data.is_empty() { @@ -97,7 +99,7 @@ impl ViewController { let trash_controller = self.trash_controller.clone(); self.persistence .begin_transaction(|transaction| { - let belong_to_id = view_rev.belong_to_id.clone(); + let belong_to_id = view_rev.app_id.clone(); let _ = transaction.create_view(view_rev)?; let _ = notify_views_changed(&belong_to_id, trash_controller, &transaction)?; Ok(()) @@ -137,7 +139,7 @@ impl ViewController { let view_info = ViewInfoPB { id: view_rev.id, - belong_to_id: view_rev.belong_to_id, + belong_to_id: view_rev.app_id, name: view_rev.name, desc: view_rev.desc, data_type: view_rev.data_type.into(), @@ -195,7 +197,7 @@ impl ViewController { .begin_transaction(|transaction| { let _ = transaction.move_view(view_id, from, to)?; let view = transaction.read_view(view_id)?; - let _ = notify_views_changed(&view.belong_to_id, self.trash_controller.clone(), &transaction)?; + let _ = notify_views_changed(&view.app_id, self.trash_controller.clone(), &transaction)?; Ok(()) }) .await?; @@ -212,14 +214,14 @@ impl ViewController { let processor = self.get_data_processor(view_rev.data_type.clone())?; let delta_bytes = processor.get_delta_data(view_id).await?; let duplicate_params = CreateViewParams { - belong_to_id: view_rev.belong_to_id.clone(), + belong_to_id: view_rev.app_id.clone(), name: format!("{} (copy)", &view_rev.name), desc: view_rev.desc, thumbnail: view_rev.thumbnail, data_type: view_rev.data_type.into(), - data: delta_bytes.to_vec(), + layout: view_rev.layout.into(), + view_content_data: delta_bytes.to_vec(), view_id: gen_view_id(), - plugin_type: view_rev.plugin_type, }; let _ = self.create_view_from_params(duplicate_params).await?; @@ -249,7 +251,7 @@ impl ViewController { send_dart_notification(&view_id, FolderNotification::ViewUpdated) .payload(view) .send(); - let _ = notify_views_changed(&view_rev.belong_to_id, self.trash_controller.clone(), &transaction)?; + let _ = notify_views_changed(&view_rev.app_id, self.trash_controller.clone(), &transaction)?; Ok(view_rev) }) .await?; @@ -364,7 +366,7 @@ impl ViewController { } #[inline] - fn get_data_processor>( + fn get_data_processor>( &self, data_type: T, ) -> FlowyResult> { @@ -392,7 +394,7 @@ async fn handle_trash_event( .begin_transaction(|transaction| { let view_revs = read_local_views_with_transaction(identifiers, &transaction)?; for view_rev in view_revs { - let _ = notify_views_changed(&view_rev.belong_to_id, trash_can.clone(), &transaction)?; + let _ = notify_views_changed(&view_rev.app_id, trash_can.clone(), &transaction)?; notify_dart(view_rev.into(), FolderNotification::ViewDeleted); } Ok(()) @@ -405,7 +407,7 @@ async fn handle_trash_event( .begin_transaction(|transaction| { let view_revs = read_local_views_with_transaction(identifiers, &transaction)?; for view_rev in view_revs { - let _ = notify_views_changed(&view_rev.belong_to_id, trash_can.clone(), &transaction)?; + let _ = notify_views_changed(&view_rev.app_id, trash_can.clone(), &transaction)?; notify_dart(view_rev.into(), FolderNotification::ViewRestored); } Ok(()) @@ -422,7 +424,7 @@ async fn handle_trash_event( for identifier in identifiers.items { let view = transaction.read_view(&identifier.id)?; let _ = transaction.delete_view(&view.id)?; - notify_ids.insert(view.belong_to_id.clone()); + notify_ids.insert(view.app_id.clone()); views.push(view); } for notify_id in notify_ids { @@ -452,7 +454,7 @@ async fn handle_trash_event( fn get_data_processor( data_processors: ViewDataProcessorMap, - data_type: &ViewDataType, + data_type: &ViewDataTypePB, ) -> FlowyResult> { match data_processors.get(data_type) { None => Err(FlowyError::internal().context(format!( diff --git a/frontend/rust-lib/flowy-folder/tests/workspace/folder_test.rs b/frontend/rust-lib/flowy-folder/tests/workspace/folder_test.rs index f17986b484..33564c923d 100644 --- a/frontend/rust-lib/flowy-folder/tests/workspace/folder_test.rs +++ b/frontend/rust-lib/flowy-folder/tests/workspace/folder_test.rs @@ -1,7 +1,6 @@ use crate::script::{invalid_workspace_name_test_case, FolderScript::*, FolderTest}; -use flowy_folder::entities::view::ViewDataType; +use flowy_folder::entities::view::ViewDataTypePB; use flowy_folder::entities::workspace::CreateWorkspacePayloadPB; - use flowy_revision::disk::RevisionState; use flowy_test::{event_builder::*, FlowySDKTest}; @@ -134,12 +133,12 @@ async fn app_create_with_view() { CreateView { name: "View A".to_owned(), desc: "View A description".to_owned(), - data_type: ViewDataType::TextBlock, + data_type: ViewDataTypePB::Text, }, CreateView { name: "Grid".to_owned(), desc: "Grid description".to_owned(), - data_type: ViewDataType::Grid, + data_type: ViewDataTypePB::Database, }, ReadApp(app.id), ]) @@ -198,12 +197,12 @@ async fn view_delete_all() { CreateView { name: "View A".to_owned(), desc: "View A description".to_owned(), - data_type: ViewDataType::TextBlock, + data_type: ViewDataTypePB::Text, }, CreateView { name: "Grid".to_owned(), desc: "Grid description".to_owned(), - data_type: ViewDataType::Grid, + data_type: ViewDataTypePB::Database, }, ReadApp(app.id.clone()), ]) @@ -231,7 +230,7 @@ async fn view_delete_all_permanent() { CreateView { name: "View A".to_owned(), desc: "View A description".to_owned(), - data_type: ViewDataType::TextBlock, + data_type: ViewDataTypePB::Text, }, ReadApp(app.id.clone()), ]) @@ -330,7 +329,7 @@ async fn folder_sync_revision_with_new_view() { CreateView { name: view_name.clone(), desc: view_desc.clone(), - data_type: ViewDataType::TextBlock, + data_type: ViewDataTypePB::Text, }, AssertCurrentRevId(3), AssertNextSyncRevId(Some(3)), diff --git a/frontend/rust-lib/flowy-folder/tests/workspace/script.rs b/frontend/rust-lib/flowy-folder/tests/workspace/script.rs index ed16664d90..74b90ce654 100644 --- a/frontend/rust-lib/flowy-folder/tests/workspace/script.rs +++ b/frontend/rust-lib/flowy-folder/tests/workspace/script.rs @@ -5,11 +5,12 @@ use flowy_folder::entities::{ trash::{RepeatedTrashPB, TrashIdPB, TrashType}, view::{CreateViewPayloadPB, UpdateViewPayloadPB}, workspace::{CreateWorkspacePayloadPB, RepeatedWorkspacePB}, + ViewLayoutTypePB, }; use flowy_folder::entities::{ app::{AppPB, RepeatedAppPB}, trash::TrashPB, - view::{RepeatedViewPB, ViewDataType, ViewPB}, + view::{RepeatedViewPB, ViewDataTypePB, ViewPB}, workspace::WorkspacePB, }; use flowy_folder::event_map::FolderEvent::*; @@ -51,7 +52,7 @@ pub enum FolderScript { CreateView { name: String, desc: String, - data_type: ViewDataType, + data_type: ViewDataTypePB, }, AssertView(ViewPB), ReadView(String), @@ -98,7 +99,8 @@ impl FolderTest { &app.id, "Folder View", "Folder test view", - ViewDataType::TextBlock, + ViewDataTypePB::Text, + ViewLayoutTypePB::Document, ) .await; app.belongings = RepeatedViewPB { @@ -149,7 +151,7 @@ impl FolderTest { // assert_eq!(json, expected_json); // } FolderScript::AssertWorkspace(workspace) => { - assert_eq!(self.workspace, workspace); + assert_eq!(self.workspace, workspace, "Workspace not equal"); } FolderScript::ReadWorkspace(workspace_id) => { let workspace = read_workspace(sdk, workspace_id).await.pop().unwrap(); @@ -165,7 +167,7 @@ impl FolderTest { // assert_eq!(json, expected_json); // } FolderScript::AssertApp(app) => { - assert_eq!(self.app, app); + assert_eq!(self.app, app, "App not equal"); } FolderScript::ReadApp(app_id) => { let app = read_app(sdk, &app_id).await; @@ -179,11 +181,15 @@ impl FolderTest { } FolderScript::CreateView { name, desc, data_type } => { - let view = create_view(sdk, &self.app.id, &name, &desc, data_type).await; + let layout = match data_type { + ViewDataTypePB::Text => ViewLayoutTypePB::Document, + ViewDataTypePB::Database => ViewLayoutTypePB::Grid, + }; + let view = create_view(sdk, &self.app.id, &name, &desc, data_type, layout).await; self.view = view; } FolderScript::AssertView(view) => { - assert_eq!(self.view, view); + assert_eq!(self.view, view, "View not equal"); } FolderScript::ReadView(view_id) => { let view = read_view(sdk, &view_id).await; @@ -214,7 +220,7 @@ impl FolderTest { } FolderScript::AssertRevisionState { rev_id, state } => { let record = cache.get(rev_id).await.unwrap(); - assert_eq!(record.state, state); + assert_eq!(record.state, state, "Revision state is not match"); if let RevisionState::Ack = state { // There is a defer action that writes the revisions to disk, so we wait here. // Make sure everything is written. @@ -234,7 +240,7 @@ impl FolderTest { .unwrap_or_else(|| panic!("Expected Next revision is {}, but receive None", rev_id.unwrap())); let mut notify = rev_manager.ack_notify(); let _ = notify.recv().await; - assert_eq!(next_revision.rev_id, rev_id.unwrap()); + assert_eq!(next_revision.rev_id, rev_id.unwrap(), "Revision id not match"); } } } @@ -346,15 +352,22 @@ pub async fn delete_app(sdk: &FlowySDKTest, app_id: &str) { .await; } -pub async fn create_view(sdk: &FlowySDKTest, app_id: &str, name: &str, desc: &str, data_type: ViewDataType) -> ViewPB { +pub async fn create_view( + sdk: &FlowySDKTest, + app_id: &str, + name: &str, + desc: &str, + data_type: ViewDataTypePB, + layout: ViewLayoutTypePB, +) -> ViewPB { let request = CreateViewPayloadPB { belong_to_id: app_id.to_string(), name: name.to_string(), desc: desc.to_string(), thumbnail: None, data_type, - plugin_type: 0, - data: vec![], + layout, + view_content_data: vec![], }; let view = FolderEventBuilder::new(sdk.clone()) .event(CreateView) diff --git a/frontend/rust-lib/flowy-grid/Cargo.toml b/frontend/rust-lib/flowy-grid/Cargo.toml index ba3702038e..709359560b 100644 --- a/frontend/rust-lib/flowy-grid/Cargo.toml +++ b/frontend/rust-lib/flowy-grid/Cargo.toml @@ -40,6 +40,7 @@ regex = "1.5.6" url = { version = "2"} futures = "0.3.15" atomic_refcell = "0.1.8" +crossbeam-utils = "0.8.7" [dev-dependencies] flowy-test = { path = "../flowy-test" } diff --git a/frontend/rust-lib/flowy-grid/src/dart_notification.rs b/frontend/rust-lib/flowy-grid/src/dart_notification.rs index 202b12eb81..0bba5bbc11 100644 --- a/frontend/rust-lib/flowy-grid/src/dart_notification.rs +++ b/frontend/rust-lib/flowy-grid/src/dart_notification.rs @@ -11,6 +11,7 @@ pub enum GridNotification { DidUpdateRow = 30, DidUpdateCell = 40, DidUpdateField = 50, + DidUpdateGroup = 60, } impl std::default::Default for GridNotification { diff --git a/frontend/rust-lib/flowy-grid/src/entities/block_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/block_entities.rs index f778186903..bb7eec9032 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/block_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/block_entities.rs @@ -4,24 +4,24 @@ use flowy_grid_data_model::parser::NotEmptyStr; use flowy_grid_data_model::revision::RowRevision; use std::sync::Arc; -/// [GridBlockPB] contains list of row ids. The rows here does not contain any data, just the id -/// of the row. Check out [GridRowPB] for more details. +/// [BlockPB] contains list of row ids. The rows here does not contain any data, just the id +/// of the row. Check out [RowPB] for more details. /// /// /// A grid can have many rows. Rows are therefore grouped into Blocks in order to make /// things more efficient. /// | #[derive(Debug, Clone, Default, ProtoBuf)] -pub struct GridBlockPB { +pub struct BlockPB { #[pb(index = 1)] pub id: String, #[pb(index = 2)] - pub rows: Vec, + pub rows: Vec, } -impl GridBlockPB { - pub fn new(block_id: &str, rows: Vec) -> Self { +impl BlockPB { + pub fn new(block_id: &str, rows: Vec) -> Self { Self { id: block_id.to_owned(), rows, @@ -29,9 +29,9 @@ impl GridBlockPB { } } -/// [GridRowPB] Describes a row. Has the id of the parent Block. Has the metadata of the row. +/// [RowPB] Describes a row. Has the id of the parent Block. Has the metadata of the row. #[derive(Debug, Default, Clone, ProtoBuf)] -pub struct GridRowPB { +pub struct RowPB { #[pb(index = 1)] pub block_id: String, @@ -42,7 +42,7 @@ pub struct GridRowPB { pub height: i32, } -impl GridRowPB { +impl RowPB { pub fn row_id(&self) -> &str { &self.id } @@ -52,7 +52,7 @@ impl GridRowPB { } } -impl std::convert::From<&RowRevision> for GridRowPB { +impl std::convert::From<&RowRevision> for RowPB { fn from(rev: &RowRevision) -> Self { Self { block_id: rev.block_id.clone(), @@ -62,7 +62,17 @@ impl std::convert::From<&RowRevision> for GridRowPB { } } -impl std::convert::From<&Arc> for GridRowPB { +impl std::convert::From<&mut RowRevision> for RowPB { + fn from(rev: &mut RowRevision) -> Self { + Self { + block_id: rev.block_id.clone(), + id: rev.id.clone(), + height: rev.height, + } + } +} + +impl std::convert::From<&Arc> for RowPB { fn from(rev: &Arc) -> Self { Self { block_id: rev.block_id.clone(), @@ -75,30 +85,30 @@ impl std::convert::From<&Arc> for GridRowPB { #[derive(Debug, Default, ProtoBuf)] pub struct OptionalRowPB { #[pb(index = 1, one_of)] - pub row: Option, + pub row: Option, } #[derive(Debug, Default, ProtoBuf)] pub struct RepeatedRowPB { #[pb(index = 1)] - pub items: Vec, + pub items: Vec, } -impl std::convert::From> for RepeatedRowPB { - fn from(items: Vec) -> Self { +impl std::convert::From> for RepeatedRowPB { + fn from(items: Vec) -> Self { Self { items } } } -/// [RepeatedGridBlockPB] contains list of [GridBlockPB] +/// [RepeatedBlockPB] contains list of [BlockPB] #[derive(Debug, Default, ProtoBuf)] -pub struct RepeatedGridBlockPB { +pub struct RepeatedBlockPB { #[pb(index = 1)] - pub items: Vec, + pub items: Vec, } -impl std::convert::From> for RepeatedGridBlockPB { - fn from(items: Vec) -> Self { +impl std::convert::From> for RepeatedBlockPB { + fn from(items: Vec) -> Self { Self { items } } } @@ -106,54 +116,27 @@ impl std::convert::From> for RepeatedGridBlockPB { #[derive(Debug, Clone, Default, ProtoBuf)] pub struct InsertedRowPB { #[pb(index = 1)] - pub block_id: String, + pub row: RowPB, - #[pb(index = 2)] - pub row_id: String, - - #[pb(index = 3)] - pub height: i32, - - #[pb(index = 4, one_of)] + #[pb(index = 2, one_of)] pub index: Option, } -#[derive(Debug, Default, ProtoBuf)] -pub struct UpdatedRowPB { - #[pb(index = 1)] - pub block_id: String, - - #[pb(index = 2)] - pub row_id: String, - - #[pb(index = 3)] - pub row: GridRowPB, -} - -impl UpdatedRowPB { - pub fn new(row_rev: &RowRevision, row: GridRowPB) -> Self { - Self { - row_id: row_rev.id.clone(), - block_id: row_rev.block_id.clone(), - row, - } +impl InsertedRowPB { + pub fn new(row: RowPB) -> Self { + Self { row, index: None } } } -impl std::convert::From for InsertedRowPB { - fn from(row_info: GridRowPB) -> Self { - Self { - row_id: row_info.id, - block_id: row_info.block_id, - height: row_info.height, - index: None, - } +impl std::convert::From for InsertedRowPB { + fn from(row: RowPB) -> Self { + Self { row, index: None } } } impl std::convert::From<&RowRevision> for InsertedRowPB { fn from(row: &RowRevision) -> Self { - let row_order = GridRowPB::from(row); + let row_order = RowPB::from(row); Self::from(row_order) } } @@ -170,7 +153,7 @@ pub struct GridBlockChangesetPB { pub deleted_rows: Vec, #[pb(index = 4)] - pub updated_rows: Vec, + pub updated_rows: Vec, #[pb(index = 5)] pub visible_rows: Vec, @@ -179,9 +162,9 @@ pub struct GridBlockChangesetPB { pub hide_rows: Vec, } impl GridBlockChangesetPB { - pub fn insert(block_id: &str, inserted_rows: Vec) -> Self { + pub fn insert(block_id: String, inserted_rows: Vec) -> Self { Self { - block_id: block_id.to_owned(), + block_id, inserted_rows, ..Default::default() } @@ -195,7 +178,7 @@ impl GridBlockChangesetPB { } } - pub fn update(block_id: &str, updated_rows: Vec) -> Self { + pub fn update(block_id: &str, updated_rows: Vec) -> Self { Self { block_id: block_id.to_owned(), updated_rows, @@ -204,10 +187,10 @@ impl GridBlockChangesetPB { } } -/// [QueryGridBlocksPayloadPB] is used to query the data of the block that belongs to the grid whose +/// [QueryBlocksPayloadPB] is used to query the data of the block that belongs to the grid whose /// id is grid_id. #[derive(ProtoBuf, Default)] -pub struct QueryGridBlocksPayloadPB { +pub struct QueryBlocksPayloadPB { #[pb(index = 1)] pub grid_id: String, @@ -220,7 +203,7 @@ pub struct QueryGridBlocksParams { pub block_ids: Vec, } -impl TryInto for QueryGridBlocksPayloadPB { +impl TryInto for QueryBlocksPayloadPB { type Error = ErrorCode; fn try_into(self) -> Result { diff --git a/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs index 1b86eb1e65..3493f0940c 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/cell_entities.rs @@ -1,7 +1,7 @@ use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; -use flowy_grid_data_model::revision::{CellRevision, RowMetaChangeset}; +use flowy_grid_data_model::revision::{CellRevision, RowChangeset}; use std::collections::HashMap; #[derive(ProtoBuf, Default)] @@ -131,20 +131,20 @@ pub struct CellChangesetPB { #[pb(index = 3)] pub field_id: String, - #[pb(index = 4, one_of)] - pub content: Option, + #[pb(index = 4)] + pub content: String, } -impl std::convert::From for RowMetaChangeset { +impl std::convert::From for RowChangeset { fn from(changeset: CellChangesetPB) -> Self { let mut cell_by_field_id = HashMap::with_capacity(1); let field_id = changeset.field_id; let cell_rev = CellRevision { - data: changeset.content.unwrap_or_else(|| "".to_owned()), + data: changeset.content, }; cell_by_field_id.insert(field_id, cell_rev); - RowMetaChangeset { + RowChangeset { row_id: changeset.row_id, height: None, visibility: None, diff --git a/frontend/rust-lib/flowy-grid/src/entities/field_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/field_entities.rs index dce77787c3..8875b3fc26 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/field_entities.rs @@ -8,9 +8,9 @@ use std::sync::Arc; use strum_macros::{Display, EnumCount as EnumCountMacro, EnumIter, EnumString}; -/// [GridFieldPB] defines a Field's attributes. Such as the name, field_type, and width. etc. +/// [FieldPB] defines a Field's attributes. Such as the name, field_type, and width. etc. #[derive(Debug, Clone, Default, ProtoBuf)] -pub struct GridFieldPB { +pub struct FieldPB { #[pb(index = 1)] pub id: String, @@ -36,13 +36,13 @@ pub struct GridFieldPB { pub is_primary: bool, } -impl std::convert::From for GridFieldPB { +impl std::convert::From for FieldPB { fn from(field_rev: FieldRevision) -> Self { Self { id: field_rev.id, name: field_rev.name, desc: field_rev.desc, - field_type: field_rev.field_type_rev.into(), + field_type: field_rev.ty.into(), frozen: field_rev.frozen, visibility: field_rev.visibility, width: field_rev.width, @@ -51,33 +51,33 @@ impl std::convert::From for GridFieldPB { } } -impl std::convert::From> for GridFieldPB { +impl std::convert::From> for FieldPB { fn from(field_rev: Arc) -> Self { let field_rev = field_rev.as_ref().clone(); - GridFieldPB::from(field_rev) + FieldPB::from(field_rev) } } -/// [GridFieldIdPB] id of the [Field] +/// [FieldIdPB] id of the [Field] #[derive(Debug, Clone, Default, ProtoBuf)] -pub struct GridFieldIdPB { +pub struct FieldIdPB { #[pb(index = 1)] pub field_id: String, } -impl std::convert::From<&str> for GridFieldIdPB { +impl std::convert::From<&str> for FieldIdPB { fn from(s: &str) -> Self { - GridFieldIdPB { field_id: s.to_owned() } + FieldIdPB { field_id: s.to_owned() } } } -impl std::convert::From for GridFieldIdPB { +impl std::convert::From for FieldIdPB { fn from(s: String) -> Self { - GridFieldIdPB { field_id: s } + FieldIdPB { field_id: s } } } -impl std::convert::From<&Arc> for GridFieldIdPB { +impl std::convert::From<&Arc> for FieldIdPB { fn from(field_rev: &Arc) -> Self { Self { field_id: field_rev.id.clone(), @@ -85,7 +85,7 @@ impl std::convert::From<&Arc> for GridFieldIdPB { } } #[derive(Debug, Clone, Default, ProtoBuf)] -pub struct GridFieldChangesetPB { +pub struct FieldChangesetPB { #[pb(index = 1)] pub grid_id: String, @@ -93,13 +93,13 @@ pub struct GridFieldChangesetPB { pub inserted_fields: Vec, #[pb(index = 3)] - pub deleted_fields: Vec, + pub deleted_fields: Vec, #[pb(index = 4)] - pub updated_fields: Vec, + pub updated_fields: Vec, } -impl GridFieldChangesetPB { +impl FieldChangesetPB { pub fn insert(grid_id: &str, inserted_fields: Vec) -> Self { Self { grid_id: grid_id.to_owned(), @@ -109,7 +109,7 @@ impl GridFieldChangesetPB { } } - pub fn delete(grid_id: &str, deleted_fields: Vec) -> Self { + pub fn delete(grid_id: &str, deleted_fields: Vec) -> Self { Self { grid_id: grid_id.to_string(), inserted_fields: vec![], @@ -118,7 +118,7 @@ impl GridFieldChangesetPB { } } - pub fn update(grid_id: &str, updated_fields: Vec) -> Self { + pub fn update(grid_id: &str, updated_fields: Vec) -> Self { Self { grid_id: grid_id.to_string(), inserted_fields: vec![], @@ -131,7 +131,7 @@ impl GridFieldChangesetPB { #[derive(Debug, Clone, Default, ProtoBuf)] pub struct IndexFieldPB { #[pb(index = 1)] - pub field: GridFieldPB, + pub field: FieldPB, #[pb(index = 2)] pub index: i32, @@ -140,7 +140,7 @@ pub struct IndexFieldPB { impl IndexFieldPB { pub fn from_field_rev(field_rev: &Arc, index: usize) -> Self { Self { - field: GridFieldPB::from(field_rev.as_ref().clone()), + field: FieldPB::from(field_rev.as_ref().clone()), index: index as i32, } } @@ -164,18 +164,11 @@ pub struct CreateFieldPayloadPB { pub grid_id: String, #[pb(index = 2)] - pub field_id: String, - - #[pb(index = 3)] pub field_type: FieldType, - - #[pb(index = 4)] - pub create_if_not_exist: bool, } pub struct CreateFieldParams { pub grid_id: String, - pub field_id: String, pub field_type: FieldType, } @@ -184,10 +177,8 @@ impl TryInto for CreateFieldPayloadPB { fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; - let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; Ok(CreateFieldParams { grid_id: grid_id.0, - field_id: field_id.0, field_type: self.field_type, }) } @@ -229,7 +220,7 @@ impl TryInto for EditFieldPayloadPB { } #[derive(Debug, Default, ProtoBuf)] -pub struct GridFieldTypeOptionIdPB { +pub struct FieldTypeOptionIdPB { #[pb(index = 1)] pub grid_id: String, @@ -240,19 +231,19 @@ pub struct GridFieldTypeOptionIdPB { pub field_type: FieldType, } -pub struct GridFieldTypeOptionIdParams { +pub struct FieldTypeOptionIdParams { pub grid_id: String, pub field_id: String, pub field_type: FieldType, } -impl TryInto for GridFieldTypeOptionIdPB { +impl TryInto for FieldTypeOptionIdPB { type Error = ErrorCode; - fn try_into(self) -> Result { + fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; - Ok(GridFieldTypeOptionIdParams { + Ok(FieldTypeOptionIdParams { grid_id: grid_id.0, field_id: field_id.0, field_type: self.field_type, @@ -273,60 +264,60 @@ pub struct FieldTypeOptionDataPB { pub grid_id: String, #[pb(index = 2)] - pub field: GridFieldPB, + pub field: FieldPB, #[pb(index = 3)] pub type_option_data: Vec, } -/// Collection of the [GridFieldPB] +/// Collection of the [FieldPB] #[derive(Debug, Default, ProtoBuf)] -pub struct RepeatedGridFieldPB { +pub struct RepeatedFieldPB { #[pb(index = 1)] - pub items: Vec, + pub items: Vec, } -impl std::ops::Deref for RepeatedGridFieldPB { - type Target = Vec; +impl std::ops::Deref for RepeatedFieldPB { + type Target = Vec; fn deref(&self) -> &Self::Target { &self.items } } -impl std::ops::DerefMut for RepeatedGridFieldPB { +impl std::ops::DerefMut for RepeatedFieldPB { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.items } } -impl std::convert::From> for RepeatedGridFieldPB { - fn from(items: Vec) -> Self { +impl std::convert::From> for RepeatedFieldPB { + fn from(items: Vec) -> Self { Self { items } } } #[derive(Debug, Clone, Default, ProtoBuf)] -pub struct RepeatedGridFieldIdPB { +pub struct RepeatedFieldIdPB { #[pb(index = 1)] - pub items: Vec, + pub items: Vec, } -impl std::ops::Deref for RepeatedGridFieldIdPB { - type Target = Vec; +impl std::ops::Deref for RepeatedFieldIdPB { + type Target = Vec; fn deref(&self) -> &Self::Target { &self.items } } -impl std::convert::From> for RepeatedGridFieldIdPB { - fn from(items: Vec) -> Self { - RepeatedGridFieldIdPB { items } +impl std::convert::From> for RepeatedFieldIdPB { + fn from(items: Vec) -> Self { + RepeatedFieldIdPB { items } } } -impl std::convert::From for RepeatedGridFieldIdPB { +impl std::convert::From for RepeatedFieldIdPB { fn from(s: String) -> Self { - RepeatedGridFieldIdPB { - items: vec![GridFieldIdPB::from(s)], + RepeatedFieldIdPB { + items: vec![FieldIdPB::from(s)], } } } @@ -337,7 +328,7 @@ pub struct InsertFieldPayloadPB { pub grid_id: String, #[pb(index = 2)] - pub field: GridFieldPB, + pub field: FieldPB, #[pb(index = 3)] pub type_option_data: Vec, @@ -349,7 +340,7 @@ pub struct InsertFieldPayloadPB { #[derive(Clone)] pub struct InsertFieldParams { pub grid_id: String, - pub field: GridFieldPB, + pub field: FieldPB, pub type_option_data: Vec, pub start_field_id: Option, } @@ -417,12 +408,12 @@ pub struct QueryFieldPayloadPB { pub grid_id: String, #[pb(index = 2)] - pub field_ids: RepeatedGridFieldIdPB, + pub field_ids: RepeatedFieldIdPB, } pub struct QueryFieldParams { pub grid_id: String, - pub field_ids: RepeatedGridFieldIdPB, + pub field_ids: RepeatedFieldIdPB, } impl TryInto for QueryFieldPayloadPB { @@ -527,6 +518,14 @@ pub enum FieldType { URL = 6, } +pub const RICH_TEXT_FIELD: FieldType = FieldType::RichText; +pub const NUMBER_FIELD: FieldType = FieldType::Number; +pub const DATE_FIELD: FieldType = FieldType::DateTime; +pub const SINGLE_SELECT_FIELD: FieldType = FieldType::SingleSelect; +pub const MULTI_SELECT_FIELD: FieldType = FieldType::MultiSelect; +pub const CHECKBOX_FIELD: FieldType = FieldType::Checkbox; +pub const URL_FIELD: FieldType = FieldType::URL; + impl std::default::Default for FieldType { fn default() -> Self { FieldType::RichText @@ -558,35 +557,39 @@ impl FieldType { } pub fn is_number(&self) -> bool { - self == &FieldType::Number + self == &NUMBER_FIELD } pub fn is_text(&self) -> bool { - self == &FieldType::RichText + self == &RICH_TEXT_FIELD } pub fn is_checkbox(&self) -> bool { - self == &FieldType::Checkbox + self == &CHECKBOX_FIELD } pub fn is_date(&self) -> bool { - self == &FieldType::DateTime + self == &DATE_FIELD } pub fn is_single_select(&self) -> bool { - self == &FieldType::SingleSelect + self == &SINGLE_SELECT_FIELD } pub fn is_multi_select(&self) -> bool { - self == &FieldType::MultiSelect + self == &MULTI_SELECT_FIELD } pub fn is_url(&self) -> bool { - self == &FieldType::URL + self == &URL_FIELD } pub fn is_select_option(&self) -> bool { - self == &FieldType::MultiSelect || self == &FieldType::SingleSelect + self == &MULTI_SELECT_FIELD || self == &SINGLE_SELECT_FIELD + } + + pub fn can_be_group(&self) -> bool { + self.is_select_option() } } @@ -642,13 +645,13 @@ pub struct GridFieldIdentifierPayloadPB { pub grid_id: String, } -impl TryInto for DuplicateFieldPayloadPB { +impl TryInto for DuplicateFieldPayloadPB { type Error = ErrorCode; - fn try_into(self) -> Result { + fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; - Ok(GridFieldIdParams { + Ok(FieldIdParams { grid_id: grid_id.0, field_id: field_id.0, }) @@ -664,20 +667,20 @@ pub struct DeleteFieldPayloadPB { pub grid_id: String, } -impl TryInto for DeleteFieldPayloadPB { +impl TryInto for DeleteFieldPayloadPB { type Error = ErrorCode; - fn try_into(self) -> Result { + fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; let field_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?; - Ok(GridFieldIdParams { + Ok(FieldIdParams { grid_id: grid_id.0, field_id: field_id.0, }) } } -pub struct GridFieldIdParams { +pub struct FieldIdParams { pub field_id: String, pub grid_id: String, } diff --git a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/checkbox_filter.rs b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/checkbox_filter.rs index bb31b7a3be..45a64af245 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/checkbox_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/checkbox_filter.rs @@ -1,10 +1,10 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use flowy_grid_data_model::revision::GridFilterRevision; +use flowy_grid_data_model::revision::FilterConfigurationRevision; use std::sync::Arc; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridCheckboxFilter { +pub struct CheckboxFilterConfigurationPB { #[pb(index = 1)] pub condition: CheckboxCondition, } @@ -40,9 +40,9 @@ impl std::convert::TryFrom for CheckboxCondition { } } -impl std::convert::From> for GridCheckboxFilter { - fn from(rev: Arc) -> Self { - GridCheckboxFilter { +impl std::convert::From> for CheckboxFilterConfigurationPB { + fn from(rev: Arc) -> Self { + CheckboxFilterConfigurationPB { condition: CheckboxCondition::try_from(rev.condition).unwrap_or(CheckboxCondition::IsChecked), } } diff --git a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/date_filter.rs b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/date_filter.rs index 936b95216c..72be45a655 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/date_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/date_filter.rs @@ -2,13 +2,13 @@ use crate::entities::FieldType; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; -use flowy_grid_data_model::revision::GridFilterRevision; +use flowy_grid_data_model::revision::FilterConfigurationRevision; use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::sync::Arc; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridDateFilter { +pub struct DateFilterConfigurationPB { #[pb(index = 1)] pub condition: DateFilterCondition, @@ -120,10 +120,10 @@ impl std::convert::TryFrom for DateFilterCondition { } } } -impl std::convert::From> for GridDateFilter { - fn from(rev: Arc) -> Self { +impl std::convert::From> for DateFilterConfigurationPB { + fn from(rev: Arc) -> Self { let condition = DateFilterCondition::try_from(rev.condition).unwrap_or(DateFilterCondition::DateIs); - let mut filter = GridDateFilter { + let mut filter = DateFilterConfigurationPB { condition, ..Default::default() }; diff --git a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/number_filter.rs b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/number_filter.rs index 097ff5330a..68c7474b8f 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/number_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/number_filter.rs @@ -1,11 +1,11 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use flowy_grid_data_model::revision::GridFilterRevision; +use flowy_grid_data_model::revision::FilterConfigurationRevision; use std::sync::Arc; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridNumberFilter { +pub struct NumberFilterConfigurationPB { #[pb(index = 1)] pub condition: NumberFilterCondition, @@ -55,9 +55,9 @@ impl std::convert::TryFrom for NumberFilterCondition { } } -impl std::convert::From> for GridNumberFilter { - fn from(rev: Arc) -> Self { - GridNumberFilter { +impl std::convert::From> for NumberFilterConfigurationPB { + fn from(rev: Arc) -> Self { + NumberFilterConfigurationPB { condition: NumberFilterCondition::try_from(rev.condition).unwrap_or(NumberFilterCondition::Equal), content: rev.content.clone(), } diff --git a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/select_option_filter.rs b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/select_option_filter.rs index 9eb4ff3fe9..47e07c0b73 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/select_option_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/select_option_filter.rs @@ -1,11 +1,11 @@ use crate::services::field::SelectOptionIds; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use flowy_grid_data_model::revision::GridFilterRevision; +use flowy_grid_data_model::revision::FilterConfigurationRevision; use std::sync::Arc; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridSelectOptionFilter { +pub struct SelectOptionFilterConfigurationPB { #[pb(index = 1)] pub condition: SelectOptionCondition, @@ -47,10 +47,10 @@ impl std::convert::TryFrom for SelectOptionCondition { } } -impl std::convert::From> for GridSelectOptionFilter { - fn from(rev: Arc) -> Self { +impl std::convert::From> for SelectOptionFilterConfigurationPB { + fn from(rev: Arc) -> Self { let ids = SelectOptionIds::from(rev.content.clone()); - GridSelectOptionFilter { + SelectOptionFilterConfigurationPB { condition: SelectOptionCondition::try_from(rev.condition).unwrap_or(SelectOptionCondition::OptionIs), option_ids: ids.into_inner(), } diff --git a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/text_filter.rs b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/text_filter.rs index 7335e89129..802941516d 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/text_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/text_filter.rs @@ -1,10 +1,10 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; -use flowy_grid_data_model::revision::GridFilterRevision; +use flowy_grid_data_model::revision::FilterConfigurationRevision; use std::sync::Arc; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridTextFilter { +pub struct TextFilterConfigurationPB { #[pb(index = 1)] pub condition: TextFilterCondition, @@ -54,9 +54,9 @@ impl std::convert::TryFrom for TextFilterCondition { } } -impl std::convert::From> for GridTextFilter { - fn from(rev: Arc) -> Self { - GridTextFilter { +impl std::convert::From> for TextFilterConfigurationPB { + fn from(rev: Arc) -> Self { + TextFilterConfigurationPB { condition: TextFilterCondition::try_from(rev.condition).unwrap_or(TextFilterCondition::Is), content: rev.content.clone(), } diff --git a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/util.rs b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/util.rs index 03cc3d6111..079b6fd6dc 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/filter_entities/util.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/filter_entities/util.rs @@ -5,39 +5,39 @@ use crate::entities::{ use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; -use flowy_grid_data_model::revision::{FieldRevision, GridFilterRevision}; +use flowy_grid_data_model::revision::{FieldRevision, FilterConfigurationRevision}; use flowy_sync::entities::grid::{CreateGridFilterParams, DeleteFilterParams}; use std::convert::TryInto; use std::sync::Arc; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridFilter { +pub struct GridFilterConfiguration { #[pb(index = 1)] pub id: String, } #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct RepeatedGridFilterPB { +pub struct RepeatedGridConfigurationFilterPB { #[pb(index = 1)] - pub items: Vec, + pub items: Vec, } -impl std::convert::From<&GridFilterRevision> for GridFilter { - fn from(rev: &GridFilterRevision) -> Self { +impl std::convert::From<&FilterConfigurationRevision> for GridFilterConfiguration { + fn from(rev: &FilterConfigurationRevision) -> Self { Self { id: rev.id.clone() } } } -impl std::convert::From>> for RepeatedGridFilterPB { - fn from(revs: Vec>) -> Self { - RepeatedGridFilterPB { +impl std::convert::From>> for RepeatedGridConfigurationFilterPB { + fn from(revs: Vec>) -> Self { + RepeatedGridConfigurationFilterPB { items: revs.into_iter().map(|rev| rev.as_ref().into()).collect(), } } } -impl std::convert::From> for RepeatedGridFilterPB { - fn from(items: Vec) -> Self { +impl std::convert::From> for RepeatedGridConfigurationFilterPB { + fn from(items: Vec) -> Self { Self { items } } } @@ -92,7 +92,7 @@ impl CreateGridFilterPayloadPB { pub fn new>(field_rev: &FieldRevision, condition: T, content: Option) -> Self { Self { field_id: field_rev.id.clone(), - field_type: field_rev.field_type_rev.into(), + field_type: field_rev.ty.into(), condition: condition.into(), content, } diff --git a/frontend/rust-lib/flowy-grid/src/entities/grid_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/grid_entities.rs index 49278afc54..be8cfdeae1 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/grid_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/grid_entities.rs @@ -1,5 +1,5 @@ -use crate::entities::{GridBlockPB, GridFieldIdPB}; -use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use crate::entities::{BlockPB, FieldIdPB}; +use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; @@ -10,10 +10,10 @@ pub struct GridPB { pub id: String, #[pb(index = 2)] - pub fields: Vec, + pub fields: Vec, #[pb(index = 3)] - pub blocks: Vec, + pub blocks: Vec, } #[derive(ProtoBuf, Default)] @@ -52,57 +52,74 @@ impl std::convert::From<&str> for GridBlockIdPB { } } -#[derive(Debug, Clone, ProtoBuf_Enum)] -pub enum MoveItemTypePB { - MoveField = 0, - MoveRow = 1, -} - -impl std::default::Default for MoveItemTypePB { - fn default() -> Self { - MoveItemTypePB::MoveField - } -} - #[derive(Debug, Clone, Default, ProtoBuf)] -pub struct MoveItemPayloadPB { +pub struct MoveFieldPayloadPB { #[pb(index = 1)] pub grid_id: String, #[pb(index = 2)] - pub item_id: String, + pub field_id: String, #[pb(index = 3)] pub from_index: i32, #[pb(index = 4)] pub to_index: i32, - - #[pb(index = 5)] - pub ty: MoveItemTypePB, } #[derive(Clone)] -pub struct MoveItemParams { +pub struct MoveFieldParams { pub grid_id: String, - pub item_id: String, + pub field_id: String, pub from_index: i32, pub to_index: i32, - pub ty: MoveItemTypePB, } -impl TryInto for MoveItemPayloadPB { +impl TryInto for MoveFieldPayloadPB { type Error = ErrorCode; - fn try_into(self) -> Result { + fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; - let item_id = NotEmptyStr::parse(self.item_id).map_err(|_| ErrorCode::InvalidData)?; - Ok(MoveItemParams { + let item_id = NotEmptyStr::parse(self.field_id).map_err(|_| ErrorCode::InvalidData)?; + Ok(MoveFieldParams { grid_id: grid_id.0, - item_id: item_id.0, + field_id: item_id.0, from_index: self.from_index, to_index: self.to_index, - ty: self.ty, + }) + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MoveRowPayloadPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub from_row_id: String, + + #[pb(index = 4)] + pub to_row_id: String, +} + +pub struct MoveRowParams { + pub view_id: String, + pub from_row_id: String, + pub to_row_id: String, +} + +impl TryInto for MoveRowPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let view_id = NotEmptyStr::parse(self.view_id).map_err(|_| ErrorCode::GridViewIdIsEmpty)?; + let from_row_id = NotEmptyStr::parse(self.from_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?; + let to_row_id = NotEmptyStr::parse(self.to_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?; + + Ok(MoveRowParams { + view_id: view_id.0, + from_row_id: from_row_id.0, + to_row_id: to_row_id.0, }) } } diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities.rs deleted file mode 100644 index dda624fc67..0000000000 --- a/frontend/rust-lib/flowy-grid/src/entities/group_entities.rs +++ /dev/null @@ -1,76 +0,0 @@ -use flowy_derive::ProtoBuf; -use flowy_error::ErrorCode; -use flowy_grid_data_model::parser::NotEmptyStr; -use flowy_grid_data_model::revision::GridGroupRevision; -use flowy_sync::entities::grid::CreateGridGroupParams; -use std::convert::TryInto; -use std::sync::Arc; - -#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct GridGroupPB { - #[pb(index = 1)] - pub id: String, - - #[pb(index = 2, one_of)] - pub group_field_id: Option, - - #[pb(index = 3, one_of)] - pub sub_group_field_id: Option, -} - -impl std::convert::From<&GridGroupRevision> for GridGroupPB { - fn from(rev: &GridGroupRevision) -> Self { - GridGroupPB { - id: rev.id.clone(), - group_field_id: rev.field_id.clone(), - sub_group_field_id: rev.sub_field_id.clone(), - } - } -} - -#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct RepeatedGridGroupPB { - #[pb(index = 1)] - pub items: Vec, -} - -impl std::convert::From> for RepeatedGridGroupPB { - fn from(items: Vec) -> Self { - Self { items } - } -} - -impl std::convert::From>> for RepeatedGridGroupPB { - fn from(revs: Vec>) -> Self { - RepeatedGridGroupPB { - items: revs.iter().map(|rev| rev.as_ref().into()).collect(), - } - } -} - -#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] -pub struct CreateGridGroupPayloadPB { - #[pb(index = 1, one_of)] - pub field_id: Option, - - #[pb(index = 2, one_of)] - pub sub_field_id: Option, -} - -impl TryInto for CreateGridGroupPayloadPB { - type Error = ErrorCode; - - fn try_into(self) -> Result { - let field_id = match self.field_id { - None => None, - Some(field_id) => Some(NotEmptyStr::parse(field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?.0), - }; - - let sub_field_id = match self.sub_field_id { - None => None, - Some(field_id) => Some(NotEmptyStr::parse(field_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?.0), - }; - - Ok(CreateGridGroupParams { field_id, sub_field_id }) - } -} diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/board_card.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/board_card.rs new file mode 100644 index 0000000000..1ba3991f96 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/board_card.rs @@ -0,0 +1,28 @@ +use crate::entities::{CreateRowParams, GridLayout}; +use flowy_derive::ProtoBuf; +use flowy_error::ErrorCode; +use flowy_grid_data_model::parser::NotEmptyStr; + +#[derive(ProtoBuf, Debug, Default, Clone)] +pub struct CreateBoardCardPayloadPB { + #[pb(index = 1)] + pub grid_id: String, + + #[pb(index = 2)] + pub group_id: String, +} + +impl TryInto for CreateBoardCardPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; + let group_id = NotEmptyStr::parse(self.group_id).map_err(|_| ErrorCode::GroupIdIsEmpty)?; + Ok(CreateRowParams { + grid_id: grid_id.0, + start_row_id: None, + group_id: Some(group_id.0), + layout: GridLayout::Board, + }) + } +} diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs new file mode 100644 index 0000000000..baa39d91a2 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs @@ -0,0 +1,56 @@ +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct UrlGroupConfigurationPB { + #[pb(index = 1)] + hide_empty: bool, +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct TextGroupConfigurationPB { + #[pb(index = 1)] + hide_empty: bool, +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct SelectOptionGroupConfigurationPB { + #[pb(index = 1)] + hide_empty: bool, +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct NumberGroupConfigurationPB { + #[pb(index = 1)] + hide_empty: bool, +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct DateGroupConfigurationPB { + #[pb(index = 1)] + pub condition: DateCondition, + + #[pb(index = 2)] + hide_empty: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum)] +#[repr(u8)] +pub enum DateCondition { + Relative = 0, + Day = 1, + Week = 2, + Month = 3, + Year = 4, +} + +impl std::default::Default for DateCondition { + fn default() -> Self { + DateCondition::Relative + } +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct CheckboxGroupConfigurationPB { + #[pb(index = 1)] + pub(crate) hide_empty: bool, +} diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs new file mode 100644 index 0000000000..32f7c4543a --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs @@ -0,0 +1,139 @@ +use crate::entities::{FieldType, RowPB}; +use flowy_derive::ProtoBuf; +use flowy_error::ErrorCode; +use flowy_grid_data_model::parser::NotEmptyStr; +use flowy_grid_data_model::revision::GroupConfigurationRevision; +use flowy_sync::entities::grid::{CreateGridGroupParams, DeleteGroupParams}; +use std::convert::TryInto; +use std::sync::Arc; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct GridGroupConfigurationPB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub group_field_id: String, +} + +impl std::convert::From<&GroupConfigurationRevision> for GridGroupConfigurationPB { + fn from(rev: &GroupConfigurationRevision) -> Self { + GridGroupConfigurationPB { + id: rev.id.clone(), + group_field_id: rev.field_id.clone(), + } + } +} + +#[derive(ProtoBuf, Debug, Default, Clone)] +pub struct RepeatedGridGroupPB { + #[pb(index = 1)] + pub items: Vec, +} + +impl std::ops::Deref for RepeatedGridGroupPB { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.items + } +} + +impl std::ops::DerefMut for RepeatedGridGroupPB { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.items + } +} + +#[derive(ProtoBuf, Debug, Default, Clone)] +pub struct GroupPB { + #[pb(index = 1)] + pub field_id: String, + + #[pb(index = 2)] + pub group_id: String, + + #[pb(index = 3)] + pub desc: String, + + #[pb(index = 4)] + pub rows: Vec, +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct RepeatedGridGroupConfigurationPB { + #[pb(index = 1)] + pub items: Vec, +} + +impl std::convert::From> for RepeatedGridGroupConfigurationPB { + fn from(items: Vec) -> Self { + Self { items } + } +} + +impl std::convert::From>> for RepeatedGridGroupConfigurationPB { + fn from(revs: Vec>) -> Self { + RepeatedGridGroupConfigurationPB { + items: revs.iter().map(|rev| rev.as_ref().into()).collect(), + } + } +} + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct CreateGridGroupPayloadPB { + #[pb(index = 1)] + pub field_id: String, + + #[pb(index = 2)] + pub field_type: FieldType, + + #[pb(index = 3, one_of)] + pub content: Option>, +} + +impl TryInto for CreateGridGroupPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let field_id = NotEmptyStr::parse(self.field_id) + .map_err(|_| ErrorCode::FieldIdIsEmpty)? + .0; + + Ok(CreateGridGroupParams { + field_id, + field_type_rev: self.field_type.into(), + content: self.content, + }) + } +} + +#[derive(ProtoBuf, Debug, Default, Clone)] +pub struct DeleteGroupPayloadPB { + #[pb(index = 1)] + pub field_id: String, + + #[pb(index = 2)] + pub group_id: String, + + #[pb(index = 3)] + pub field_type: FieldType, +} + +impl TryInto for DeleteGroupPayloadPB { + type Error = ErrorCode; + + fn try_into(self) -> Result { + let field_id = NotEmptyStr::parse(self.field_id) + .map_err(|_| ErrorCode::FieldIdIsEmpty)? + .0; + let group_id = NotEmptyStr::parse(self.group_id) + .map_err(|_| ErrorCode::FieldIdIsEmpty)? + .0; + + Ok(DeleteGroupParams { + field_id, + field_type_rev: self.field_type.into(), + group_id, + }) + } +} diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs new file mode 100644 index 0000000000..743da70444 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs @@ -0,0 +1,64 @@ +use crate::entities::{InsertedRowPB, RowPB}; +use flowy_derive::ProtoBuf; +use std::fmt::Formatter; + +#[derive(Debug, Default, ProtoBuf)] +pub struct GroupRowsChangesetPB { + #[pb(index = 1)] + pub group_id: String, + + #[pb(index = 2)] + pub inserted_rows: Vec, + + #[pb(index = 3)] + pub deleted_rows: Vec, + + #[pb(index = 4)] + pub updated_rows: Vec, +} + +impl std::fmt::Display for GroupRowsChangesetPB { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + for inserted_row in &self.inserted_rows { + let _ = f.write_fmt(format_args!( + "Insert: {} row at {:?}", + inserted_row.row.id, inserted_row.index + ))?; + } + + for deleted_row in &self.deleted_rows { + let _ = f.write_fmt(format_args!("Delete: {} row", deleted_row))?; + } + + Ok(()) + } +} + +impl GroupRowsChangesetPB { + pub fn is_empty(&self) -> bool { + self.inserted_rows.is_empty() && self.deleted_rows.is_empty() && self.updated_rows.is_empty() + } + pub fn insert(group_id: String, inserted_rows: Vec) -> Self { + Self { + group_id, + inserted_rows, + ..Default::default() + } + } + + pub fn delete(group_id: String, deleted_rows: Vec) -> Self { + Self { + group_id, + deleted_rows, + ..Default::default() + } + } + + pub fn update(group_id: String, updated_rows: Vec) -> Self { + Self { + group_id, + updated_rows, + ..Default::default() + } + } +} diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/mod.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/mod.rs new file mode 100644 index 0000000000..f5daa803bc --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/mod.rs @@ -0,0 +1,9 @@ +mod board_card; +mod configuration; +mod group; +mod group_changeset; + +pub use board_card::*; +pub use configuration::*; +pub use group::*; +pub use group_changeset::*; diff --git a/frontend/rust-lib/flowy-grid/src/entities/row_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/row_entities.rs index 745a5dc368..398351371c 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/row_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/row_entities.rs @@ -1,9 +1,10 @@ +use crate::entities::GridLayout; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; #[derive(Debug, Default, Clone, ProtoBuf)] -pub struct GridRowIdPB { +pub struct RowIdPB { #[pb(index = 1)] pub grid_id: String, @@ -14,21 +15,21 @@ pub struct GridRowIdPB { pub row_id: String, } -pub struct GridRowIdParams { +pub struct RowIdParams { pub grid_id: String, pub block_id: String, pub row_id: String, } -impl TryInto for GridRowIdPB { +impl TryInto for RowIdPB { type Error = ErrorCode; - fn try_into(self) -> Result { + fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; let block_id = NotEmptyStr::parse(self.block_id).map_err(|_| ErrorCode::BlockIdIsEmpty)?; let row_id = NotEmptyStr::parse(self.row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?; - Ok(GridRowIdParams { + Ok(RowIdParams { grid_id: grid_id.0, block_id: block_id.0, row_id: row_id.0, @@ -46,7 +47,7 @@ pub struct BlockRowIdPB { } #[derive(ProtoBuf, Default)] -pub struct CreateRowPayloadPB { +pub struct CreateTableRowPayloadPB { #[pb(index = 1)] pub grid_id: String, @@ -58,16 +59,21 @@ pub struct CreateRowPayloadPB { pub struct CreateRowParams { pub grid_id: String, pub start_row_id: Option, + pub group_id: Option, + pub layout: GridLayout, } -impl TryInto for CreateRowPayloadPB { +impl TryInto for CreateTableRowPayloadPB { type Error = ErrorCode; fn try_into(self) -> Result { let grid_id = NotEmptyStr::parse(self.grid_id).map_err(|_| ErrorCode::GridIdIsEmpty)?; + Ok(CreateRowParams { grid_id: grid_id.0, start_row_id: self.start_row_id, + group_id: None, + layout: GridLayout::Table, }) } } diff --git a/frontend/rust-lib/flowy-grid/src/entities/setting_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/setting_entities.rs index 3564886c4a..89f66dd433 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/setting_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/setting_entities.rs @@ -1,11 +1,11 @@ use crate::entities::{ CreateGridFilterPayloadPB, CreateGridGroupPayloadPB, CreateGridSortPayloadPB, DeleteFilterPayloadPB, - RepeatedGridFilterPB, RepeatedGridGroupPB, RepeatedGridSortPB, + DeleteGroupPayloadPB, RepeatedGridConfigurationFilterPB, RepeatedGridGroupConfigurationPB, RepeatedGridSortPB, }; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; -use flowy_grid_data_model::revision::GridLayoutRevision; +use flowy_grid_data_model::revision::LayoutRevision; use flowy_sync::entities::grid::GridSettingChangesetParams; use std::collections::HashMap; use std::convert::TryInto; @@ -19,13 +19,13 @@ pub struct GridSettingPB { pub layouts: Vec, #[pb(index = 2)] - pub current_layout_type: GridLayoutType, + pub current_layout_type: GridLayout, #[pb(index = 3)] - pub filters_by_field_id: HashMap, + pub filter_configuration_by_field_id: HashMap, #[pb(index = 4)] - pub groups_by_field_id: HashMap, + pub group_configuration_by_field_id: HashMap, #[pb(index = 5)] pub sorts_by_field_id: HashMap, @@ -34,13 +34,13 @@ pub struct GridSettingPB { #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct GridLayoutPB { #[pb(index = 1)] - ty: GridLayoutType, + ty: GridLayout, } impl GridLayoutPB { pub fn all() -> Vec { let mut layouts = vec![]; - for layout_ty in GridLayoutType::iter() { + for layout_ty in GridLayout::iter() { layouts.push(GridLayoutPB { ty: layout_ty }) } @@ -50,31 +50,31 @@ impl GridLayoutPB { #[derive(Debug, Clone, PartialEq, Eq, ProtoBuf_Enum, EnumIter)] #[repr(u8)] -pub enum GridLayoutType { +pub enum GridLayout { Table = 0, Board = 1, } -impl std::default::Default for GridLayoutType { +impl std::default::Default for GridLayout { fn default() -> Self { - GridLayoutType::Table + GridLayout::Table } } -impl std::convert::From for GridLayoutType { - fn from(rev: GridLayoutRevision) -> Self { +impl std::convert::From for GridLayout { + fn from(rev: LayoutRevision) -> Self { match rev { - GridLayoutRevision::Table => GridLayoutType::Table, - GridLayoutRevision::Board => GridLayoutType::Board, + LayoutRevision::Table => GridLayout::Table, + LayoutRevision::Board => GridLayout::Board, } } } -impl std::convert::From for GridLayoutRevision { - fn from(layout: GridLayoutType) -> Self { +impl std::convert::From for LayoutRevision { + fn from(layout: GridLayout) -> Self { match layout { - GridLayoutType::Table => GridLayoutRevision::Table, - GridLayoutType::Board => GridLayoutRevision::Board, + GridLayout::Table => LayoutRevision::Table, + GridLayout::Board => LayoutRevision::Board, } } } @@ -85,7 +85,7 @@ pub struct GridSettingChangesetPayloadPB { pub grid_id: String, #[pb(index = 2)] - pub layout_type: GridLayoutType, + pub layout_type: GridLayout, #[pb(index = 3, one_of)] pub insert_filter: Option, @@ -97,7 +97,7 @@ pub struct GridSettingChangesetPayloadPB { pub insert_group: Option, #[pb(index = 6, one_of)] - pub delete_group: Option, + pub delete_group: Option, #[pb(index = 7, one_of)] pub insert_sort: Option, @@ -130,8 +130,8 @@ impl TryInto for GridSettingChangesetPayloadPB { }; let delete_group = match self.delete_group { + Some(payload) => Some(payload.try_into()?), None => None, - Some(filter_id) => Some(NotEmptyStr::parse(filter_id).map_err(|_| ErrorCode::FieldIdIsEmpty)?.0), }; let insert_sort = match self.insert_sort { diff --git a/frontend/rust-lib/flowy-grid/src/entities/sort_entities.rs b/frontend/rust-lib/flowy-grid/src/entities/sort_entities.rs index b630b000c5..f844b75066 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/sort_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/sort_entities.rs @@ -1,7 +1,7 @@ use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; -use flowy_grid_data_model::revision::GridSortRevision; +use flowy_grid_data_model::revision::SortConfigurationRevision; use flowy_sync::entities::grid::CreateGridSortParams; use std::convert::TryInto; use std::sync::Arc; @@ -15,8 +15,8 @@ pub struct GridSort { pub field_id: Option, } -impl std::convert::From<&GridSortRevision> for GridSort { - fn from(rev: &GridSortRevision) -> Self { +impl std::convert::From<&SortConfigurationRevision> for GridSort { + fn from(rev: &SortConfigurationRevision) -> Self { GridSort { id: rev.id.clone(), @@ -31,8 +31,8 @@ pub struct RepeatedGridSortPB { pub items: Vec, } -impl std::convert::From>> for RepeatedGridSortPB { - fn from(revs: Vec>) -> Self { +impl std::convert::From>> for RepeatedGridSortPB { + fn from(revs: Vec>) -> Self { RepeatedGridSortPB { items: revs.into_iter().map(|rev| rev.as_ref().into()).collect(), } diff --git a/frontend/rust-lib/flowy-grid/src/event_handler.rs b/frontend/rust-lib/flowy-grid/src/event_handler.rs index b0ef43f5ad..3108acd977 100644 --- a/frontend/rust-lib/flowy-grid/src/event_handler.rs +++ b/frontend/rust-lib/flowy-grid/src/event_handler.rs @@ -49,9 +49,9 @@ pub(crate) async fn update_grid_setting_handler( #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn get_grid_blocks_handler( - data: Data, + data: Data, manager: AppData>, -) -> DataResult { +) -> DataResult { let params: QueryGridBlocksParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let repeated_grid_block = editor.get_blocks(Some(params.block_ids)).await?; @@ -62,7 +62,7 @@ pub(crate) async fn get_grid_blocks_handler( pub(crate) async fn get_fields_handler( data: Data, manager: AppData>, -) -> DataResult { +) -> DataResult { let params: QueryFieldParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let field_orders = params @@ -72,7 +72,7 @@ pub(crate) async fn get_fields_handler( .map(|field_order| field_order.field_id) .collect(); let field_revs = editor.get_field_revs(Some(field_orders)).await?; - let repeated_field: RepeatedGridFieldPB = field_revs.into_iter().map(GridFieldPB::from).collect::>().into(); + let repeated_field: RepeatedFieldPB = field_revs.into_iter().map(FieldPB::from).collect::>().into(); data_result(repeated_field) } @@ -116,7 +116,7 @@ pub(crate) async fn delete_field_handler( data: Data, manager: AppData>, ) -> Result<(), FlowyError> { - let params: GridFieldIdParams = data.into_inner().try_into()?; + let params: FieldIdParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let _ = editor.delete_field(¶ms.field_id).await?; Ok(()) @@ -154,7 +154,7 @@ pub(crate) async fn duplicate_field_handler( data: Data, manager: AppData>, ) -> Result<(), FlowyError> { - let params: GridFieldIdParams = data.into_inner().try_into()?; + let params: FieldIdParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let _ = editor.duplicate_field(¶ms.field_id).await?; Ok(()) @@ -163,15 +163,15 @@ pub(crate) async fn duplicate_field_handler( /// Return the FieldTypeOptionData if the Field exists otherwise return record not found error. #[tracing::instrument(level = "trace", skip(data, manager), err)] pub(crate) async fn get_field_type_option_data_handler( - data: Data, + data: Data, manager: AppData>, ) -> DataResult { - let params: GridFieldTypeOptionIdParams = data.into_inner().try_into()?; + let params: FieldTypeOptionIdParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; match editor.get_field_rev(¶ms.field_id).await { None => Err(FlowyError::record_not_found()), Some(field_rev) => { - let field_type = field_rev.field_type_rev.into(); + let field_type = field_rev.ty.into(); let type_option_data = get_type_option_data(&field_rev, &field_type).await?; let data = FieldTypeOptionDataPB { grid_id: params.grid_id, @@ -192,7 +192,7 @@ pub(crate) async fn create_field_type_option_data_handler( let params: CreateFieldParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let field_rev = editor.create_next_field_rev(¶ms.field_type).await?; - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); let type_option_data = get_type_option_data(&field_rev, &field_type).await?; data_result(FieldTypeOptionDataPB { @@ -203,13 +203,13 @@ pub(crate) async fn create_field_type_option_data_handler( } #[tracing::instrument(level = "trace", skip(data, manager), err)] -pub(crate) async fn move_item_handler( - data: Data, +pub(crate) async fn move_field_handler( + data: Data, manager: AppData>, ) -> Result<(), FlowyError> { - let params: MoveItemParams = data.into_inner().try_into()?; + let params: MoveFieldParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; - let _ = editor.move_item(params).await?; + let _ = editor.move_field(params).await?; Ok(()) } @@ -218,34 +218,31 @@ async fn get_type_option_data(field_rev: &FieldRevision, field_type: &FieldType) let s = field_rev .get_type_option_str(field_type) .unwrap_or_else(|| default_type_option_builder_from_type(field_type).entry().json_str()); - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); let builder = type_option_builder_from_json_str(&s, &field_type); let type_option_data = builder.entry().protobuf_bytes().to_vec(); Ok(type_option_data) } -#[tracing::instrument(level = "debug", skip(data, manager), err)] +// #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn get_row_handler( - data: Data, + data: Data, manager: AppData>, ) -> DataResult { - let params: GridRowIdParams = data.into_inner().try_into()?; + let params: RowIdParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; - let row = editor - .get_row_rev(¶ms.row_id) - .await? - .and_then(make_row_from_row_rev); + let row = editor.get_row_rev(¶ms.row_id).await?.map(make_row_from_row_rev); data_result(OptionalRowPB { row }) } #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn delete_row_handler( - data: Data, + data: Data, manager: AppData>, ) -> Result<(), FlowyError> { - let params: GridRowIdParams = data.into_inner().try_into()?; + let params: RowIdParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let _ = editor.delete_row(¶ms.row_id).await?; Ok(()) @@ -253,24 +250,35 @@ pub(crate) async fn delete_row_handler( #[tracing::instrument(level = "debug", skip(data, manager), err)] pub(crate) async fn duplicate_row_handler( - data: Data, + data: Data, manager: AppData>, ) -> Result<(), FlowyError> { - let params: GridRowIdParams = data.into_inner().try_into()?; + let params: RowIdParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(¶ms.grid_id)?; let _ = editor.duplicate_row(¶ms.row_id).await?; Ok(()) } #[tracing::instrument(level = "debug", skip(data, manager), err)] -pub(crate) async fn create_row_handler( - data: Data, +pub(crate) async fn move_row_handler( + data: Data, manager: AppData>, ) -> Result<(), FlowyError> { + let params: MoveRowParams = data.into_inner().try_into()?; + let editor = manager.get_grid_editor(¶ms.view_id)?; + let _ = editor.move_row(params).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(data, manager), err)] +pub(crate) async fn create_table_row_handler( + data: Data, + manager: AppData>, +) -> DataResult { let params: CreateRowParams = data.into_inner().try_into()?; let editor = manager.get_grid_editor(params.grid_id.as_ref())?; - let _ = editor.create_row(params.start_row_id).await?; - Ok(()) + let row = editor.create_row(params).await?; + data_result(row) } // #[tracing::instrument(level = "debug", skip_all, err)] @@ -344,13 +352,15 @@ pub(crate) async fn update_select_option_handler( mut_field_rev.insert_type_option_entry(&*type_option); let _ = editor.replace_field(field_rev).await?; - let changeset = CellChangesetPB { - grid_id: changeset.cell_identifier.grid_id, - row_id: changeset.cell_identifier.row_id, - field_id: changeset.cell_identifier.field_id, - content: cell_content_changeset, - }; - let _ = editor.update_cell(changeset).await?; + if let Some(cell_content_changeset) = cell_content_changeset { + let changeset = CellChangesetPB { + grid_id: changeset.cell_identifier.grid_id, + row_id: changeset.cell_identifier.row_id, + field_id: changeset.cell_identifier.field_id, + content: cell_content_changeset, + }; + let _ = editor.update_cell(changeset).await?; + } } Ok(()) } @@ -374,7 +384,7 @@ pub(crate) async fn get_select_option_handler( let any_cell_data: AnyCellData = match cell_rev { None => AnyCellData { data: "".to_string(), - field_type: field_rev.field_type_rev.into(), + field_type: field_rev.ty.into(), }, Some(cell_rev) => cell_rev.try_into()?, }; @@ -405,3 +415,25 @@ pub(crate) async fn update_date_cell_handler( let _ = editor.update_cell(params.into()).await?; Ok(()) } + +#[tracing::instrument(level = "trace", skip_all, err)] +pub(crate) async fn get_groups_handler( + data: Data, + manager: AppData>, +) -> DataResult { + let params: GridIdPB = data.into_inner(); + let editor = manager.get_grid_editor(¶ms.value)?; + let group = editor.load_groups().await?; + data_result(group) +} + +#[tracing::instrument(level = "debug", skip(data, manager), err)] +pub(crate) async fn create_board_card_handler( + data: Data, + manager: AppData>, +) -> DataResult { + let params: CreateRowParams = data.into_inner().try_into()?; + let editor = manager.get_grid_editor(params.grid_id.as_ref())?; + let row = editor.create_row(params).await?; + data_result(row) +} diff --git a/frontend/rust-lib/flowy-grid/src/event_map.rs b/frontend/rust-lib/flowy-grid/src/event_map.rs index 0855ef0032..55ef3ff4db 100644 --- a/frontend/rust-lib/flowy-grid/src/event_map.rs +++ b/frontend/rust-lib/flowy-grid/src/event_map.rs @@ -20,14 +20,15 @@ pub fn create(grid_manager: Arc) -> Module { .event(GridEvent::DeleteField, delete_field_handler) .event(GridEvent::SwitchToField, switch_to_field_handler) .event(GridEvent::DuplicateField, duplicate_field_handler) - .event(GridEvent::MoveItem, move_item_handler) + .event(GridEvent::MoveField, move_field_handler) .event(GridEvent::GetFieldTypeOption, get_field_type_option_data_handler) .event(GridEvent::CreateFieldTypeOption, create_field_type_option_data_handler) // Row - .event(GridEvent::CreateRow, create_row_handler) + .event(GridEvent::CreateTableRow, create_table_row_handler) .event(GridEvent::GetRow, get_row_handler) .event(GridEvent::DeleteRow, delete_row_handler) .event(GridEvent::DuplicateRow, duplicate_row_handler) + .event(GridEvent::MoveRow, move_row_handler) // Cell .event(GridEvent::GetCell, get_cell_handler) .event(GridEvent::UpdateCell, update_cell_handler) @@ -37,7 +38,10 @@ pub fn create(grid_manager: Arc) -> Module { .event(GridEvent::GetSelectOptionCellData, get_select_option_handler) .event(GridEvent::UpdateSelectOptionCell, update_select_option_cell_handler) // Date - .event(GridEvent::UpdateDateCell, update_date_cell_handler); + .event(GridEvent::UpdateDateCell, update_date_cell_handler) + // Group + .event(GridEvent::CreateBoardCard, create_board_card_handler) + .event(GridEvent::GetGroup, get_groups_handler); module } @@ -55,9 +59,9 @@ pub enum GridEvent { /// [GetGridBlocks] event is used to get the grid's block. /// - /// The event handler accepts a [QueryGridBlocksPayloadPB] and returns a [RepeatedGridBlockPB] + /// The event handler accepts a [QueryBlocksPayloadPB] and returns a [RepeatedBlockPB] /// if there are no errors. - #[event(input = "QueryGridBlocksPayloadPB", output = "RepeatedGridBlockPB")] + #[event(input = "QueryBlocksPayloadPB", output = "RepeatedBlockPB")] GetGridBlocks = 1, /// [GetGridSetting] event is used to get the grid's settings. @@ -75,9 +79,9 @@ pub enum GridEvent { /// [GetFields] event is used to get the grid's settings. /// - /// The event handler accepts a [QueryFieldPayloadPB] and returns a [RepeatedGridFieldPB] + /// The event handler accepts a [QueryFieldPayloadPB] and returns a [RepeatedFieldPB] /// if there are no errors. - #[event(input = "QueryFieldPayloadPB", output = "RepeatedGridFieldPB")] + #[event(input = "QueryFieldPayloadPB", output = "RepeatedFieldPB")] GetFields = 10, /// [UpdateField] event is used to update a field's attributes. @@ -127,16 +131,16 @@ pub enum GridEvent { /// [MoveItem] event is used to move an item. For the moment, Item has two types defined in /// [MoveItemTypePB]. - #[event(input = "MoveItemPayloadPB")] - MoveItem = 22, + #[event(input = "MoveFieldPayloadPB")] + MoveField = 22, - /// [GetFieldTypeOption] event is used to get the FieldTypeOption data for a specific field type. + /// [FieldTypeOptionIdPB] event is used to get the FieldTypeOption data for a specific field type. /// /// Check out the [FieldTypeOptionDataPB] for more details. If the [FieldTypeOptionData] does exist /// for the target type, the [TypeOptionBuilder] will create the default data for that type. /// /// Return the [FieldTypeOptionDataPB] if there are no errors. - #[event(input = "GridFieldTypeOptionIdPB", output = "FieldTypeOptionDataPB")] + #[event(input = "FieldTypeOptionIdPB", output = "FieldTypeOptionDataPB")] GetFieldTypeOption = 23, /// [CreateFieldTypeOption] event is used to create a new FieldTypeOptionData. @@ -163,20 +167,23 @@ pub enum GridEvent { #[event(input = "SelectOptionChangesetPayloadPB")] UpdateSelectOption = 32, - #[event(input = "CreateRowPayloadPB", output = "GridRowPB")] - CreateRow = 50, + #[event(input = "CreateTableRowPayloadPB", output = "RowPB")] + CreateTableRow = 50, - /// [GetRow] event is used to get the row data,[GridRowPB]. [OptionalRowPB] is a wrapper that enables + /// [GetRow] event is used to get the row data,[RowPB]. [OptionalRowPB] is a wrapper that enables /// to return a nullable row data. - #[event(input = "GridRowIdPB", output = "OptionalRowPB")] + #[event(input = "RowIdPB", output = "OptionalRowPB")] GetRow = 51, - #[event(input = "GridRowIdPB")] + #[event(input = "RowIdPB")] DeleteRow = 52, - #[event(input = "GridRowIdPB")] + #[event(input = "RowIdPB")] DuplicateRow = 53, + #[event(input = "MoveRowPayloadPB")] + MoveRow = 54, + #[event(input = "GridCellIdPB", output = "GridCellPB")] GetCell = 70, @@ -204,4 +211,10 @@ pub enum GridEvent { /// will be used by the `update_cell` function. #[event(input = "DateChangesetPayloadPB")] UpdateDateCell = 80, + + #[event(input = "GridIdPB", output = "RepeatedGridGroupPB")] + GetGroup = 100, + + #[event(input = "CreateBoardCardPayloadPB", output = "RowPB")] + CreateBoardCard = 110, } diff --git a/frontend/rust-lib/flowy-grid/src/manager.rs b/frontend/rust-lib/flowy-grid/src/manager.rs index 19a3669c46..9e4556b793 100644 --- a/frontend/rust-lib/flowy-grid/src/manager.rs +++ b/frontend/rust-lib/flowy-grid/src/manager.rs @@ -1,5 +1,6 @@ -use crate::services::block_revision_editor::GridBlockRevisionCompactor; +use crate::services::block_editor::GridBlockRevisionCompactor; use crate::services::grid_editor::{GridRevisionCompactor, GridRevisionEditor}; +use crate::services::grid_view_manager::make_grid_view_rev_manager; use crate::services::persistence::block_index::BlockIndexCache; use crate::services::persistence::kv::GridKVPersistence; use crate::services::persistence::migration::GridMigration; @@ -9,10 +10,10 @@ use bytes::Bytes; use dashmap::DashMap; use flowy_database::ConnectionPool; use flowy_error::{FlowyError, FlowyResult}; -use flowy_grid_data_model::revision::{BuildGridContext, GridRevision}; +use flowy_grid_data_model::revision::{BuildGridContext, GridRevision, GridViewRevision}; use flowy_revision::disk::{SQLiteGridBlockRevisionPersistence, SQLiteGridRevisionPersistence}; use flowy_revision::{RevisionManager, RevisionPersistence, RevisionWebSocket, SQLiteRevisionSnapshotPersistence}; -use flowy_sync::client_grid::{make_grid_block_delta, make_grid_delta}; +use flowy_sync::client_grid::{make_grid_block_delta, make_grid_delta, make_grid_view_delta}; use flowy_sync::entities::revision::{RepeatedRevision, Revision}; use std::sync::Arc; use tokio::sync::RwLock; @@ -70,6 +71,15 @@ impl GridManager { let db_pool = self.grid_user.db_pool()?; let rev_manager = self.make_grid_rev_manager(grid_id, db_pool)?; let _ = rev_manager.reset_object(revisions).await?; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all, err)] + async fn create_grid_view>(&self, view_id: T, revisions: RepeatedRevision) -> FlowyResult<()> { + let view_id = view_id.as_ref(); + let rev_manager = make_grid_view_rev_manager(&self.grid_user, view_id).await?; + let _ = rev_manager.reset_object(revisions).await?; Ok(()) } @@ -86,7 +96,7 @@ impl GridManager { pub async fn open_grid>(&self, grid_id: T) -> FlowyResult> { let grid_id = grid_id.as_ref(); tracing::Span::current().record("grid_id", &grid_id); - let _ = self.migration.migration_grid_if_need(grid_id).await; + let _ = self.migration.run_v1_migration(grid_id).await; self.get_or_create_grid_editor(grid_id).await } @@ -183,7 +193,7 @@ pub async fn make_grid_view_data( grid_manager: Arc, build_context: BuildGridContext, ) -> FlowyResult { - for block_meta_data in &build_context.blocks_meta_data { + for block_meta_data in &build_context.blocks { let block_id = &block_meta_data.block_id; // Indexing the block's rows block_meta_data.rows.iter().for_each(|row| { @@ -198,14 +208,24 @@ pub async fn make_grid_view_data( let _ = grid_manager.create_grid_block(&block_id, repeated_revision).await?; } - let grid_rev = GridRevision::from_build_context(view_id, build_context); + // Will replace the grid_id with the value returned by the gen_grid_id() + let grid_id = view_id.to_owned(); + let grid_rev = GridRevision::from_build_context(&grid_id, build_context); // Create grid - let grid_meta_delta = make_grid_delta(&grid_rev); - let grid_delta_data = grid_meta_delta.json_bytes(); + let grid_rev_delta = make_grid_delta(&grid_rev); + let grid_rev_delta_bytes = grid_rev_delta.json_bytes(); let repeated_revision: RepeatedRevision = - Revision::initial_revision(user_id, view_id, grid_delta_data.clone()).into(); - let _ = grid_manager.create_grid(view_id, repeated_revision).await?; + Revision::initial_revision(user_id, &grid_id, grid_rev_delta_bytes.clone()).into(); + let _ = grid_manager.create_grid(&grid_id, repeated_revision).await?; - Ok(grid_delta_data) + // Create grid view + let grid_view = GridViewRevision::new(grid_id, view_id.to_owned()); + let grid_view_delta = make_grid_view_delta(&grid_view); + let grid_view_delta_bytes = grid_view_delta.json_bytes(); + let repeated_revision: RepeatedRevision = + Revision::initial_revision(user_id, view_id, grid_view_delta_bytes).into(); + let _ = grid_manager.create_grid_view(view_id, repeated_revision).await?; + + Ok(grid_rev_delta_bytes) } diff --git a/frontend/rust-lib/flowy-grid/src/services/block_revision_editor.rs b/frontend/rust-lib/flowy-grid/src/services/block_editor.rs similarity index 82% rename from frontend/rust-lib/flowy-grid/src/services/block_revision_editor.rs rename to frontend/rust-lib/flowy-grid/src/services/block_editor.rs index a9f68c5776..f576742c90 100644 --- a/frontend/rust-lib/flowy-grid/src/services/block_revision_editor.rs +++ b/frontend/rust-lib/flowy-grid/src/services/block_editor.rs @@ -1,13 +1,13 @@ -use crate::entities::GridRowPB; +use crate::entities::RowPB; use bytes::Bytes; use flowy_error::{FlowyError, FlowyResult}; -use flowy_grid_data_model::revision::{CellRevision, GridBlockRevision, RowMetaChangeset, RowRevision}; +use flowy_grid_data_model::revision::{CellRevision, GridBlockRevision, RowChangeset, RowRevision}; use flowy_revision::{RevisionCloudService, RevisionCompactor, RevisionManager, RevisionObjectBuilder}; -use flowy_sync::client_grid::{GridBlockMetaChange, GridBlockRevisionPad}; +use flowy_sync::client_grid::{GridBlockRevisionChangeset, GridBlockRevisionPad}; use flowy_sync::entities::revision::Revision; -use flowy_sync::util::make_delta_from_revisions; +use flowy_sync::util::make_text_delta_from_revisions; use lib_infra::future::FutureResult; -use lib_ot::core::PhantomAttributes; + use std::borrow::Cow; use std::sync::Arc; use tokio::sync::RwLock; @@ -29,8 +29,8 @@ impl GridBlockRevisionEditor { let cloud = Arc::new(GridBlockRevisionCloudService { token: token.to_owned(), }); - let block_meta_pad = rev_manager.load::(Some(cloud)).await?; - let pad = Arc::new(RwLock::new(block_meta_pad)); + let block_revision_pad = rev_manager.load::(Some(cloud)).await?; + let pad = Arc::new(RwLock::new(block_revision_pad)); let rev_manager = Arc::new(rev_manager); let user_id = user_id.to_owned(); let block_id = block_id.to_owned(); @@ -59,7 +59,7 @@ impl GridBlockRevisionEditor { if let Some(start_row_id) = prev_row_id.as_ref() { match block_pad.index_of_row(start_row_id) { None => {} - Some(index) => row_index = Some(index + 1), + Some(index) => row_index = Some(index as i32 + 1), } } @@ -88,7 +88,7 @@ impl GridBlockRevisionEditor { Ok(row_count) } - pub async fn update_row(&self, changeset: RowMetaChangeset) -> FlowyResult<()> { + pub async fn update_row(&self, changeset: RowChangeset) -> FlowyResult<()> { let _ = self.modify(|block_pad| Ok(block_pad.update_row(changeset)?)).await?; Ok(()) } @@ -100,6 +100,10 @@ impl GridBlockRevisionEditor { Ok(()) } + pub async fn index_of_row(&self, row_id: &str) -> Option { + self.pad.read().await.index_of_row(row_id) + } + pub async fn get_row_rev(&self, row_id: &str) -> FlowyResult>> { let row_ids = vec![Cow::Borrowed(row_id)]; let row_rev = self.get_row_revs(Some(row_ids)).await?.pop(); @@ -123,12 +127,12 @@ impl GridBlockRevisionEditor { Ok(cell_revs) } - pub async fn get_row_info(&self, row_id: &str) -> FlowyResult> { + pub async fn get_row_pb(&self, row_id: &str) -> FlowyResult> { let row_ids = Some(vec![Cow::Borrowed(row_id)]); Ok(self.get_row_infos(row_ids).await?.pop()) } - pub async fn get_row_infos(&self, row_ids: Option>>) -> FlowyResult> + pub async fn get_row_infos(&self, row_ids: Option>>) -> FlowyResult> where T: AsRef + ToOwned + ?Sized, { @@ -138,14 +142,14 @@ impl GridBlockRevisionEditor { .await .get_row_revs(row_ids)? .iter() - .map(GridRowPB::from) - .collect::>(); + .map(RowPB::from) + .collect::>(); Ok(row_infos) } async fn modify(&self, f: F) -> FlowyResult<()> where - F: for<'a> FnOnce(&'a mut GridBlockRevisionPad) -> FlowyResult>, + F: for<'a> FnOnce(&'a mut GridBlockRevisionPad) -> FlowyResult>, { let mut write_guard = self.pad.write().await; match f(&mut *write_guard)? { @@ -157,8 +161,8 @@ impl GridBlockRevisionEditor { Ok(()) } - async fn apply_change(&self, change: GridBlockMetaChange) -> FlowyResult<()> { - let GridBlockMetaChange { delta, md5 } = change; + async fn apply_change(&self, change: GridBlockRevisionChangeset) -> FlowyResult<()> { + let GridBlockRevisionChangeset { delta, md5 } = change; let user_id = self.user_id.clone(); let (base_rev_id, rev_id) = self.rev_manager.next_rev_id_pair(); let delta_data = delta.json_bytes(); @@ -187,8 +191,8 @@ impl RevisionCloudService for GridBlockRevisionCloudService { } } -struct GridBlockMetaPadBuilder(); -impl RevisionObjectBuilder for GridBlockMetaPadBuilder { +struct GridBlockRevisionPadBuilder(); +impl RevisionObjectBuilder for GridBlockRevisionPadBuilder { type Output = GridBlockRevisionPad; fn build_object(object_id: &str, revisions: Vec) -> FlowyResult { @@ -200,7 +204,7 @@ impl RevisionObjectBuilder for GridBlockMetaPadBuilder { pub struct GridBlockRevisionCompactor(); impl RevisionCompactor for GridBlockRevisionCompactor { fn bytes_from_revisions(&self, revisions: Vec) -> FlowyResult { - let delta = make_delta_from_revisions::(revisions)?; + let delta = make_text_delta_from_revisions(revisions)?; Ok(delta.json_bytes()) } } diff --git a/frontend/rust-lib/flowy-grid/src/services/block_manager.rs b/frontend/rust-lib/flowy-grid/src/services/block_manager.rs index c83bfcc2cd..1bc9b9dc42 100644 --- a/frontend/rust-lib/flowy-grid/src/services/block_manager.rs +++ b/frontend/rust-lib/flowy-grid/src/services/block_manager.rs @@ -1,13 +1,13 @@ use crate::dart_notification::{send_dart_notification, GridNotification}; -use crate::entities::{CellChangesetPB, GridBlockChangesetPB, GridRowPB, InsertedRowPB, UpdatedRowPB}; +use crate::entities::{CellChangesetPB, GridBlockChangesetPB, InsertedRowPB, RowPB}; use crate::manager::GridUser; -use crate::services::block_revision_editor::{GridBlockRevisionCompactor, GridBlockRevisionEditor}; +use crate::services::block_editor::{GridBlockRevisionCompactor, GridBlockRevisionEditor}; use crate::services::persistence::block_index::BlockIndexCache; -use crate::services::row::{block_from_row_orders, GridBlockSnapshot}; +use crate::services::row::{block_from_row_orders, make_row_from_row_rev, GridBlockSnapshot}; use dashmap::DashMap; use flowy_error::FlowyResult; use flowy_grid_data_model::revision::{ - GridBlockMetaRevision, GridBlockMetaRevisionChangeset, RowMetaChangeset, RowRevision, + GridBlockMetaRevision, GridBlockMetaRevisionChangeset, RowChangeset, RowRevision, }; use flowy_revision::disk::SQLiteGridBlockRevisionPersistence; use flowy_revision::{RevisionManager, RevisionPersistence, SQLiteRevisionSnapshotPersistence}; @@ -17,8 +17,6 @@ use std::sync::Arc; type BlockId = String; pub(crate) struct GridBlockManager { - #[allow(dead_code)] - grid_id: String, user: Arc, persistence: Arc, block_editors: DashMap>, @@ -26,16 +24,13 @@ pub(crate) struct GridBlockManager { impl GridBlockManager { pub(crate) async fn new( - grid_id: &str, user: &Arc, block_meta_revs: Vec>, persistence: Arc, ) -> FlowyResult { let block_editors = make_block_editors(user, block_meta_revs).await?; let user = user.clone(); - let grid_id = grid_id.to_owned(); let manager = Self { - grid_id, user, block_editors, persistence, @@ -44,7 +39,7 @@ impl GridBlockManager { } // #[tracing::instrument(level = "trace", skip(self))] - pub(crate) async fn get_editor(&self, block_id: &str) -> FlowyResult> { + pub(crate) async fn get_block_editor(&self, block_id: &str) -> FlowyResult> { debug_assert!(!block_id.is_empty()); match self.block_editors.get(block_id) { None => { @@ -57,27 +52,21 @@ impl GridBlockManager { } } - async fn get_editor_from_row_id(&self, row_id: &str) -> FlowyResult> { + pub(crate) async fn get_editor_from_row_id(&self, row_id: &str) -> FlowyResult> { let block_id = self.persistence.get_block_id(row_id)?; - Ok(self.get_editor(&block_id).await?) + Ok(self.get_block_editor(&block_id).await?) } - pub(crate) async fn create_row( - &self, - block_id: &str, - row_rev: RowRevision, - start_row_id: Option, - ) -> FlowyResult { + pub(crate) async fn create_row(&self, row_rev: RowRevision, start_row_id: Option) -> FlowyResult { + let block_id = row_rev.block_id.clone(); let _ = self.persistence.insert(&row_rev.block_id, &row_rev.id)?; - let editor = self.get_editor(&row_rev.block_id).await?; + let editor = self.get_block_editor(&row_rev.block_id).await?; let mut index_row_order = InsertedRowPB::from(&row_rev); let (row_count, row_index) = editor.create_row(row_rev, start_row_id).await?; index_row_order.index = row_index; - - let _ = self - .notify_did_update_block(block_id, GridBlockChangesetPB::insert(block_id, vec![index_row_order])) - .await?; + let changeset = GridBlockChangesetPB::insert(block_id.clone(), vec![index_row_order]); + let _ = self.notify_did_update_block(&block_id, changeset).await?; Ok(row_count) } @@ -88,7 +77,7 @@ impl GridBlockManager { let mut changesets = vec![]; for (block_id, row_revs) in rows_by_block_id { let mut inserted_row_orders = vec![]; - let editor = self.get_editor(&block_id).await?; + let editor = self.get_block_editor(&block_id).await?; let mut row_count = 0; for row in row_revs { let _ = self.persistence.insert(&row.block_id, &row.id)?; @@ -98,110 +87,110 @@ impl GridBlockManager { row_order.index = index; inserted_row_orders.push(row_order); } - changesets.push(GridBlockMetaRevisionChangeset::from_row_count(&block_id, row_count)); + changesets.push(GridBlockMetaRevisionChangeset::from_row_count( + block_id.clone(), + row_count, + )); let _ = self - .notify_did_update_block(&block_id, GridBlockChangesetPB::insert(&block_id, inserted_row_orders)) + .notify_did_update_block( + &block_id, + GridBlockChangesetPB::insert(block_id.clone(), inserted_row_orders), + ) .await?; } Ok(changesets) } - pub async fn update_row(&self, changeset: RowMetaChangeset, row_builder: F) -> FlowyResult<()> - where - F: FnOnce(Arc) -> Option, - { + pub async fn update_row(&self, changeset: RowChangeset) -> FlowyResult<()> { let editor = self.get_editor_from_row_id(&changeset.row_id).await?; let _ = editor.update_row(changeset.clone()).await?; match editor.get_row_rev(&changeset.row_id).await? { None => tracing::error!("Internal error: can't find the row with id: {}", changeset.row_id), Some(row_rev) => { - if let Some(row) = row_builder(row_rev.clone()) { - let row_order = UpdatedRowPB::new(&row_rev, row); - let block_order_changeset = GridBlockChangesetPB::update(&editor.block_id, vec![row_order]); - let _ = self - .notify_did_update_block(&editor.block_id, block_order_changeset) - .await?; - } - } - } - Ok(()) - } - - pub async fn delete_row(&self, row_id: &str) -> FlowyResult<()> { - let row_id = row_id.to_owned(); - let block_id = self.persistence.get_block_id(&row_id)?; - let editor = self.get_editor(&block_id).await?; - match editor.get_row_info(&row_id).await? { - None => {} - Some(row_info) => { - let _ = editor.delete_rows(vec![Cow::Borrowed(&row_id)]).await?; + let row_pb = make_row_from_row_rev(row_rev.clone()); + let block_order_changeset = GridBlockChangesetPB::update(&editor.block_id, vec![row_pb]); let _ = self - .notify_did_update_block(&block_id, GridBlockChangesetPB::delete(&block_id, vec![row_info.id])) + .notify_did_update_block(&editor.block_id, block_order_changeset) .await?; } } - Ok(()) } - pub(crate) async fn delete_rows( - &self, - row_orders: Vec, - ) -> FlowyResult> { + #[tracing::instrument(level = "trace", skip_all, err)] + pub async fn delete_row(&self, row_id: &str) -> FlowyResult>> { + let row_id = row_id.to_owned(); + let block_id = self.persistence.get_block_id(&row_id)?; + let editor = self.get_block_editor(&block_id).await?; + match editor.get_row_rev(&row_id).await? { + None => Ok(None), + Some(row_rev) => { + let _ = editor.delete_rows(vec![Cow::Borrowed(&row_id)]).await?; + let _ = self + .notify_did_update_block( + &block_id, + GridBlockChangesetPB::delete(&block_id, vec![row_rev.id.clone()]), + ) + .await?; + Ok(Some(row_rev)) + } + } + } + + pub(crate) async fn delete_rows(&self, row_orders: Vec) -> FlowyResult> { let mut changesets = vec![]; for grid_block in block_from_row_orders(row_orders) { - let editor = self.get_editor(&grid_block.id).await?; + let editor = self.get_block_editor(&grid_block.id).await?; let row_ids = grid_block .rows .into_iter() .map(|row_info| Cow::Owned(row_info.row_id().to_owned())) .collect::>>(); let row_count = editor.delete_rows(row_ids).await?; - let changeset = GridBlockMetaRevisionChangeset::from_row_count(&grid_block.id, row_count); + let changeset = GridBlockMetaRevisionChangeset::from_row_count(grid_block.id.clone(), row_count); changesets.push(changeset); } Ok(changesets) } + // This function will be moved to GridViewRevisionEditor + pub(crate) async fn move_row(&self, row_rev: Arc, from: usize, to: usize) -> FlowyResult<()> { + let editor = self.get_editor_from_row_id(&row_rev.id).await?; + let _ = editor.move_row(&row_rev.id, from, to).await?; - pub(crate) async fn move_row(&self, row_id: &str, from: usize, to: usize) -> FlowyResult<()> { - let editor = self.get_editor_from_row_id(row_id).await?; - let _ = editor.move_row(row_id, from, to).await?; + let delete_row_id = row_rev.id.clone(); + let insert_row = InsertedRowPB { + index: Some(to as i32), + row: make_row_from_row_rev(row_rev), + }; - match editor.get_row_revs(Some(vec![Cow::Borrowed(row_id)])).await?.pop() { - None => {} - Some(row_rev) => { - let insert_row = InsertedRowPB { - block_id: row_rev.block_id.clone(), - row_id: row_rev.id.clone(), - index: Some(to as i32), - height: row_rev.height, - }; + let notified_changeset = GridBlockChangesetPB { + block_id: editor.block_id.clone(), + inserted_rows: vec![insert_row], + deleted_rows: vec![delete_row_id], + ..Default::default() + }; - let notified_changeset = GridBlockChangesetPB { - block_id: editor.block_id.clone(), - inserted_rows: vec![insert_row], - deleted_rows: vec![row_rev.id.clone()], - ..Default::default() - }; - - let _ = self - .notify_did_update_block(&editor.block_id, notified_changeset) - .await?; - } - } + let _ = self + .notify_did_update_block(&editor.block_id, notified_changeset) + .await?; Ok(()) } - pub async fn update_cell(&self, changeset: CellChangesetPB, row_builder: F) -> FlowyResult<()> - where - F: FnOnce(Arc) -> Option, - { - let row_changeset: RowMetaChangeset = changeset.clone().into(); - let _ = self.update_row(row_changeset, row_builder).await?; + // This function will be moved to GridViewRevisionEditor. + pub async fn index_of_row(&self, row_id: &str) -> Option { + match self.get_editor_from_row_id(row_id).await { + Ok(editor) => editor.index_of_row(row_id).await, + Err(_) => None, + } + } + + pub async fn update_cell(&self, changeset: CellChangesetPB) -> FlowyResult<()> { + let row_changeset: RowChangeset = changeset.clone().into(); + let _ = self.update_row(row_changeset).await?; self.notify_did_update_cell(changeset).await?; Ok(()) } @@ -217,8 +206,8 @@ impl GridBlockManager { } } - pub async fn get_row_orders(&self, block_id: &str) -> FlowyResult> { - let editor = self.get_editor(block_id).await?; + pub async fn get_row_orders(&self, block_id: &str) -> FlowyResult> { + let editor = self.get_block_editor(block_id).await?; editor.get_row_infos::<&str>(None).await } @@ -238,7 +227,7 @@ impl GridBlockManager { } Some(block_ids) => { for block_id in block_ids { - let editor = self.get_editor(&block_id).await?; + let editor = self.get_block_editor(&block_id).await?; let row_revs = editor.get_row_revs::<&str>(None).await?; snapshots.push(GridBlockSnapshot { block_id, row_revs }); } @@ -261,6 +250,7 @@ impl GridBlockManager { } } +/// Initialize each block editor async fn make_block_editors( user: &Arc, block_meta_revs: Vec>, @@ -275,7 +265,7 @@ async fn make_block_editors( } async fn make_block_editor(user: &Arc, block_id: &str) -> FlowyResult { - tracing::trace!("Open block:{} meta editor", block_id); + tracing::trace!("Open block:{} editor", block_id); let token = user.token()?; let user_id = user.user_id()?; let pool = user.db_pool()?; diff --git a/frontend/rust-lib/flowy-grid/src/services/block_manager_trait_impl.rs b/frontend/rust-lib/flowy-grid/src/services/block_manager_trait_impl.rs new file mode 100644 index 0000000000..3adea9a853 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/block_manager_trait_impl.rs @@ -0,0 +1,38 @@ +use crate::services::block_manager::GridBlockManager; +use crate::services::grid_view_manager::GridViewRowDelegate; + +use flowy_grid_data_model::revision::RowRevision; +use lib_infra::future::{wrap_future, AFFuture}; +use std::sync::Arc; + +impl GridViewRowDelegate for Arc { + fn gv_index_of_row(&self, row_id: &str) -> AFFuture> { + let block_manager = self.clone(); + let row_id = row_id.to_owned(); + wrap_future(async move { block_manager.index_of_row(&row_id).await }) + } + + fn gv_get_row_rev(&self, row_id: &str) -> AFFuture>> { + let block_manager = self.clone(); + let row_id = row_id.to_owned(); + wrap_future(async move { + match block_manager.get_row_rev(&row_id).await { + Ok(row_rev) => row_rev, + Err(_) => None, + } + }) + } + + fn gv_row_revs(&self) -> AFFuture>> { + let block_manager = self.clone(); + + wrap_future(async move { + let blocks = block_manager.get_block_snapshots(None).await.unwrap(); + blocks + .into_iter() + .map(|block| block.row_revs) + .flatten() + .collect::>>() + }) + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/cell/any_cell_data.rs b/frontend/rust-lib/flowy-grid/src/services/cell/any_cell_data.rs index 8ebffcbedc..8f76ea62c5 100644 --- a/frontend/rust-lib/flowy-grid/src/services/cell/any_cell_data.rs +++ b/frontend/rust-lib/flowy-grid/src/services/cell/any_cell_data.rs @@ -114,6 +114,11 @@ impl AnyCellData { pub struct CellBytes(pub Bytes); pub trait CellBytesParser { + type Object; + fn parser(bytes: &Bytes) -> FlowyResult; +} + +pub trait CellBytesCustomParser { type Object; fn parse(&self, bytes: &Bytes) -> FlowyResult; } @@ -132,9 +137,16 @@ impl CellBytes { Ok(Self(bytes)) } - pub fn with_parser

(&self, parser: P) -> FlowyResult + pub fn parser

(&self) -> FlowyResult where P: CellBytesParser, + { + P::parser(&self.0) + } + + pub fn custom_parser

(&self, parser: P) -> FlowyResult + where + P: CellBytesCustomParser, { parser.parse(&self.0) } diff --git a/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs index e34f3a8430..294aff9885 100644 --- a/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-grid/src/services/cell/cell_operation.rs @@ -11,6 +11,10 @@ pub trait CellFilterOperation { fn apply_filter(&self, any_cell_data: AnyCellData, filter: &T) -> FlowyResult; } +pub trait CellGroupOperation { + fn apply_group(&self, any_cell_data: AnyCellData, group_content: &str) -> FlowyResult; +} + /// Return object that describes the cell. pub trait CellDisplayable { fn display_data( @@ -53,17 +57,17 @@ pub fn apply_cell_data_changeset>( ) -> Result { let field_rev = field_rev.as_ref(); let changeset = changeset.to_string(); - let field_type = field_rev.field_type_rev.into(); + let field_type = field_rev.ty.into(); let s = match field_type { - FieldType::RichText => RichTextTypeOption::from(field_rev).apply_changeset(changeset.into(), cell_rev), - FieldType::Number => NumberTypeOption::from(field_rev).apply_changeset(changeset.into(), cell_rev), - FieldType::DateTime => DateTypeOption::from(field_rev).apply_changeset(changeset.into(), cell_rev), + FieldType::RichText => RichTextTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev), + FieldType::Number => NumberTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev), + FieldType::DateTime => DateTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev), FieldType::SingleSelect => { SingleSelectTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev) } - FieldType::MultiSelect => MultiSelectTypeOption::from(field_rev).apply_changeset(changeset.into(), cell_rev), - FieldType::Checkbox => CheckboxTypeOption::from(field_rev).apply_changeset(changeset.into(), cell_rev), - FieldType::URL => URLTypeOption::from(field_rev).apply_changeset(changeset.into(), cell_rev), + FieldType::MultiSelect => MultiSelectTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev), + FieldType::Checkbox => CheckboxTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev), + FieldType::URL => URLTypeOptionPB::from(field_rev).apply_changeset(changeset.into(), cell_rev), }?; Ok(AnyCellData::new(s, field_type).json()) @@ -72,7 +76,7 @@ pub fn apply_cell_data_changeset>( pub fn decode_any_cell_data>(data: T, field_rev: &FieldRevision) -> CellBytes { if let Ok(any_cell_data) = data.try_into() { let AnyCellData { data, field_type } = any_cell_data; - let to_field_type = field_rev.field_type_rev.into(); + let to_field_type = field_rev.ty.into(); match try_decode_cell_data(data.into(), field_rev, &field_type, &to_field_type) { Ok(cell_bytes) => cell_bytes, Err(e) => { @@ -97,25 +101,25 @@ pub fn try_decode_cell_data( let field_type: FieldTypeRevision = t_field_type.into(); let data = match t_field_type { FieldType::RichText => field_rev - .get_type_option_entry::(field_type)? + .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), FieldType::Number => field_rev - .get_type_option_entry::(field_type)? + .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), FieldType::DateTime => field_rev - .get_type_option_entry::(field_type)? + .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), FieldType::SingleSelect => field_rev .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), FieldType::MultiSelect => field_rev - .get_type_option_entry::(field_type)? + .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), FieldType::Checkbox => field_rev - .get_type_option_entry::(field_type)? + .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), FieldType::URL => field_rev - .get_type_option_entry::(field_type)? + .get_type_option_entry::(field_type)? .decode_cell_data(cell_data.into(), s_field_type, field_rev), }; Some(data) @@ -131,6 +135,47 @@ pub fn try_decode_cell_data( } } +pub fn insert_text_cell(s: String, field_rev: &FieldRevision) -> CellRevision { + let data = apply_cell_data_changeset(s, None, field_rev).unwrap(); + CellRevision::new(data) +} + +pub fn insert_number_cell(num: i64, field_rev: &FieldRevision) -> CellRevision { + let data = apply_cell_data_changeset(num, None, field_rev).unwrap(); + CellRevision::new(data) +} + +pub fn insert_url_cell(url: String, field_rev: &FieldRevision) -> CellRevision { + let data = apply_cell_data_changeset(url, None, field_rev).unwrap(); + CellRevision::new(data) +} + +pub fn insert_checkbox_cell(is_check: bool, field_rev: &FieldRevision) -> CellRevision { + let s = if is_check { + CHECK.to_string() + } else { + UNCHECK.to_string() + }; + let data = apply_cell_data_changeset(s, None, field_rev).unwrap(); + CellRevision::new(data) +} + +pub fn insert_date_cell(timestamp: i64, field_rev: &FieldRevision) -> CellRevision { + let cell_data = serde_json::to_string(&DateCellChangesetPB { + date: Some(timestamp.to_string()), + time: None, + }) + .unwrap(); + let data = apply_cell_data_changeset(cell_data, None, field_rev).unwrap(); + CellRevision::new(data) +} + +pub fn insert_select_option_cell(option_id: String, field_rev: &FieldRevision) -> CellRevision { + let cell_data = SelectOptionCellChangeset::from_insert(&option_id).to_str(); + let data = apply_cell_data_changeset(cell_data, None, field_rev).unwrap(); + CellRevision::new(data) +} + /// If the cell data is not String type, it should impl this trait. /// Deserialize the String into cell specific data type. pub trait FromCellString { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/field_builder.rs b/frontend/rust-lib/flowy-grid/src/services/field/field_builder.rs index de1f37b04f..0242a5ca43 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/field_builder.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/field_builder.rs @@ -1,4 +1,4 @@ -use crate::entities::{FieldType, GridFieldPB}; +use crate::entities::{FieldPB, FieldType}; use crate::services::field::type_options::*; use bytes::Bytes; use flowy_grid_data_model::revision::{FieldRevision, TypeOptionDataEntry}; @@ -28,12 +28,12 @@ impl FieldBuilder { Self::new(type_option_builder) } - pub fn from_field(field: GridFieldPB, type_option_builder: Box) -> Self { + pub fn from_field(field: FieldPB, type_option_builder: Box) -> Self { let field_rev = FieldRevision { id: field.id, name: field.name, desc: field.desc, - field_type_rev: field.field_type.into(), + ty: field.field_type.into(), frozen: field.frozen, visibility: field.visibility, width: field.width, @@ -90,13 +90,13 @@ pub trait TypeOptionBuilder { pub fn default_type_option_builder_from_type(field_type: &FieldType) -> Box { let s: String = match field_type { - FieldType::RichText => RichTextTypeOption::default().into(), - FieldType::Number => NumberTypeOption::default().into(), - FieldType::DateTime => DateTypeOption::default().into(), + FieldType::RichText => RichTextTypeOptionPB::default().into(), + FieldType::Number => NumberTypeOptionPB::default().into(), + FieldType::DateTime => DateTypeOptionPB::default().into(), FieldType::SingleSelect => SingleSelectTypeOptionPB::default().into(), - FieldType::MultiSelect => MultiSelectTypeOption::default().into(), - FieldType::Checkbox => CheckboxTypeOption::default().into(), - FieldType::URL => URLTypeOption::default().into(), + FieldType::MultiSelect => MultiSelectTypeOptionPB::default().into(), + FieldType::Checkbox => CheckboxTypeOptionPB::default().into(), + FieldType::URL => URLTypeOptionPB::default().into(), }; type_option_builder_from_json_str(&s, field_type) diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs index ddd1ba049d..838e341666 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_tests.rs @@ -8,7 +8,7 @@ mod tests { #[test] fn checkout_box_description_test() { - let type_option = CheckboxTypeOption::default(); + let type_option = CheckboxTypeOptionPB::default(); let field_type = FieldType::Checkbox; let field_rev = FieldBuilder::from_field_type(&field_type).build(); @@ -27,7 +27,7 @@ mod tests { } fn assert_checkbox( - type_option: &CheckboxTypeOption, + type_option: &CheckboxTypeOptionPB, input_str: &str, expected_str: &str, field_type: &FieldType, diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs index 155965f409..cf668fcc1e 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; #[derive(Default)] -pub struct CheckboxTypeOptionBuilder(CheckboxTypeOption); +pub struct CheckboxTypeOptionBuilder(CheckboxTypeOptionPB); impl_into_box_type_option_builder!(CheckboxTypeOptionBuilder); -impl_builder_from_json_str_and_from_bytes!(CheckboxTypeOptionBuilder, CheckboxTypeOption); +impl_builder_from_json_str_and_from_bytes!(CheckboxTypeOptionBuilder, CheckboxTypeOptionPB); impl CheckboxTypeOptionBuilder { pub fn set_selected(mut self, is_selected: bool) -> Self { @@ -32,13 +32,13 @@ impl TypeOptionBuilder for CheckboxTypeOptionBuilder { } #[derive(Debug, Clone, Serialize, Deserialize, Default, ProtoBuf)] -pub struct CheckboxTypeOption { +pub struct CheckboxTypeOptionPB { #[pb(index = 1)] pub is_selected: bool, } -impl_type_option!(CheckboxTypeOption, FieldType::Checkbox); +impl_type_option!(CheckboxTypeOptionPB, FieldType::Checkbox); -impl CellDisplayable for CheckboxTypeOption { +impl CellDisplayable for CheckboxTypeOptionPB { fn display_data( &self, cell_data: CellData, @@ -50,7 +50,7 @@ impl CellDisplayable for CheckboxTypeOption { } } -impl CellDataOperation for CheckboxTypeOption { +impl CellDataOperation for CheckboxTypeOptionPB { fn decode_cell_data( &self, cell_data: CellData, diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs index 3da9ebd23f..cfc123fad9 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/checkbox_type_option/checkbox_type_option_entities.rs @@ -60,7 +60,7 @@ impl ToString for CheckboxCellData { pub struct CheckboxCellDataParser(); impl CellBytesParser for CheckboxCellDataParser { type Object = CheckboxCellData; - fn parse(&self, bytes: &Bytes) -> FlowyResult { + fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { Ok(s) => CheckboxCellData::from_str(&s), Err(_) => Ok(CheckboxCellData("".to_string())), diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_tests.rs index 1229a8f5e1..ea5a33871a 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_tests.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_tests.rs @@ -3,13 +3,13 @@ mod tests { use crate::entities::FieldType; use crate::services::cell::CellDataOperation; use crate::services::field::*; - // use crate::services::field::{DateCellChangeset, DateCellData, DateFormat, DateTypeOption, TimeFormat}; + // use crate::services::field::{DateCellChangeset, DateCellData, DateFormat, DateTypeOptionPB, TimeFormat}; use flowy_grid_data_model::revision::FieldRevision; use strum::IntoEnumIterator; #[test] fn date_type_option_date_format_test() { - let mut type_option = DateTypeOption::default(); + let mut type_option = DateTypeOptionPB::default(); let field_rev = FieldBuilder::from_field_type(&FieldType::DateTime).build(); for date_format in DateFormat::iter() { type_option.date_format = date_format; @@ -32,7 +32,7 @@ mod tests { #[test] fn date_type_option_different_time_format_test() { - let mut type_option = DateTypeOption::default(); + let mut type_option = DateTypeOptionPB::default(); let field_type = FieldType::DateTime; let field_rev = FieldBuilder::from_field_type(&field_type).build(); @@ -66,7 +66,7 @@ mod tests { #[test] fn date_type_option_invalid_date_str_test() { - let type_option = DateTypeOption::default(); + let type_option = DateTypeOptionPB::default(); let field_type = FieldType::DateTime; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_date(&type_option, "abc", None, "", &field_rev); @@ -75,7 +75,7 @@ mod tests { #[test] #[should_panic] fn date_type_option_invalid_include_time_str_test() { - let mut type_option = DateTypeOption::new(); + let mut type_option = DateTypeOptionPB::new(); type_option.include_time = true; let field_rev = FieldBuilder::from_field_type(&FieldType::DateTime).build(); @@ -90,7 +90,7 @@ mod tests { #[test] fn date_type_option_empty_include_time_str_test() { - let mut type_option = DateTypeOption::new(); + let mut type_option = DateTypeOptionPB::new(); type_option.include_time = true; let field_rev = FieldBuilder::from_field_type(&FieldType::DateTime).build(); @@ -101,7 +101,7 @@ mod tests { #[test] #[should_panic] fn date_type_option_twelve_hours_include_time_str_in_twenty_four_hours_format() { - let mut type_option = DateTypeOption::new(); + let mut type_option = DateTypeOptionPB::new(); type_option.include_time = true; let field_rev = FieldBuilder::from_field_type(&FieldType::DateTime).build(); @@ -114,7 +114,7 @@ mod tests { ); } fn assert_date( - type_option: &DateTypeOption, + type_option: &DateTypeOptionPB, timestamp: T, include_time_str: Option, expected_str: &str, @@ -133,11 +133,11 @@ mod tests { ); } - fn decode_cell_data(encoded_data: String, type_option: &DateTypeOption, field_rev: &FieldRevision) -> String { + fn decode_cell_data(encoded_data: String, type_option: &DateTypeOptionPB, field_rev: &FieldRevision) -> String { let decoded_data = type_option .decode_cell_data(encoded_data.into(), &FieldType::DateTime, field_rev) .unwrap() - .with_parser(DateCellDataParser()) + .parser::() .unwrap(); if type_option.include_time { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs index 17b897b1f0..729ae1958a 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; // Date #[derive(Clone, Debug, Default, Serialize, Deserialize, ProtoBuf)] -pub struct DateTypeOption { +pub struct DateTypeOptionPB { #[pb(index = 1)] pub date_format: DateFormat, @@ -24,9 +24,9 @@ pub struct DateTypeOption { #[pb(index = 3)] pub include_time: bool, } -impl_type_option!(DateTypeOption, FieldType::DateTime); +impl_type_option!(DateTypeOptionPB, FieldType::DateTime); -impl DateTypeOption { +impl DateTypeOptionPB { #[allow(dead_code)] pub fn new() -> Self { Self::default() @@ -116,7 +116,7 @@ impl DateTypeOption { } } -impl CellDisplayable for DateTypeOption { +impl CellDisplayable for DateTypeOptionPB { fn display_data( &self, cell_data: CellData, @@ -129,7 +129,7 @@ impl CellDisplayable for DateTypeOption { } } -impl CellDataOperation for DateTypeOption { +impl CellDataOperation for DateTypeOptionPB { fn decode_cell_data( &self, cell_data: CellData, @@ -169,9 +169,9 @@ impl CellDataOperation for DateTypeOption { } #[derive(Default)] -pub struct DateTypeOptionBuilder(DateTypeOption); +pub struct DateTypeOptionBuilder(DateTypeOptionPB); impl_into_box_type_option_builder!(DateTypeOptionBuilder); -impl_builder_from_json_str_and_from_bytes!(DateTypeOptionBuilder, DateTypeOption); +impl_builder_from_json_str_and_from_bytes!(DateTypeOptionBuilder, DateTypeOptionPB); impl DateTypeOptionBuilder { pub fn date_format(mut self, date_format: DateFormat) -> Self { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option_entities.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option_entities.rs index 1c54606f75..aef76b9c65 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/date_type_option/date_type_option_entities.rs @@ -58,12 +58,12 @@ impl std::convert::From for CellChangesetPB { date: params.date, time: params.time, }; - let s = serde_json::to_string(&changeset).unwrap(); + let content = serde_json::to_string(&changeset).unwrap(); CellChangesetPB { grid_id: params.cell_identifier.grid_id, row_id: params.cell_identifier.row_id, field_id: params.cell_identifier.field_id, - content: Some(s), + content, } } } @@ -204,7 +204,7 @@ pub struct DateCellDataParser(); impl CellBytesParser for DateCellDataParser { type Object = DateCellDataPB; - fn parse(&self, bytes: &Bytes) -> FlowyResult { + fn parser(bytes: &Bytes) -> FlowyResult { DateCellDataPB::try_from(bytes.as_ref()).map_err(internal_error) } } diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs index 41132ecfe4..41d4cb212c 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_tests.rs @@ -3,14 +3,14 @@ mod tests { use crate::entities::FieldType; use crate::services::cell::CellDataOperation; use crate::services::field::FieldBuilder; - use crate::services::field::{strip_currency_symbol, NumberFormat, NumberTypeOption}; + use crate::services::field::{strip_currency_symbol, NumberFormat, NumberTypeOptionPB}; use flowy_grid_data_model::revision::FieldRevision; use strum::IntoEnumIterator; /// Testing when the input is not a number. #[test] fn number_type_option_invalid_input_test() { - let type_option = NumberTypeOption::default(); + let type_option = NumberTypeOptionPB::default(); let field_type = FieldType::Number; let field_rev = FieldBuilder::from_field_type(&field_type).build(); @@ -33,7 +33,7 @@ mod tests { /// Format the input number to the corresponding format string. #[test] fn number_type_option_format_number_test() { - let mut type_option = NumberTypeOption::default(); + let mut type_option = NumberTypeOptionPB::default(); let field_type = FieldType::Number; let field_rev = FieldBuilder::from_field_type(&field_type).build(); @@ -63,7 +63,7 @@ mod tests { /// Format the input String to the corresponding format string. #[test] fn number_type_option_format_str_test() { - let mut type_option = NumberTypeOption::default(); + let mut type_option = NumberTypeOptionPB::default(); let field_type = FieldType::Number; let field_rev = FieldBuilder::from_field_type(&field_type).build(); @@ -101,7 +101,7 @@ mod tests { /// Carry out the sign positive to input number #[test] fn number_description_sign_test() { - let mut type_option = NumberTypeOption { + let mut type_option = NumberTypeOptionPB { sign_positive: false, ..Default::default() }; @@ -129,7 +129,7 @@ mod tests { } fn assert_number( - type_option: &NumberTypeOption, + type_option: &NumberTypeOptionPB, input_str: &str, expected_str: &str, field_type: &FieldType, diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs index 26d1d64248..cdb1118385 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option.rs @@ -14,9 +14,9 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; #[derive(Default)] -pub struct NumberTypeOptionBuilder(NumberTypeOption); +pub struct NumberTypeOptionBuilder(NumberTypeOptionPB); impl_into_box_type_option_builder!(NumberTypeOptionBuilder); -impl_builder_from_json_str_and_from_bytes!(NumberTypeOptionBuilder, NumberTypeOption); +impl_builder_from_json_str_and_from_bytes!(NumberTypeOptionBuilder, NumberTypeOptionPB); impl NumberTypeOptionBuilder { pub fn name(mut self, name: &str) -> Self { @@ -52,7 +52,7 @@ impl TypeOptionBuilder for NumberTypeOptionBuilder { // Number #[derive(Clone, Debug, Serialize, Deserialize, ProtoBuf)] -pub struct NumberTypeOption { +pub struct NumberTypeOptionPB { #[pb(index = 1)] pub format: NumberFormat, @@ -68,9 +68,9 @@ pub struct NumberTypeOption { #[pb(index = 5)] pub name: String, } -impl_type_option!(NumberTypeOption, FieldType::Number); +impl_type_option!(NumberTypeOptionPB, FieldType::Number); -impl NumberTypeOption { +impl NumberTypeOptionPB { pub fn new() -> Self { Self::default() } @@ -102,7 +102,7 @@ pub(crate) fn strip_currency_symbol(s: T) -> String { s } -impl CellDataOperation for NumberTypeOption { +impl CellDataOperation for NumberTypeOptionPB { fn decode_cell_data( &self, cell_data: CellData, @@ -132,11 +132,11 @@ impl CellDataOperation for NumberTypeOption { } } -impl std::default::Default for NumberTypeOption { +impl std::default::Default for NumberTypeOptionPB { fn default() -> Self { let format = NumberFormat::default(); let symbol = format.symbol(); - NumberTypeOption { + NumberTypeOptionPB { format, scale: 0, symbol, diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option_entities.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option_entities.rs index 6297114a07..aa372222b8 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/number_type_option/number_type_option_entities.rs @@ -1,4 +1,4 @@ -use crate::services::cell::CellBytesParser; +use crate::services::cell::{CellBytesCustomParser, CellBytesParser}; use crate::services::field::number_currency::Currency; use crate::services::field::{strip_currency_symbol, NumberFormat, STRIP_SYMBOL}; use bytes::Bytes; @@ -93,8 +93,19 @@ impl ToString for NumberCellData { } } } -pub struct NumberCellDataParser(pub NumberFormat); +pub struct NumberCellDataParser(); impl CellBytesParser for NumberCellDataParser { + type Object = NumberCellData; + fn parser(bytes: &Bytes) -> FlowyResult { + match String::from_utf8(bytes.to_vec()) { + Ok(s) => NumberCellData::from_format_str(&s, true, &NumberFormat::Num), + Err(_) => Ok(NumberCellData::default()), + } + } +} + +pub struct NumberCellCustomDataParser(pub NumberFormat); +impl CellBytesCustomParser for NumberCellCustomDataParser { type Object = NumberCellData; fn parse(&self, bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/multi_select_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/multi_select_type_option.rs index 187b81d060..75f654507d 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/multi_select_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/multi_select_type_option.rs @@ -14,16 +14,16 @@ use serde::{Deserialize, Serialize}; // Multiple select #[derive(Clone, Debug, Default, Serialize, Deserialize, ProtoBuf)] -pub struct MultiSelectTypeOption { +pub struct MultiSelectTypeOptionPB { #[pb(index = 1)] pub options: Vec, #[pb(index = 2)] pub disable_color: bool, } -impl_type_option!(MultiSelectTypeOption, FieldType::MultiSelect); +impl_type_option!(MultiSelectTypeOptionPB, FieldType::MultiSelect); -impl SelectOptionOperation for MultiSelectTypeOption { +impl SelectOptionOperation for MultiSelectTypeOptionPB { fn selected_select_option(&self, cell_data: CellData) -> SelectOptionCellDataPB { let select_options = make_selected_select_options(cell_data, &self.options); SelectOptionCellDataPB { @@ -41,7 +41,7 @@ impl SelectOptionOperation for MultiSelectTypeOption { } } -impl CellDataOperation for MultiSelectTypeOption { +impl CellDataOperation for MultiSelectTypeOptionPB { fn decode_cell_data( &self, cell_data: CellData, @@ -93,11 +93,11 @@ impl CellDataOperation for MultiSele } #[derive(Default)] -pub struct MultiSelectTypeOptionBuilder(MultiSelectTypeOption); +pub struct MultiSelectTypeOptionBuilder(MultiSelectTypeOptionPB); impl_into_box_type_option_builder!(MultiSelectTypeOptionBuilder); -impl_builder_from_json_str_and_from_bytes!(MultiSelectTypeOptionBuilder, MultiSelectTypeOption); +impl_builder_from_json_str_and_from_bytes!(MultiSelectTypeOptionBuilder, MultiSelectTypeOptionPB); impl MultiSelectTypeOptionBuilder { - pub fn option(mut self, opt: SelectOptionPB) -> Self { + pub fn add_option(mut self, opt: SelectOptionPB) -> Self { self.0.options.push(opt); self } @@ -118,7 +118,7 @@ mod tests { use crate::services::cell::CellDataOperation; use crate::services::field::type_options::selection_type_option::*; use crate::services::field::FieldBuilder; - use crate::services::field::{MultiSelectTypeOption, MultiSelectTypeOptionBuilder}; + use crate::services::field::{MultiSelectTypeOptionBuilder, MultiSelectTypeOptionPB}; use flowy_grid_data_model::revision::FieldRevision; #[test] @@ -127,16 +127,16 @@ mod tests { let facebook_option = SelectOptionPB::new("Facebook"); let twitter_option = SelectOptionPB::new("Twitter"); let multi_select = MultiSelectTypeOptionBuilder::default() - .option(google_option.clone()) - .option(facebook_option.clone()) - .option(twitter_option); + .add_option(google_option.clone()) + .add_option(facebook_option.clone()) + .add_option(twitter_option); let field_rev = FieldBuilder::new(multi_select) .name("Platform") .visibility(true) .build(); - let type_option = MultiSelectTypeOption::from(&field_rev); + let type_option = MultiSelectTypeOptionPB::from(&field_rev); let option_ids = vec![google_option.id.clone(), facebook_option.id.clone()].join(SELECTION_IDS_SEPARATOR); let data = SelectOptionCellChangeset::from_insert(&option_ids).to_str(); @@ -170,17 +170,17 @@ mod tests { fn assert_multi_select_options( cell_data: String, - type_option: &MultiSelectTypeOption, + type_option: &MultiSelectTypeOptionPB, field_rev: &FieldRevision, expected: Vec, ) { - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); assert_eq!( expected, type_option .decode_cell_data(cell_data.into(), &field_type, field_rev) .unwrap() - .with_parser(SelectOptionCellDataParser()) + .parser::() .unwrap() .select_options, ); diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs index d426844427..13da2e8359 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/select_option.rs @@ -1,6 +1,6 @@ use crate::entities::{CellChangesetPB, FieldType, GridCellIdPB, GridCellIdParams}; use crate::services::cell::{CellBytes, CellBytesParser, CellData, CellDisplayable, FromCellChangeset, FromCellString}; -use crate::services::field::{MultiSelectTypeOption, SingleSelectTypeOptionPB}; +use crate::services::field::{MultiSelectTypeOptionPB, SingleSelectTypeOptionPB}; use bytes::Bytes; use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; use flowy_error::{internal_error, ErrorCode, FlowyResult}; @@ -123,14 +123,14 @@ where } pub fn select_option_operation(field_rev: &FieldRevision) -> FlowyResult> { - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); match &field_type { FieldType::SingleSelect => { let type_option = SingleSelectTypeOptionPB::from(field_rev); Ok(Box::new(type_option)) } FieldType::MultiSelect => { - let type_option = MultiSelectTypeOption::from(field_rev); + let type_option = MultiSelectTypeOptionPB::from(field_rev); Ok(Box::new(type_option)) } ty => { @@ -206,7 +206,7 @@ impl std::ops::DerefMut for SelectOptionIds { pub struct SelectOptionIdsParser(); impl CellBytesParser for SelectOptionIdsParser { type Object = SelectOptionIds; - fn parse(&self, bytes: &Bytes) -> FlowyResult { + fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { Ok(s) => Ok(SelectOptionIds::from(s)), Err(_) => Ok(SelectOptionIds::from("".to_owned())), @@ -218,7 +218,7 @@ pub struct SelectOptionCellDataParser(); impl CellBytesParser for SelectOptionCellDataParser { type Object = SelectOptionCellDataPB; - fn parse(&self, bytes: &Bytes) -> FlowyResult { + fn parser(bytes: &Bytes) -> FlowyResult { SelectOptionCellDataPB::try_from(bytes.as_ref()).map_err(internal_error) } } @@ -247,12 +247,12 @@ impl std::convert::From for CellChangesetPB { insert_option_id: params.insert_option_id, delete_option_id: params.delete_option_id, }; - let s = serde_json::to_string(&changeset).unwrap(); + let content = serde_json::to_string(&changeset).unwrap(); CellChangesetPB { grid_id: params.cell_identifier.grid_id, row_id: params.cell_identifier.row_id, field_id: params.cell_identifier.field_id, - content: Some(s), + content, } } } diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/single_select_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/single_select_type_option.rs index 553425222e..287d0c3217 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/single_select_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/selection_type_option/single_select_type_option.rs @@ -65,10 +65,8 @@ impl CellDataOperation for SingleSel let select_option_changeset = changeset.try_into_inner()?; let new_cell_data: String; if let Some(insert_option_id) = select_option_changeset.insert_option_id { - tracing::trace!("Insert single select option: {}", &insert_option_id); new_cell_data = insert_option_id; } else { - tracing::trace!("Delete single select option"); new_cell_data = "".to_string() } @@ -82,7 +80,7 @@ impl_into_box_type_option_builder!(SingleSelectTypeOptionBuilder); impl_builder_from_json_str_and_from_bytes!(SingleSelectTypeOptionBuilder, SingleSelectTypeOptionPB); impl SingleSelectTypeOptionBuilder { - pub fn option(mut self, opt: SelectOptionPB) -> Self { + pub fn add_option(mut self, opt: SelectOptionPB) -> Self { self.0.options.push(opt); self } @@ -113,9 +111,9 @@ mod tests { let facebook_option = SelectOptionPB::new("Facebook"); let twitter_option = SelectOptionPB::new("Twitter"); let single_select = SingleSelectTypeOptionBuilder::default() - .option(google_option.clone()) - .option(facebook_option.clone()) - .option(twitter_option); + .add_option(google_option.clone()) + .add_option(facebook_option.clone()) + .add_option(twitter_option); let field_rev = FieldBuilder::new(single_select) .name("Platform") @@ -156,13 +154,13 @@ mod tests { field_rev: &FieldRevision, expected: Vec, ) { - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); assert_eq!( expected, type_option .decode_cell_data(cell_data.into(), &field_type, field_rev) .unwrap() - .with_parser(SelectOptionCellDataParser()) + .parser::() .unwrap() .select_options, ); diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs index b11bd026d2..6c50ce8da3 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/text_type_option/text_type_option.rs @@ -12,9 +12,9 @@ use flowy_grid_data_model::revision::{CellRevision, FieldRevision, TypeOptionDat use serde::{Deserialize, Serialize}; #[derive(Default)] -pub struct RichTextTypeOptionBuilder(RichTextTypeOption); +pub struct RichTextTypeOptionBuilder(RichTextTypeOptionPB); impl_into_box_type_option_builder!(RichTextTypeOptionBuilder); -impl_builder_from_json_str_and_from_bytes!(RichTextTypeOptionBuilder, RichTextTypeOption); +impl_builder_from_json_str_and_from_bytes!(RichTextTypeOptionBuilder, RichTextTypeOptionPB); impl TypeOptionBuilder for RichTextTypeOptionBuilder { fn field_type(&self) -> FieldType { @@ -27,13 +27,13 @@ impl TypeOptionBuilder for RichTextTypeOptionBuilder { } #[derive(Debug, Clone, Default, Serialize, Deserialize, ProtoBuf)] -pub struct RichTextTypeOption { +pub struct RichTextTypeOptionPB { #[pb(index = 1)] data: String, //It's not used yet } -impl_type_option!(RichTextTypeOption, FieldType::RichText); +impl_type_option!(RichTextTypeOptionPB, FieldType::RichText); -impl CellDisplayable for RichTextTypeOption { +impl CellDisplayable for RichTextTypeOptionPB { fn display_data( &self, cell_data: CellData, @@ -45,7 +45,7 @@ impl CellDisplayable for RichTextTypeOption { } } -impl CellDataOperation for RichTextTypeOption { +impl CellDataOperation for RichTextTypeOptionPB { fn decode_cell_data( &self, cell_data: CellData, @@ -96,7 +96,7 @@ impl FromCellString for TextCellData { pub struct TextCellDataParser(); impl CellBytesParser for TextCellDataParser { type Object = TextCellData; - fn parse(&self, bytes: &Bytes) -> FlowyResult { + fn parser(bytes: &Bytes) -> FlowyResult { match String::from_utf8(bytes.to_vec()) { Ok(s) => Ok(TextCellData(s)), Err(_) => Ok(TextCellData("".to_owned())), @@ -114,7 +114,7 @@ mod tests { #[test] fn text_description_test() { - let type_option = RichTextTypeOption::default(); + let type_option = RichTextTypeOptionPB::default(); // date let field_type = FieldType::DateTime; @@ -124,7 +124,7 @@ mod tests { type_option .decode_cell_data(1647251762.to_string().into(), &field_type, &date_time_field_rev) .unwrap() - .with_parser(DateCellDataParser()) + .parser::() .unwrap() .date, "Mar 14,2022".to_owned() @@ -133,7 +133,7 @@ mod tests { // Single select let done_option = SelectOptionPB::new("Done"); let done_option_id = done_option.id.clone(); - let single_select = SingleSelectTypeOptionBuilder::default().option(done_option.clone()); + let single_select = SingleSelectTypeOptionBuilder::default().add_option(done_option.clone()); let single_select_field_rev = FieldBuilder::new(single_select).build(); assert_eq!( @@ -144,7 +144,7 @@ mod tests { &single_select_field_rev ) .unwrap() - .with_parser(SelectOptionCellDataParser()) + .parser::() .unwrap() .select_options, vec![done_option], @@ -156,10 +156,10 @@ mod tests { let ids = vec![google_option.id.clone(), facebook_option.id.clone()].join(SELECTION_IDS_SEPARATOR); let cell_data_changeset = SelectOptionCellChangeset::from_insert(&ids).to_str(); let multi_select = MultiSelectTypeOptionBuilder::default() - .option(google_option.clone()) - .option(facebook_option.clone()); + .add_option(google_option.clone()) + .add_option(facebook_option.clone()); let multi_select_field_rev = FieldBuilder::new(multi_select).build(); - let multi_type_option = MultiSelectTypeOption::from(&multi_select_field_rev); + let multi_type_option = MultiSelectTypeOptionPB::from(&multi_select_field_rev); let cell_data = multi_type_option .apply_changeset(cell_data_changeset.into(), None) .unwrap(); @@ -167,7 +167,7 @@ mod tests { type_option .decode_cell_data(cell_data.into(), &FieldType::MultiSelect, &multi_select_field_rev) .unwrap() - .with_parser(SelectOptionCellDataParser()) + .parser::() .unwrap() .select_options, vec![google_option, facebook_option] diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs index 3cf5d6f99b..97b8275287 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs @@ -3,14 +3,14 @@ mod tests { use crate::entities::FieldType; use crate::services::cell::{CellData, CellDataOperation}; use crate::services::field::{FieldBuilder, URLCellDataParser}; - use crate::services::field::{URLCellDataPB, URLTypeOption}; + use crate::services::field::{URLCellDataPB, URLTypeOptionPB}; use flowy_grid_data_model::revision::FieldRevision; /// The expected_str will equal to the input string, but the expected_url will be empty if there's no /// http url in the input string. #[test] fn url_type_option_does_not_contain_url_test() { - let type_option = URLTypeOption::default(); + let type_option = URLTypeOptionPB::default(); let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url(&type_option, "123", "123", "", &field_type, &field_rev); @@ -21,7 +21,7 @@ mod tests { /// if there's a http url in the input string. #[test] fn url_type_option_contains_url_test() { - let type_option = URLTypeOption::default(); + let type_option = URLTypeOptionPB::default(); let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url( @@ -46,7 +46,7 @@ mod tests { /// if there's a http url and some words following it in the input string. #[test] fn url_type_option_contains_url_with_string_after_test() { - let type_option = URLTypeOption::default(); + let type_option = URLTypeOptionPB::default(); let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url( @@ -71,7 +71,7 @@ mod tests { /// if there's a http url and special words following it in the input string. #[test] fn url_type_option_contains_url_with_special_string_after_test() { - let type_option = URLTypeOption::default(); + let type_option = URLTypeOptionPB::default(); let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url( @@ -96,7 +96,7 @@ mod tests { /// if there's a level4 url in the input string. #[test] fn level4_url_type_test() { - let type_option = URLTypeOption::default(); + let type_option = URLTypeOptionPB::default(); let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url( @@ -121,7 +121,7 @@ mod tests { /// urls with different top level domains. #[test] fn different_top_level_domains_test() { - let type_option = URLTypeOption::default(); + let type_option = URLTypeOptionPB::default(); let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url( @@ -162,7 +162,7 @@ mod tests { } fn assert_url( - type_option: &URLTypeOption, + type_option: &URLTypeOptionPB, input_str: &str, expected_str: &str, expected_url: &str, @@ -177,14 +177,14 @@ mod tests { fn decode_cell_data>>( encoded_data: T, - type_option: &URLTypeOption, + type_option: &URLTypeOptionPB, field_rev: &FieldRevision, field_type: &FieldType, ) -> URLCellDataPB { type_option .decode_cell_data(encoded_data.into(), field_type, field_rev) .unwrap() - .with_parser(URLCellDataParser()) + .parser::() .unwrap() } } diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs index 3fa49dac09..adbc91b4f0 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option.rs @@ -11,9 +11,9 @@ use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; #[derive(Default)] -pub struct URLTypeOptionBuilder(URLTypeOption); +pub struct URLTypeOptionBuilder(URLTypeOptionPB); impl_into_box_type_option_builder!(URLTypeOptionBuilder); -impl_builder_from_json_str_and_from_bytes!(URLTypeOptionBuilder, URLTypeOption); +impl_builder_from_json_str_and_from_bytes!(URLTypeOptionBuilder, URLTypeOptionPB); impl TypeOptionBuilder for URLTypeOptionBuilder { fn field_type(&self) -> FieldType { @@ -26,13 +26,13 @@ impl TypeOptionBuilder for URLTypeOptionBuilder { } #[derive(Debug, Clone, Serialize, Deserialize, Default, ProtoBuf)] -pub struct URLTypeOption { +pub struct URLTypeOptionPB { #[pb(index = 1)] data: String, //It's not used yet. } -impl_type_option!(URLTypeOption, FieldType::URL); +impl_type_option!(URLTypeOptionPB, FieldType::URL); -impl CellDisplayable for URLTypeOption { +impl CellDisplayable for URLTypeOptionPB { fn display_data( &self, cell_data: CellData, @@ -44,7 +44,7 @@ impl CellDisplayable for URLTypeOption { } } -impl CellDataOperation for URLTypeOption { +impl CellDataOperation for URLTypeOptionPB { fn decode_cell_data( &self, cell_data: CellData, diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option_entities.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option_entities.rs index 6ff77cea9a..46e67fdf18 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_type_option_entities.rs @@ -30,7 +30,7 @@ pub struct URLCellDataParser(); impl CellBytesParser for URLCellDataParser { type Object = URLCellDataPB; - fn parse(&self, bytes: &Bytes) -> FlowyResult { + fn parser(bytes: &Bytes) -> FlowyResult { URLCellDataPB::try_from(bytes.as_ref()).map_err(internal_error) } } diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/filter_cache.rs b/frontend/rust-lib/flowy-grid/src/services/filter/filter_cache.rs index a1f6d4cbbf..cad2998a5f 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/filter_cache.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/filter_cache.rs @@ -1,8 +1,9 @@ use crate::entities::{ - FieldType, GridCheckboxFilter, GridDateFilter, GridNumberFilter, GridSelectOptionFilter, GridTextFilter, + CheckboxFilterConfigurationPB, DateFilterConfigurationPB, FieldType, NumberFilterConfigurationPB, + SelectOptionFilterConfigurationPB, TextFilterConfigurationPB, }; use dashmap::DashMap; -use flowy_grid_data_model::revision::{FieldRevision, RowRevision}; +use flowy_grid_data_model::revision::{FieldRevision, FilterConfigurationRevision, RowRevision}; use flowy_sync::client_grid::GridRevisionPad; use std::collections::HashMap; use std::sync::Arc; @@ -58,12 +59,12 @@ impl FilterResult { #[derive(Default)] pub(crate) struct FilterCache { - pub(crate) text_filter: DashMap, - pub(crate) url_filter: DashMap, - pub(crate) number_filter: DashMap, - pub(crate) date_filter: DashMap, - pub(crate) select_option_filter: DashMap, - pub(crate) checkbox_filter: DashMap, + pub(crate) text_filter: DashMap, + pub(crate) url_filter: DashMap, + pub(crate) number_filter: DashMap, + pub(crate) date_filter: DashMap, + pub(crate) select_option_filter: DashMap, + pub(crate) checkbox_filter: DashMap, } impl FilterCache { @@ -73,6 +74,7 @@ impl FilterCache { this } + #[allow(dead_code)] pub(crate) fn remove(&self, filter_id: &FilterId) { let _ = match filter_id.field_type { FieldType::RichText => { @@ -103,42 +105,50 @@ impl FilterCache { /// Refresh the filter according to the field id. pub(crate) async fn refresh_filter_cache( cache: Arc, - field_ids: Option>, + _field_ids: Option>, grid_pad: &Arc>, ) { let grid_pad = grid_pad.read().await; - let filters_revs = grid_pad.get_filters(None, field_ids).unwrap_or_default(); + // let filters_revs = grid_pad.get_filters(field_ids).unwrap_or_default(); + // TODO nathan + let filter_revs: Vec> = vec![]; - for filter_rev in filters_revs { + for filter_rev in filter_revs { match grid_pad.get_field_rev(&filter_rev.field_id) { None => {} Some((_, field_rev)) => { let filter_id = FilterId::from(field_rev); - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); match &field_type { FieldType::RichText => { - let _ = cache.text_filter.insert(filter_id, GridTextFilter::from(filter_rev)); + let _ = cache + .text_filter + .insert(filter_id, TextFilterConfigurationPB::from(filter_rev)); } FieldType::Number => { let _ = cache .number_filter - .insert(filter_id, GridNumberFilter::from(filter_rev)); + .insert(filter_id, NumberFilterConfigurationPB::from(filter_rev)); } FieldType::DateTime => { - let _ = cache.date_filter.insert(filter_id, GridDateFilter::from(filter_rev)); + let _ = cache + .date_filter + .insert(filter_id, DateFilterConfigurationPB::from(filter_rev)); } FieldType::SingleSelect | FieldType::MultiSelect => { let _ = cache .select_option_filter - .insert(filter_id, GridSelectOptionFilter::from(filter_rev)); + .insert(filter_id, SelectOptionFilterConfigurationPB::from(filter_rev)); } FieldType::Checkbox => { let _ = cache .checkbox_filter - .insert(filter_id, GridCheckboxFilter::from(filter_rev)); + .insert(filter_id, CheckboxFilterConfigurationPB::from(filter_rev)); } FieldType::URL => { - let _ = cache.url_filter.insert(filter_id, GridTextFilter::from(filter_rev)); + let _ = cache + .url_filter + .insert(filter_id, TextFilterConfigurationPB::from(filter_rev)); } } } @@ -155,7 +165,7 @@ impl std::convert::From<&Arc> for FilterId { fn from(rev: &Arc) -> Self { Self { field_id: rev.id.clone(), - field_type: rev.field_type_rev.into(), + field_type: rev.ty.into(), } } } diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/filter_service.rs b/frontend/rust-lib/flowy-grid/src/services/filter/filter_service.rs index f3b1d72fb8..a2831c0ad9 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/filter_service.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/filter_service.rs @@ -1,10 +1,15 @@ +#![allow(clippy::all)] +#![allow(unused_attributes)] +#![allow(dead_code)] +#![allow(unused_imports)] +#![allow(unused_results)] use crate::dart_notification::{send_dart_notification, GridNotification}; use crate::entities::{FieldType, GridBlockChangesetPB}; use crate::services::block_manager::GridBlockManager; use crate::services::cell::{AnyCellData, CellFilterOperation}; use crate::services::field::{ - CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RichTextTypeOption, - SingleSelectTypeOptionPB, URLTypeOption, + CheckboxTypeOptionPB, DateTypeOptionPB, MultiSelectTypeOptionPB, NumberTypeOptionPB, RichTextTypeOptionPB, + SingleSelectTypeOptionPB, URLTypeOptionPB, }; use crate::services::filter::filter_cache::{ refresh_filter_cache, FilterCache, FilterId, FilterResult, FilterResultCache, @@ -23,9 +28,9 @@ use tokio::sync::RwLock; pub(crate) struct GridFilterService { #[allow(dead_code)] - grid_id: String, scheduler: Arc, grid_pad: Arc>, + #[allow(dead_code)] block_manager: Arc, filter_cache: Arc, filter_result_cache: Arc, @@ -36,12 +41,10 @@ impl GridFilterService { block_manager: Arc, scheduler: S, ) -> Self { - let grid_id = grid_pad.read().await.grid_id(); let scheduler = Arc::new(scheduler); let filter_cache = FilterCache::from_grid_pad(&grid_pad).await; let filter_result_cache = FilterResultCache::new(); Self { - grid_id, grid_pad, block_manager, scheduler, @@ -134,8 +137,9 @@ impl GridFilterService { } async fn notify(&self, changesets: Vec) { + let grid_id = self.grid_pad.read().await.grid_id(); for changeset in changesets { - send_dart_notification(&self.grid_id, GridNotification::DidUpdateGridBlock) + send_dart_notification(&grid_id, GridNotification::DidUpdateGridBlock) .payload(changeset) .send(); } @@ -174,7 +178,7 @@ fn filter_cell( cell_rev: &CellRevision, ) -> Option<()> { let field_rev = field_revs.get(field_id)?; - let field_type = FieldType::from(field_rev.field_type_rev); + let field_type = FieldType::from(field_rev.ty); let field_type_rev = field_type.clone().into(); let filter_id = FilterId { field_id: field_id.to_owned(), @@ -185,7 +189,7 @@ fn filter_cell( FieldType::RichText => filter_cache.text_filter.get(&filter_id).and_then(|filter| { Some( field_rev - .get_type_option_entry::(field_type_rev)? + .get_type_option_entry::(field_type_rev)? .apply_filter(any_cell_data, filter.value()) .ok(), ) @@ -193,7 +197,7 @@ fn filter_cell( FieldType::Number => filter_cache.number_filter.get(&filter_id).and_then(|filter| { Some( field_rev - .get_type_option_entry::(field_type_rev)? + .get_type_option_entry::(field_type_rev)? .apply_filter(any_cell_data, filter.value()) .ok(), ) @@ -201,7 +205,7 @@ fn filter_cell( FieldType::DateTime => filter_cache.date_filter.get(&filter_id).and_then(|filter| { Some( field_rev - .get_type_option_entry::(field_type_rev)? + .get_type_option_entry::(field_type_rev)? .apply_filter(any_cell_data, filter.value()) .ok(), ) @@ -217,7 +221,7 @@ fn filter_cell( FieldType::MultiSelect => filter_cache.select_option_filter.get(&filter_id).and_then(|filter| { Some( field_rev - .get_type_option_entry::(field_type_rev)? + .get_type_option_entry::(field_type_rev)? .apply_filter(any_cell_data, filter.value()) .ok(), ) @@ -225,7 +229,7 @@ fn filter_cell( FieldType::Checkbox => filter_cache.checkbox_filter.get(&filter_id).and_then(|filter| { Some( field_rev - .get_type_option_entry::(field_type_rev)? + .get_type_option_entry::(field_type_rev)? .apply_filter(any_cell_data, filter.value()) .ok(), ) @@ -233,7 +237,7 @@ fn filter_cell( FieldType::URL => filter_cache.url_filter.get(&filter_id).and_then(|filter| { Some( field_rev - .get_type_option_entry::(field_type_rev)? + .get_type_option_entry::(field_type_rev)? .apply_filter(any_cell_data, filter.value()) .ok(), ) diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/impls/checkbox_filter.rs b/frontend/rust-lib/flowy-grid/src/services/filter/impls/checkbox_filter.rs index 24e21bbeb7..3239ff449d 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/impls/checkbox_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/impls/checkbox_filter.rs @@ -1,9 +1,9 @@ -use crate::entities::{CheckboxCondition, GridCheckboxFilter}; +use crate::entities::{CheckboxCondition, CheckboxFilterConfigurationPB}; use crate::services::cell::{AnyCellData, CellData, CellFilterOperation}; -use crate::services::field::{CheckboxCellData, CheckboxTypeOption}; +use crate::services::field::{CheckboxCellData, CheckboxTypeOptionPB}; use flowy_error::FlowyResult; -impl GridCheckboxFilter { +impl CheckboxFilterConfigurationPB { pub fn is_visible(&self, cell_data: &CheckboxCellData) -> bool { let is_check = cell_data.is_check(); match self.condition { @@ -13,8 +13,8 @@ impl GridCheckboxFilter { } } -impl CellFilterOperation for CheckboxTypeOption { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridCheckboxFilter) -> FlowyResult { +impl CellFilterOperation for CheckboxTypeOptionPB { + fn apply_filter(&self, any_cell_data: AnyCellData, filter: &CheckboxFilterConfigurationPB) -> FlowyResult { if !any_cell_data.is_checkbox() { return Ok(true); } @@ -26,13 +26,13 @@ impl CellFilterOperation for CheckboxTypeOption { #[cfg(test)] mod tests { - use crate::entities::{CheckboxCondition, GridCheckboxFilter}; + use crate::entities::{CheckboxCondition, CheckboxFilterConfigurationPB}; use crate::services::field::CheckboxCellData; use std::str::FromStr; #[test] fn checkbox_filter_is_check_test() { - let checkbox_filter = GridCheckboxFilter { + let checkbox_filter = CheckboxFilterConfigurationPB { condition: CheckboxCondition::IsChecked, }; for (value, visible) in [("true", true), ("yes", true), ("false", false), ("no", false)] { @@ -43,7 +43,7 @@ mod tests { #[test] fn checkbox_filter_is_uncheck_test() { - let checkbox_filter = GridCheckboxFilter { + let checkbox_filter = CheckboxFilterConfigurationPB { condition: CheckboxCondition::IsUnChecked, }; for (value, visible) in [("false", true), ("no", true), ("true", false), ("yes", false)] { diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/impls/date_filter.rs b/frontend/rust-lib/flowy-grid/src/services/filter/impls/date_filter.rs index 46e2571438..18d968d2a4 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/impls/date_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/impls/date_filter.rs @@ -1,9 +1,9 @@ -use crate::entities::{DateFilterCondition, GridDateFilter}; +use crate::entities::{DateFilterCondition, DateFilterConfigurationPB}; use crate::services::cell::{AnyCellData, CellData, CellFilterOperation}; -use crate::services::field::{DateTimestamp, DateTypeOption}; +use crate::services::field::{DateTimestamp, DateTypeOptionPB}; use flowy_error::FlowyResult; -impl GridDateFilter { +impl DateFilterConfigurationPB { pub fn is_visible>(&self, cell_timestamp: T) -> bool { if self.start.is_none() { return false; @@ -29,8 +29,8 @@ impl GridDateFilter { } } -impl CellFilterOperation for DateTypeOption { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridDateFilter) -> FlowyResult { +impl CellFilterOperation for DateTypeOptionPB { + fn apply_filter(&self, any_cell_data: AnyCellData, filter: &DateFilterConfigurationPB) -> FlowyResult { if !any_cell_data.is_date() { return Ok(true); } @@ -43,11 +43,11 @@ impl CellFilterOperation for DateTypeOption { #[cfg(test)] mod tests { #![allow(clippy::all)] - use crate::entities::{DateFilterCondition, GridDateFilter}; + use crate::entities::{DateFilterCondition, DateFilterConfigurationPB}; #[test] fn date_filter_is_test() { - let filter = GridDateFilter { + let filter = DateFilterConfigurationPB { condition: DateFilterCondition::DateIs, start: Some(123), end: None, @@ -59,7 +59,7 @@ mod tests { } #[test] fn date_filter_before_test() { - let filter = GridDateFilter { + let filter = DateFilterConfigurationPB { condition: DateFilterCondition::DateBefore, start: Some(123), end: None, @@ -71,7 +71,7 @@ mod tests { } #[test] fn date_filter_before_or_on_test() { - let filter = GridDateFilter { + let filter = DateFilterConfigurationPB { condition: DateFilterCondition::DateOnOrBefore, start: Some(123), end: None, @@ -83,7 +83,7 @@ mod tests { } #[test] fn date_filter_after_test() { - let filter = GridDateFilter { + let filter = DateFilterConfigurationPB { condition: DateFilterCondition::DateAfter, start: Some(123), end: None, @@ -95,7 +95,7 @@ mod tests { } #[test] fn date_filter_within_test() { - let filter = GridDateFilter { + let filter = DateFilterConfigurationPB { condition: DateFilterCondition::DateWithIn, start: Some(123), end: Some(130), diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/impls/number_filter.rs b/frontend/rust-lib/flowy-grid/src/services/filter/impls/number_filter.rs index f44c1d2d62..45ae0ac464 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/impls/number_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/impls/number_filter.rs @@ -1,12 +1,12 @@ -use crate::entities::{GridNumberFilter, NumberFilterCondition}; +use crate::entities::{NumberFilterCondition, NumberFilterConfigurationPB}; use crate::services::cell::{AnyCellData, CellFilterOperation}; -use crate::services::field::{NumberCellData, NumberTypeOption}; +use crate::services::field::{NumberCellData, NumberTypeOptionPB}; use flowy_error::FlowyResult; use rust_decimal::prelude::Zero; use rust_decimal::Decimal; use std::str::FromStr; -impl GridNumberFilter { +impl NumberFilterConfigurationPB { pub fn is_visible(&self, num_cell_data: &NumberCellData) -> bool { if self.content.is_none() { return false; @@ -31,8 +31,8 @@ impl GridNumberFilter { } } -impl CellFilterOperation for NumberTypeOption { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridNumberFilter) -> FlowyResult { +impl CellFilterOperation for NumberTypeOptionPB { + fn apply_filter(&self, any_cell_data: AnyCellData, filter: &NumberFilterConfigurationPB) -> FlowyResult { if !any_cell_data.is_number() { return Ok(true); } @@ -46,11 +46,11 @@ impl CellFilterOperation for NumberTypeOption { #[cfg(test)] mod tests { - use crate::entities::{GridNumberFilter, NumberFilterCondition}; + use crate::entities::{NumberFilterCondition, NumberFilterConfigurationPB}; use crate::services::field::{NumberCellData, NumberFormat}; #[test] fn number_filter_equal_test() { - let number_filter = GridNumberFilter { + let number_filter = NumberFilterConfigurationPB { condition: NumberFilterCondition::Equal, content: Some("123".to_owned()), }; @@ -68,7 +68,7 @@ mod tests { } #[test] fn number_filter_greater_than_test() { - let number_filter = GridNumberFilter { + let number_filter = NumberFilterConfigurationPB { condition: NumberFilterCondition::GreaterThan, content: Some("12".to_owned()), }; @@ -80,7 +80,7 @@ mod tests { #[test] fn number_filter_less_than_test() { - let number_filter = GridNumberFilter { + let number_filter = NumberFilterConfigurationPB { condition: NumberFilterCondition::LessThan, content: Some("100".to_owned()), }; diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/impls/select_option_filter.rs b/frontend/rust-lib/flowy-grid/src/services/filter/impls/select_option_filter.rs index e6cb9ff846..f48069911e 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/impls/select_option_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/impls/select_option_filter.rs @@ -1,12 +1,12 @@ #![allow(clippy::needless_collect)] -use crate::entities::{GridSelectOptionFilter, SelectOptionCondition}; +use crate::entities::{SelectOptionCondition, SelectOptionFilterConfigurationPB}; use crate::services::cell::{AnyCellData, CellFilterOperation}; -use crate::services::field::{MultiSelectTypeOption, SingleSelectTypeOptionPB}; +use crate::services::field::{MultiSelectTypeOptionPB, SingleSelectTypeOptionPB}; use crate::services::field::{SelectOptionOperation, SelectedSelectOptions}; use flowy_error::FlowyResult; -impl GridSelectOptionFilter { +impl SelectOptionFilterConfigurationPB { pub fn is_visible(&self, selected_options: &SelectedSelectOptions) -> bool { let selected_option_ids: Vec<&String> = selected_options.options.iter().map(|option| &option.id).collect(); match self.condition { @@ -39,8 +39,12 @@ impl GridSelectOptionFilter { } } -impl CellFilterOperation for MultiSelectTypeOption { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridSelectOptionFilter) -> FlowyResult { +impl CellFilterOperation for MultiSelectTypeOptionPB { + fn apply_filter( + &self, + any_cell_data: AnyCellData, + filter: &SelectOptionFilterConfigurationPB, + ) -> FlowyResult { if !any_cell_data.is_multi_select() { return Ok(true); } @@ -50,8 +54,12 @@ impl CellFilterOperation for MultiSelectTypeOption { } } -impl CellFilterOperation for SingleSelectTypeOptionPB { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridSelectOptionFilter) -> FlowyResult { +impl CellFilterOperation for SingleSelectTypeOptionPB { + fn apply_filter( + &self, + any_cell_data: AnyCellData, + filter: &SelectOptionFilterConfigurationPB, + ) -> FlowyResult { if !any_cell_data.is_single_select() { return Ok(true); } @@ -63,7 +71,7 @@ impl CellFilterOperation for SingleSelectTypeOptionPB { #[cfg(test)] mod tests { #![allow(clippy::all)] - use crate::entities::{GridSelectOptionFilter, SelectOptionCondition}; + use crate::entities::{SelectOptionCondition, SelectOptionFilterConfigurationPB}; use crate::services::field::selection_type_option::{SelectOptionPB, SelectedSelectOptions}; #[test] @@ -72,7 +80,7 @@ mod tests { let option_2 = SelectOptionPB::new("B"); let option_3 = SelectOptionPB::new("C"); - let filter_1 = GridSelectOptionFilter { + let filter_1 = SelectOptionFilterConfigurationPB { condition: SelectOptionCondition::OptionIs, option_ids: vec![option_1.id.clone(), option_2.id.clone()], }; diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/impls/text_filter.rs b/frontend/rust-lib/flowy-grid/src/services/filter/impls/text_filter.rs index 25f3902ceb..86cb2aadfa 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/impls/text_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/impls/text_filter.rs @@ -1,9 +1,9 @@ -use crate::entities::{GridTextFilter, TextFilterCondition}; +use crate::entities::{TextFilterCondition, TextFilterConfigurationPB}; use crate::services::cell::{AnyCellData, CellData, CellFilterOperation}; -use crate::services::field::{RichTextTypeOption, TextCellData}; +use crate::services::field::{RichTextTypeOptionPB, TextCellData}; use flowy_error::FlowyResult; -impl GridTextFilter { +impl TextFilterConfigurationPB { pub fn is_visible>(&self, cell_data: T) -> bool { let cell_data = cell_data.as_ref(); let s = cell_data.to_lowercase(); @@ -24,8 +24,8 @@ impl GridTextFilter { } } -impl CellFilterOperation for RichTextTypeOption { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridTextFilter) -> FlowyResult { +impl CellFilterOperation for RichTextTypeOptionPB { + fn apply_filter(&self, any_cell_data: AnyCellData, filter: &TextFilterConfigurationPB) -> FlowyResult { if !any_cell_data.is_text() { return Ok(true); } @@ -38,11 +38,11 @@ impl CellFilterOperation for RichTextTypeOption { #[cfg(test)] mod tests { #![allow(clippy::all)] - use crate::entities::{GridTextFilter, TextFilterCondition}; + use crate::entities::{TextFilterCondition, TextFilterConfigurationPB}; #[test] fn text_filter_equal_test() { - let text_filter = GridTextFilter { + let text_filter = TextFilterConfigurationPB { condition: TextFilterCondition::Is, content: Some("appflowy".to_owned()), }; @@ -54,7 +54,7 @@ mod tests { } #[test] fn text_filter_start_with_test() { - let text_filter = GridTextFilter { + let text_filter = TextFilterConfigurationPB { condition: TextFilterCondition::StartsWith, content: Some("appflowy".to_owned()), }; @@ -66,7 +66,7 @@ mod tests { #[test] fn text_filter_end_with_test() { - let text_filter = GridTextFilter { + let text_filter = TextFilterConfigurationPB { condition: TextFilterCondition::EndsWith, content: Some("appflowy".to_owned()), }; @@ -77,7 +77,7 @@ mod tests { } #[test] fn text_filter_empty_test() { - let text_filter = GridTextFilter { + let text_filter = TextFilterConfigurationPB { condition: TextFilterCondition::TextIsEmpty, content: Some("appflowy".to_owned()), }; @@ -87,7 +87,7 @@ mod tests { } #[test] fn text_filter_contain_test() { - let text_filter = GridTextFilter { + let text_filter = TextFilterConfigurationPB { condition: TextFilterCondition::Contains, content: Some("appflowy".to_owned()), }; diff --git a/frontend/rust-lib/flowy-grid/src/services/filter/impls/url_filter.rs b/frontend/rust-lib/flowy-grid/src/services/filter/impls/url_filter.rs index 15254d4713..4f0a7b93cd 100644 --- a/frontend/rust-lib/flowy-grid/src/services/filter/impls/url_filter.rs +++ b/frontend/rust-lib/flowy-grid/src/services/filter/impls/url_filter.rs @@ -1,10 +1,10 @@ -use crate::entities::GridTextFilter; +use crate::entities::TextFilterConfigurationPB; use crate::services::cell::{AnyCellData, CellData, CellFilterOperation}; -use crate::services::field::{TextCellData, URLTypeOption}; +use crate::services::field::{TextCellData, URLTypeOptionPB}; use flowy_error::FlowyResult; -impl CellFilterOperation for URLTypeOption { - fn apply_filter(&self, any_cell_data: AnyCellData, filter: &GridTextFilter) -> FlowyResult { +impl CellFilterOperation for URLTypeOptionPB { + fn apply_filter(&self, any_cell_data: AnyCellData, filter: &TextFilterConfigurationPB) -> FlowyResult { if !any_cell_data.is_url() { return Ok(true); } diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs b/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs index 44626b96fd..92cf9da550 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs @@ -3,35 +3,36 @@ use crate::entities::GridCellIdParams; use crate::entities::*; use crate::manager::{GridTaskSchedulerRwLock, GridUser}; use crate::services::block_manager::GridBlockManager; + use crate::services::cell::{apply_cell_data_changeset, decode_any_cell_data, CellBytes}; use crate::services::field::{default_type_option_builder_from_type, type_option_builder_from_bytes, FieldBuilder}; -use crate::services::filter::{GridFilterChangeset, GridFilterService}; +use crate::services::filter::GridFilterService; +use crate::services::grid_view_manager::GridViewManager; use crate::services::persistence::block_index::BlockIndexCache; -use crate::services::row::{ - make_grid_blocks, make_row_from_row_rev, make_rows_from_row_revs, GridBlockSnapshot, RowRevisionBuilder, -}; -use crate::services::setting::make_grid_setting; +use crate::services::row::{make_grid_blocks, make_rows_from_row_revs, GridBlockSnapshot, RowRevisionBuilder}; use bytes::Bytes; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_grid_data_model::revision::*; use flowy_revision::{RevisionCloudService, RevisionCompactor, RevisionManager, RevisionObjectBuilder}; -use flowy_sync::client_grid::{GridChangeset, GridRevisionPad, JsonDeserializer}; +use flowy_sync::client_grid::{GridRevisionChangeset, GridRevisionPad, JsonDeserializer}; use flowy_sync::entities::grid::{FieldChangesetParams, GridSettingChangesetParams}; use flowy_sync::entities::revision::Revision; use flowy_sync::errors::CollaborateResult; -use flowy_sync::util::make_delta_from_revisions; +use flowy_sync::util::make_text_delta_from_revisions; use lib_infra::future::FutureResult; -use lib_ot::core::PhantomAttributes; + use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; pub struct GridRevisionEditor { - pub(crate) grid_id: String, + pub grid_id: String, user: Arc, grid_pad: Arc>, + view_manager: Arc, rev_manager: Arc, block_manager: Arc, + #[allow(dead_code)] pub(crate) filter_service: Arc, } @@ -55,17 +56,32 @@ impl GridRevisionEditor { let grid_pad = rev_manager.load::(Some(cloud)).await?; let rev_manager = Arc::new(rev_manager); let grid_pad = Arc::new(RwLock::new(grid_pad)); + + // Block manager let block_meta_revs = grid_pad.read().await.get_block_meta_revs(); - let block_manager = Arc::new(GridBlockManager::new(grid_id, &user, block_meta_revs, persistence).await?); + let block_manager = Arc::new(GridBlockManager::new(&user, block_meta_revs, persistence).await?); let filter_service = - Arc::new(GridFilterService::new(grid_pad.clone(), block_manager.clone(), task_scheduler.clone()).await); + GridFilterService::new(grid_pad.clone(), block_manager.clone(), task_scheduler.clone()).await; + + // View manager + let view_manager = Arc::new( + GridViewManager::new( + grid_id.to_owned(), + user.clone(), + Arc::new(grid_pad.clone()), + Arc::new(block_manager.clone()), + Arc::new(task_scheduler.clone()), + ) + .await?, + ); let editor = Arc::new(Self { grid_id: grid_id.to_owned(), user, grid_pad, rev_manager, block_manager, - filter_service, + view_manager, + filter_service: Arc::new(filter_service), }); Ok(editor) @@ -126,7 +142,7 @@ impl GridRevisionEditor { let field_rev = result.unwrap(); let _ = self .modify(|grid| { - let field_type = field_rev.field_type_rev.into(); + let field_type = field_rev.ty.into(); let deserializer = TypeOptionJsonDeserializer(field_type); let changeset = FieldChangesetParams { field_id: field_id.to_owned(), @@ -165,7 +181,7 @@ impl GridRevisionEditor { let field_id = params.field_id.clone(); let json_deserializer = match self.grid_pad.read().await.get_field_rev(params.field_id.as_str()) { None => return Err(ErrorCode::FieldDoesNotExist.into()), - Some((_, field_rev)) => TypeOptionJsonDeserializer(field_rev.field_type_rev.into()), + Some((_, field_rev)) => TypeOptionJsonDeserializer(field_rev.ty.into()), }; let _ = self @@ -187,8 +203,8 @@ impl GridRevisionEditor { pub async fn delete_field(&self, field_id: &str) -> FlowyResult<()> { let _ = self.modify(|grid_pad| Ok(grid_pad.delete_field_rev(field_id)?)).await?; - let field_order = GridFieldIdPB::from(field_id); - let notified_changeset = GridFieldChangesetPB::delete(&self.grid_id, vec![field_order]); + let field_order = FieldIdPB::from(field_id); + let notified_changeset = FieldChangesetPB::delete(&self.grid_id, vec![field_order]); let _ = self.notify_did_update_grid(notified_changeset).await?; Ok(()) } @@ -267,29 +283,23 @@ impl GridRevisionEditor { Ok(()) } - pub async fn create_row(&self, start_row_id: Option) -> FlowyResult { - let field_revs = self.grid_pad.read().await.get_field_revs(None)?; - let block_id = self.block_id().await?; + pub async fn create_row(&self, params: CreateRowParams) -> FlowyResult { + let mut row_rev = self.create_row_rev().await?; - // insert empty row below the row whose id is upper_row_id - let row_rev = RowRevisionBuilder::new(&field_revs).build(&block_id); - let row_order = GridRowPB::from(&row_rev); + self.view_manager.will_create_row(&mut row_rev, ¶ms).await; - // insert the row - let row_count = self.block_manager.create_row(&block_id, row_rev, start_row_id).await?; + let row_pb = self.create_row_pb(row_rev, params.start_row_id.clone()).await?; - // update block row count - let changeset = GridBlockMetaRevisionChangeset::from_row_count(&block_id, row_count); - let _ = self.update_block(changeset).await?; - Ok(row_order) + self.view_manager.did_create_row(&row_pb, ¶ms).await; + Ok(row_pb) } - pub async fn insert_rows(&self, row_revs: Vec) -> FlowyResult> { + pub async fn insert_rows(&self, row_revs: Vec) -> FlowyResult> { let block_id = self.block_id().await?; let mut rows_by_block_id: HashMap> = HashMap::new(); let mut row_orders = vec![]; for row_rev in row_revs { - row_orders.push(GridRowPB::from(&row_rev)); + row_orders.push(RowPB::from(&row_rev)); rows_by_block_id .entry(block_id.clone()) .or_insert_with(Vec::new) @@ -302,8 +312,11 @@ impl GridRevisionEditor { Ok(row_orders) } - pub async fn update_row(&self, changeset: RowMetaChangeset) -> FlowyResult<()> { - self.block_manager.update_row(changeset, make_row_from_row_rev).await + pub async fn update_row(&self, changeset: RowChangeset) -> FlowyResult<()> { + let row_id = changeset.row_id.clone(); + let _ = self.block_manager.update_row(changeset).await?; + self.view_manager.did_update_row(&row_id).await; + Ok(()) } pub async fn get_rows(&self, block_id: &str) -> FlowyResult { @@ -330,7 +343,10 @@ impl GridRevisionEditor { } pub async fn delete_row(&self, row_id: &str) -> FlowyResult<()> { - let _ = self.block_manager.delete_row(row_id).await?; + let row_rev = self.block_manager.delete_row(row_id).await?; + if let Some(row_rev) = row_rev { + self.view_manager.did_delete_row(row_rev).await; + } Ok(()) } @@ -364,10 +380,6 @@ impl GridRevisionEditor { #[tracing::instrument(level = "trace", skip_all, err)] pub async fn update_cell(&self, cell_changeset: CellChangesetPB) -> FlowyResult<()> { - if cell_changeset.content.as_ref().is_none() { - return Ok(()); - } - let CellChangesetPB { grid_id, row_id, @@ -377,31 +389,28 @@ impl GridRevisionEditor { match self.grid_pad.read().await.get_field_rev(&field_id) { None => { - let msg = format!("Field not found with id: {}", &field_id); + let msg = format!("Field:{} not found", &field_id); Err(FlowyError::internal().context(msg)) } Some((_, field_rev)) => { tracing::trace!("field changeset: id:{} / value:{:?}", &field_id, content); - let cell_rev = self.get_cell_rev(&row_id, &field_id).await?; // Update the changeset.data property with the return value. - content = Some(apply_cell_data_changeset(content.unwrap(), cell_rev, field_rev)?); + content = apply_cell_data_changeset(content, cell_rev, field_rev)?; let cell_changeset = CellChangesetPB { grid_id, - row_id, - field_id, + row_id: row_id.clone(), + field_id: field_id.clone(), content, }; - let _ = self - .block_manager - .update_cell(cell_changeset, make_row_from_row_rev) - .await?; + let _ = self.block_manager.update_cell(cell_changeset).await?; + self.view_manager.did_update_cell(&row_id, &field_id).await; Ok(()) } } } - pub async fn get_blocks(&self, block_ids: Option>) -> FlowyResult { + pub async fn get_blocks(&self, block_ids: Option>) -> FlowyResult { let block_snapshots = self.grid_block_snapshots(block_ids.clone()).await?; make_grid_blocks(block_ids, block_snapshots) } @@ -411,7 +420,7 @@ impl GridRevisionEditor { Ok(block_meta_revs) } - pub async fn delete_rows(&self, row_orders: Vec) -> FlowyResult<()> { + pub async fn delete_rows(&self, row_orders: Vec) -> FlowyResult<()> { let changesets = self.block_manager.delete_rows(row_orders).await?; for changeset in changesets { let _ = self.update_block(changeset).await?; @@ -424,12 +433,12 @@ impl GridRevisionEditor { let field_orders = pad_read_guard .get_field_revs(None)? .iter() - .map(GridFieldIdPB::from) + .map(FieldIdPB::from) .collect(); let mut block_orders = vec![]; for block_rev in pad_read_guard.get_block_meta_revs() { let row_orders = self.block_manager.get_row_orders(&block_rev.block_id).await?; - let block_order = GridBlockPB { + let block_order = BlockPB { id: block_rev.block_id.clone(), rows: row_orders, }; @@ -444,35 +453,15 @@ impl GridRevisionEditor { } pub async fn get_grid_setting(&self) -> FlowyResult { - let read_guard = self.grid_pad.read().await; - let grid_setting_rev = read_guard.get_grid_setting_rev(); - let field_revs = read_guard.get_field_revs(None)?; - let grid_setting = make_grid_setting(grid_setting_rev, &field_revs); - Ok(grid_setting) + self.view_manager.get_setting().await } - pub async fn get_grid_filter(&self, layout_type: &GridLayoutType) -> FlowyResult> { - let read_guard = self.grid_pad.read().await; - let layout_rev = layout_type.clone().into(); - match read_guard.get_filters(Some(&layout_rev), None) { - Some(filter_revs) => Ok(filter_revs - .iter() - .map(|filter_rev| filter_rev.as_ref().into()) - .collect::>()), - None => Ok(vec![]), - } + pub async fn get_grid_filter(&self) -> FlowyResult> { + self.view_manager.get_filters().await } pub async fn update_grid_setting(&self, params: GridSettingChangesetParams) -> FlowyResult<()> { - let filter_changeset = GridFilterChangeset::from(¶ms); - let _ = self - .modify(|grid_pad| Ok(grid_pad.update_grid_setting_rev(params)?)) - .await?; - - let filter_service = self.filter_service.clone(); - tokio::spawn(async move { - filter_service.apply_changeset(filter_changeset).await; - }); + let _ = self.view_manager.update_setting(params).await?; Ok(()) } @@ -492,24 +481,60 @@ impl GridRevisionEditor { Ok(snapshots) } - pub async fn move_item(&self, params: MoveItemParams) -> FlowyResult<()> { - match params.ty { - MoveItemTypePB::MoveField => { - self.move_field(¶ms.item_id, params.from_index, params.to_index) - .await + pub async fn move_row(&self, params: MoveRowParams) -> FlowyResult<()> { + let MoveRowParams { + view_id: _, + from_row_id, + to_row_id, + } = params; + + match self.block_manager.get_row_rev(&from_row_id).await? { + None => tracing::warn!("Move row failed, can not find the row:{}", from_row_id), + Some(row_rev) => { + match ( + self.block_manager.index_of_row(&from_row_id).await, + self.block_manager.index_of_row(&to_row_id).await, + ) { + (Some(from_index), Some(to_index)) => { + tracing::trace!("Move row from {} to {}", from_index, to_index); + let _ = self + .block_manager + .move_row(row_rev.clone(), from_index, to_index) + .await?; + + if let Some(row_changeset) = self.view_manager.move_row(row_rev, to_row_id.clone()).await { + tracing::trace!("Receive row changeset after moving the row"); + match self.block_manager.update_row(row_changeset).await { + Ok(_) => {} + Err(e) => { + tracing::error!("Apply row changeset error:{:?}", e); + } + } + } + } + (_, None) => tracing::warn!("Can not find the from row id: {}", from_row_id), + (None, _) => tracing::warn!("Can not find the to row id: {}", to_row_id), + } } - MoveItemTypePB::MoveRow => self.move_row(¶ms.item_id, params.from_index, params.to_index).await, } + Ok(()) } - pub async fn move_field(&self, field_id: &str, from: i32, to: i32) -> FlowyResult<()> { + pub async fn move_field(&self, params: MoveFieldParams) -> FlowyResult<()> { + let MoveFieldParams { + grid_id: _, + field_id, + from_index, + to_index, + } = params; + let _ = self - .modify(|grid_pad| Ok(grid_pad.move_field(field_id, from as usize, to as usize)?)) + .modify(|grid_pad| Ok(grid_pad.move_field(&field_id, from_index as usize, to_index as usize)?)) .await?; - if let Some((index, field_rev)) = self.grid_pad.read().await.get_field_rev(field_id) { - let delete_field_order = GridFieldIdPB::from(field_id); + if let Some((index, field_rev)) = self.grid_pad.read().await.get_field_rev(&field_id) { + let delete_field_order = FieldIdPB::from(field_id); let insert_field = IndexFieldPB::from_field_rev(field_rev, index); - let notified_changeset = GridFieldChangesetPB { + let notified_changeset = FieldChangesetPB { grid_id: self.grid_id.clone(), inserted_fields: vec![insert_field], deleted_fields: vec![delete_field_order], @@ -521,11 +546,6 @@ impl GridRevisionEditor { Ok(()) } - pub async fn move_row(&self, row_id: &str, from: i32, to: i32) -> FlowyResult<()> { - let _ = self.block_manager.move_row(row_id, from as usize, to as usize).await?; - Ok(()) - } - pub async fn delta_bytes(&self) -> Bytes { self.grid_pad.read().await.delta_bytes() } @@ -538,7 +558,10 @@ impl GridRevisionEditor { let mut blocks_meta_data = vec![]; if original_blocks.len() == duplicated_blocks.len() { for (index, original_block_meta) in original_blocks.iter().enumerate() { - let grid_block_meta_editor = self.block_manager.get_editor(&original_block_meta.block_id).await?; + let grid_block_meta_editor = self + .block_manager + .get_block_editor(&original_block_meta.block_id) + .await?; let duplicated_block_id = &duplicated_blocks[index].block_id; tracing::trace!("Duplicate block:{} meta data", duplicated_block_id); @@ -552,14 +575,41 @@ impl GridRevisionEditor { Ok(BuildGridContext { field_revs: duplicated_fields.into_iter().map(Arc::new).collect(), - blocks: duplicated_blocks, - blocks_meta_data, + block_metas: duplicated_blocks, + blocks: blocks_meta_data, }) } + #[tracing::instrument(level = "trace", skip_all, err)] + pub async fn load_groups(&self) -> FlowyResult { + self.view_manager.load_groups().await + } + + async fn create_row_rev(&self) -> FlowyResult { + let field_revs = self.grid_pad.read().await.get_field_revs(None)?; + let block_id = self.block_id().await?; + + // insert empty row below the row whose id is upper_row_id + let row_rev = RowRevisionBuilder::new(&block_id, &field_revs).build(); + Ok(row_rev) + } + + async fn create_row_pb(&self, row_rev: RowRevision, start_row_id: Option) -> FlowyResult { + let row_pb = RowPB::from(&row_rev); + let block_id = row_rev.block_id.clone(); + + // insert the row + let row_count = self.block_manager.create_row(row_rev, start_row_id).await?; + + // update block row count + let changeset = GridBlockMetaRevisionChangeset::from_row_count(block_id, row_count); + let _ = self.update_block(changeset).await?; + Ok(row_pb) + } + async fn modify(&self, f: F) -> FlowyResult<()> where - F: for<'a> FnOnce(&'a mut GridRevisionPad) -> FlowyResult>, + F: for<'a> FnOnce(&'a mut GridRevisionPad) -> FlowyResult>, { let mut write_guard = self.grid_pad.write().await; if let Some(changeset) = f(&mut *write_guard)? { @@ -568,8 +618,8 @@ impl GridRevisionEditor { Ok(()) } - async fn apply_change(&self, change: GridChangeset) -> FlowyResult<()> { - let GridChangeset { delta, md5 } = change; + async fn apply_change(&self, change: GridRevisionChangeset) -> FlowyResult<()> { + let GridRevisionChangeset { delta, md5 } = change; let user_id = self.user.user_id()?; let (base_rev_id, rev_id) = self.rev_manager.next_rev_id_pair(); let delta_data = delta.json_bytes(); @@ -596,7 +646,7 @@ impl GridRevisionEditor { async fn notify_did_insert_grid_field(&self, field_id: &str) -> FlowyResult<()> { if let Some((index, field_rev)) = self.grid_pad.read().await.get_field_rev(field_id) { let index_field = IndexFieldPB::from_field_rev(field_rev, index); - let notified_changeset = GridFieldChangesetPB::insert(&self.grid_id, vec![index_field]); + let notified_changeset = FieldChangesetPB::insert(&self.grid_id, vec![index_field]); let _ = self.notify_did_update_grid(notified_changeset).await?; } Ok(()) @@ -611,8 +661,8 @@ impl GridRevisionEditor { .get_field_rev(field_id) .map(|(index, field)| (index, field.clone())) { - let updated_field = GridFieldPB::from(field_rev); - let notified_changeset = GridFieldChangesetPB::update(&self.grid_id, vec![updated_field.clone()]); + let updated_field = FieldPB::from(field_rev); + let notified_changeset = FieldChangesetPB::update(&self.grid_id, vec![updated_field.clone()]); let _ = self.notify_did_update_grid(notified_changeset).await?; send_dart_notification(field_id, GridNotification::DidUpdateField) @@ -623,7 +673,7 @@ impl GridRevisionEditor { Ok(()) } - async fn notify_did_update_grid(&self, changeset: GridFieldChangesetPB) -> FlowyResult<()> { + async fn notify_did_update_grid(&self, changeset: FieldChangesetPB) -> FlowyResult<()> { send_dart_notification(&self.grid_id, GridNotification::DidUpdateGridField) .payload(changeset) .send(); @@ -663,7 +713,7 @@ impl RevisionCloudService for GridRevisionCloudService { pub struct GridRevisionCompactor(); impl RevisionCompactor for GridRevisionCompactor { fn bytes_from_revisions(&self, revisions: Vec) -> FlowyResult { - let delta = make_delta_from_revisions::(revisions)?; + let delta = make_text_delta_from_revisions(revisions)?; Ok(delta.json_bytes()) } } diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_editor_task.rs b/frontend/rust-lib/flowy-grid/src/services/grid_editor_task.rs index 0338730818..f5c45811dd 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_editor_task.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_editor_task.rs @@ -19,6 +19,7 @@ impl GridTaskHandler for GridRevisionEditor { Box::pin(async move { match content { TaskContent::Snapshot => {} + TaskContent::Group => {} TaskContent::Filter(context) => self.filter_service.process(context).await?, } Ok(()) diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_editor_trait_impl.rs b/frontend/rust-lib/flowy-grid/src/services/grid_editor_trait_impl.rs new file mode 100644 index 0000000000..43bc74bf12 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/grid_editor_trait_impl.rs @@ -0,0 +1,32 @@ +use crate::services::grid_view_manager::GridViewFieldDelegate; +use flowy_grid_data_model::revision::FieldRevision; +use flowy_sync::client_grid::GridRevisionPad; +use lib_infra::future::{wrap_future, AFFuture}; +use std::sync::Arc; +use tokio::sync::RwLock; + +impl GridViewFieldDelegate for Arc> { + fn get_field_revs(&self) -> AFFuture>> { + let pad = self.clone(); + wrap_future(async move { + match pad.read().await.get_field_revs(None) { + Ok(field_revs) => field_revs, + Err(e) => { + tracing::error!("[GridViewRevisionDelegate] get field revisions failed: {}", e); + vec![] + } + } + }) + } + + fn get_field_rev(&self, field_id: &str) -> AFFuture>> { + let pad = self.clone(); + let field_id = field_id.to_owned(); + wrap_future(async move { + pad.read() + .await + .get_field_rev(&field_id) + .map(|(_, field_rev)| field_rev.clone()) + }) + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs b/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs new file mode 100644 index 0000000000..da3ea1be2d --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs @@ -0,0 +1,266 @@ +use crate::dart_notification::{send_dart_notification, GridNotification}; +use crate::entities::{ + CreateRowParams, GridFilterConfiguration, GridSettingPB, GroupPB, GroupRowsChangesetPB, InsertedRowPB, RowPB, +}; +use crate::services::grid_editor_task::GridServiceTaskScheduler; +use crate::services::grid_view_manager::{GridViewFieldDelegate, GridViewRowDelegate}; +use crate::services::group::{default_group_configuration, GroupConfigurationDelegate, GroupService}; +use crate::services::setting::make_grid_setting; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_grid_data_model::revision::{FieldRevision, GroupConfigurationRevision, RowChangeset, RowRevision}; +use flowy_revision::{RevisionCloudService, RevisionManager, RevisionObjectBuilder}; +use flowy_sync::client_grid::{GridViewRevisionChangeset, GridViewRevisionPad}; +use flowy_sync::entities::grid::GridSettingChangesetParams; +use flowy_sync::entities::revision::Revision; +use lib_infra::future::{wrap_future, AFFuture, FutureResult}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[allow(dead_code)] +pub struct GridViewRevisionEditor { + user_id: String, + view_id: String, + pad: Arc>, + rev_manager: Arc, + field_delegate: Arc, + row_delegate: Arc, + group_service: Arc>, + scheduler: Arc, + did_load_group: AtomicBool, +} + +impl GridViewRevisionEditor { + pub(crate) async fn new( + user_id: &str, + token: &str, + view_id: String, + field_delegate: Arc, + row_delegate: Arc, + scheduler: Arc, + mut rev_manager: RevisionManager, + ) -> FlowyResult { + let cloud = Arc::new(GridViewRevisionCloudService { + token: token.to_owned(), + }); + let view_revision_pad = rev_manager.load::(Some(cloud)).await?; + let pad = Arc::new(RwLock::new(view_revision_pad)); + let rev_manager = Arc::new(rev_manager); + let group_service = GroupService::new(Box::new(pad.clone())).await; + let user_id = user_id.to_owned(); + let did_load_group = AtomicBool::new(false); + Ok(Self { + pad, + user_id, + view_id, + rev_manager, + scheduler, + field_delegate, + row_delegate, + group_service: Arc::new(RwLock::new(group_service)), + did_load_group, + }) + } + + pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, params: &CreateRowParams) { + match params.group_id.as_ref() { + None => {} + Some(group_id) => { + self.group_service + .read() + .await + .will_create_row(row_rev, group_id, |field_id| { + self.field_delegate.get_field_rev(&field_id) + }) + .await; + } + } + } + + pub(crate) async fn did_create_row(&self, row_pb: &RowPB, params: &CreateRowParams) { + // Send the group notification if the current view has groups + match params.group_id.as_ref() { + None => {} + Some(group_id) => { + let inserted_row = InsertedRowPB { + row: row_pb.clone(), + index: None, + }; + let changeset = GroupRowsChangesetPB::insert(group_id.clone(), vec![inserted_row]); + self.notify_did_update_group(changeset).await; + } + } + } + + pub(crate) async fn did_delete_row(&self, row_rev: &RowRevision) { + // Send the group notification if the current view has groups; + if let Some(changesets) = self + .group_service + .write() + .await + .did_delete_row(row_rev, |field_id| self.field_delegate.get_field_rev(&field_id)) + .await + { + for changeset in changesets { + self.notify_did_update_group(changeset).await; + } + } + } + + pub(crate) async fn did_update_row(&self, row_rev: &RowRevision) { + if let Some(changesets) = self + .group_service + .write() + .await + .did_update_row(row_rev, |field_id| self.field_delegate.get_field_rev(&field_id)) + .await + { + for changeset in changesets { + self.notify_did_update_group(changeset).await; + } + } + } + + pub(crate) async fn did_move_row( + &self, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + upper_row_id: &str, + ) { + if let Some(changesets) = self + .group_service + .write() + .await + .did_move_row(row_rev, row_changeset, upper_row_id, |field_id| { + self.field_delegate.get_field_rev(&field_id) + }) + .await + { + for changeset in changesets { + tracing::trace!("Group: {} changeset: {}", changeset.group_id, changeset); + self.notify_did_update_group(changeset).await; + } + } + } + + pub(crate) async fn load_groups(&self) -> FlowyResult> { + let groups = if !self.did_load_group.load(Ordering::SeqCst) { + self.did_load_group.store(true, Ordering::SeqCst); + let field_revs = self.field_delegate.get_field_revs().await; + let row_revs = self.row_delegate.gv_row_revs().await; + match self + .group_service + .write() + .await + .load_groups(&field_revs, row_revs) + .await + { + None => vec![], + Some(groups) => groups, + } + } else { + self.group_service.read().await.groups().await + }; + + Ok(groups.into_iter().map(GroupPB::from).collect()) + } + + pub(crate) async fn get_setting(&self) -> GridSettingPB { + let field_revs = self.field_delegate.get_field_revs().await; + let grid_setting = make_grid_setting(self.pad.read().await.get_setting_rev(), &field_revs); + grid_setting + } + + pub(crate) async fn update_setting(&self, changeset: GridSettingChangesetParams) -> FlowyResult<()> { + let _ = self.modify(|pad| Ok(pad.update_setting(changeset)?)).await; + Ok(()) + } + + pub(crate) async fn get_filters(&self) -> Vec { + let field_revs = self.field_delegate.get_field_revs().await; + match self.pad.read().await.get_setting_rev().get_all_filters(&field_revs) { + None => vec![], + Some(filters) => filters + .into_values() + .flatten() + .map(|filter| GridFilterConfiguration::from(filter.as_ref())) + .collect(), + } + } + + async fn notify_did_update_group(&self, changeset: GroupRowsChangesetPB) { + send_dart_notification(&changeset.group_id, GridNotification::DidUpdateGroup) + .payload(changeset) + .send(); + } + + async fn modify(&self, f: F) -> FlowyResult<()> + where + F: for<'a> FnOnce(&'a mut GridViewRevisionPad) -> FlowyResult>, + { + let mut write_guard = self.pad.write().await; + match f(&mut *write_guard)? { + None => {} + Some(change) => { + let _ = self.apply_change(change).await?; + } + } + Ok(()) + } + + async fn apply_change(&self, change: GridViewRevisionChangeset) -> FlowyResult<()> { + let GridViewRevisionChangeset { delta, md5 } = change; + let user_id = self.user_id.clone(); + let (base_rev_id, rev_id) = self.rev_manager.next_rev_id_pair(); + let delta_data = delta.json_bytes(); + let revision = Revision::new( + &self.rev_manager.object_id, + base_rev_id, + rev_id, + delta_data, + &user_id, + md5, + ); + let _ = self.rev_manager.add_local_revision(&revision).await?; + Ok(()) + } +} + +struct GridViewRevisionCloudService { + #[allow(dead_code)] + token: String, +} + +impl RevisionCloudService for GridViewRevisionCloudService { + #[tracing::instrument(level = "trace", skip(self))] + fn fetch_object(&self, _user_id: &str, _object_id: &str) -> FutureResult, FlowyError> { + FutureResult::new(async move { Ok(vec![]) }) + } +} + +struct GridViewRevisionPadBuilder(); +impl RevisionObjectBuilder for GridViewRevisionPadBuilder { + type Output = GridViewRevisionPad; + + fn build_object(object_id: &str, revisions: Vec) -> FlowyResult { + let pad = GridViewRevisionPad::from_revisions(object_id, revisions)?; + Ok(pad) + } +} + +impl GroupConfigurationDelegate for Arc> { + fn get_group_configuration(&self, field_rev: Arc) -> AFFuture { + let view_pad = self.clone(); + wrap_future(async move { + let grid_pad = view_pad.read().await; + let configurations = grid_pad.get_groups(&field_rev.id, &field_rev.ty); + match configurations { + None => default_group_configuration(&field_rev), + Some(mut configurations) => { + assert_eq!(configurations.len(), 1); + (&*configurations.pop().unwrap()).clone() + } + } + }) + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs b/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs new file mode 100644 index 0000000000..5a8faad6a0 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs @@ -0,0 +1,209 @@ +use crate::entities::{CreateRowParams, GridFilterConfiguration, GridSettingPB, RepeatedGridGroupPB, RowPB}; +use crate::manager::GridUser; +use crate::services::grid_editor_task::GridServiceTaskScheduler; +use crate::services::grid_view_editor::GridViewRevisionEditor; +use bytes::Bytes; +use dashmap::DashMap; +use flowy_error::FlowyResult; +use flowy_grid_data_model::revision::{FieldRevision, RowChangeset, RowRevision}; +use flowy_revision::disk::SQLiteGridViewRevisionPersistence; +use flowy_revision::{RevisionCompactor, RevisionManager, RevisionPersistence, SQLiteRevisionSnapshotPersistence}; +use flowy_sync::entities::grid::GridSettingChangesetParams; +use flowy_sync::entities::revision::Revision; +use flowy_sync::util::make_text_delta_from_revisions; +use lib_infra::future::AFFuture; +use std::sync::Arc; + +type ViewId = String; + +pub trait GridViewFieldDelegate: Send + Sync + 'static { + fn get_field_revs(&self) -> AFFuture>>; + fn get_field_rev(&self, field_id: &str) -> AFFuture>>; +} + +pub trait GridViewRowDelegate: Send + Sync + 'static { + fn gv_index_of_row(&self, row_id: &str) -> AFFuture>; + fn gv_get_row_rev(&self, row_id: &str) -> AFFuture>>; + fn gv_row_revs(&self) -> AFFuture>>; +} + +pub(crate) struct GridViewManager { + grid_id: String, + user: Arc, + field_delegate: Arc, + row_delegate: Arc, + view_editors: DashMap>, + scheduler: Arc, +} + +impl GridViewManager { + pub(crate) async fn new( + grid_id: String, + user: Arc, + field_delegate: Arc, + row_delegate: Arc, + scheduler: Arc, + ) -> FlowyResult { + Ok(Self { + grid_id, + user, + scheduler, + field_delegate, + row_delegate, + view_editors: DashMap::default(), + }) + } + + /// When the row was created, we may need to modify the [RowRevision] according to the [CreateRowParams]. + pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, params: &CreateRowParams) { + for view_editor in self.view_editors.iter() { + view_editor.will_create_row(row_rev, params).await; + } + } + + /// Notify the view that the row was created. For the moment, the view is just sending notifications. + pub(crate) async fn did_create_row(&self, row_pb: &RowPB, params: &CreateRowParams) { + for view_editor in self.view_editors.iter() { + view_editor.did_create_row(row_pb, params).await; + } + } + + /// Insert/Delete the group's row if the corresponding data was changed. + pub(crate) async fn did_update_row(&self, row_id: &str) { + match self.row_delegate.gv_get_row_rev(row_id).await { + None => { + tracing::warn!("Can not find the row in grid view"); + } + Some(row_rev) => { + for view_editor in self.view_editors.iter() { + view_editor.did_update_row(&row_rev).await; + } + } + } + } + + pub(crate) async fn did_update_cell(&self, row_id: &str, _field_id: &str) { + self.did_update_row(row_id).await + } + + pub(crate) async fn did_delete_row(&self, row_rev: Arc) { + for view_editor in self.view_editors.iter() { + view_editor.did_delete_row(&row_rev).await; + } + } + + pub(crate) async fn get_setting(&self) -> FlowyResult { + let view_editor = self.get_default_view_editor().await?; + Ok(view_editor.get_setting().await) + } + + pub(crate) async fn update_setting(&self, params: GridSettingChangesetParams) -> FlowyResult<()> { + let view_editor = self.get_default_view_editor().await?; + let _ = view_editor.update_setting(params).await?; + Ok(()) + } + + pub(crate) async fn get_filters(&self) -> FlowyResult> { + let view_editor = self.get_default_view_editor().await?; + Ok(view_editor.get_filters().await) + } + + pub(crate) async fn load_groups(&self) -> FlowyResult { + let view_editor = self.get_default_view_editor().await?; + let groups = view_editor.load_groups().await?; + Ok(RepeatedGridGroupPB { items: groups }) + } + + /// It may generate a RowChangeset when the Row was moved from one group to another. + /// The return value, [RowChangeset], contains the changes made by the groups. + /// + pub(crate) async fn move_row(&self, row_rev: Arc, to_row_id: String) -> Option { + let mut row_changeset = RowChangeset::new(row_rev.id.clone()); + for view_editor in self.view_editors.iter() { + view_editor.did_move_row(&row_rev, &mut row_changeset, &to_row_id).await; + } + + if row_changeset.has_changed() { + Some(row_changeset) + } else { + None + } + } + + pub(crate) async fn get_view_editor(&self, view_id: &str) -> FlowyResult> { + debug_assert!(!view_id.is_empty()); + match self.view_editors.get(view_id) { + None => { + let editor = Arc::new( + make_view_editor( + &self.user, + view_id, + self.field_delegate.clone(), + self.row_delegate.clone(), + self.scheduler.clone(), + ) + .await?, + ); + self.view_editors.insert(view_id.to_owned(), editor.clone()); + Ok(editor) + } + Some(view_editor) => Ok(view_editor.clone()), + } + } + + async fn get_default_view_editor(&self) -> FlowyResult> { + self.get_view_editor(&self.grid_id).await + } +} + +async fn make_view_editor( + user: &Arc, + view_id: &str, + field_delegate: Arc, + row_delegate: Arc, + scheduler: Arc, +) -> FlowyResult { + tracing::trace!("Open view:{} editor", view_id); + + let rev_manager = make_grid_view_rev_manager(user, view_id).await?; + let user_id = user.user_id()?; + let token = user.token()?; + let view_id = view_id.to_owned(); + GridViewRevisionEditor::new( + &user_id, + &token, + view_id, + field_delegate, + row_delegate, + scheduler, + rev_manager, + ) + .await +} + +pub async fn make_grid_view_rev_manager(user: &Arc, view_id: &str) -> FlowyResult { + tracing::trace!("Open view:{} editor", view_id); + let user_id = user.user_id()?; + let pool = user.db_pool()?; + + let disk_cache = SQLiteGridViewRevisionPersistence::new(&user_id, pool.clone()); + let rev_persistence = RevisionPersistence::new(&user_id, view_id, disk_cache); + let rev_compactor = GridViewRevisionCompactor(); + + let snapshot_persistence = SQLiteRevisionSnapshotPersistence::new(view_id, pool); + Ok(RevisionManager::new( + &user_id, + view_id, + rev_persistence, + rev_compactor, + snapshot_persistence, + )) +} + +pub struct GridViewRevisionCompactor(); +impl RevisionCompactor for GridViewRevisionCompactor { + fn bytes_from_revisions(&self, revisions: Vec) -> FlowyResult { + let delta = make_text_delta_from_revisions(revisions)?; + Ok(delta.json_bytes()) + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/group/group_generator/checkbox_group.rs b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/checkbox_group.rs new file mode 100644 index 0000000000..c1284dd659 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/checkbox_group.rs @@ -0,0 +1,80 @@ +use crate::entities::{CheckboxGroupConfigurationPB, GroupRowsChangesetPB}; + +use flowy_grid_data_model::revision::{FieldRevision, RowChangeset, RowRevision}; + +use crate::services::field::{CheckboxCellData, CheckboxCellDataParser, CheckboxTypeOptionPB, CHECK, UNCHECK}; +use crate::services::group::{GenericGroupController, Group, GroupController, GroupGenerator, Groupable}; + +pub type CheckboxGroupController = GenericGroupController< + CheckboxGroupConfigurationPB, + CheckboxTypeOptionPB, + CheckboxGroupGenerator, + CheckboxCellDataParser, +>; + +impl Groupable for CheckboxGroupController { + type CellDataType = CheckboxCellData; + + fn can_group(&self, _content: &str, _cell_data: &Self::CellDataType) -> bool { + false + } + + fn add_row_if_match( + &mut self, + _row_rev: &RowRevision, + _cell_data: &Self::CellDataType, + ) -> Vec { + todo!() + } + + fn remove_row_if_match( + &mut self, + _row_rev: &RowRevision, + _cell_data: &Self::CellDataType, + ) -> Vec { + todo!() + } + + fn move_row_if_match( + &mut self, + _field_rev: &FieldRevision, + _row_rev: &RowRevision, + _row_changeset: &mut RowChangeset, + _cell_data: &Self::CellDataType, + _to_row_id: &str, + ) -> Vec { + todo!() + } +} + +impl GroupController for CheckboxGroupController { + fn will_create_row(&mut self, _row_rev: &mut RowRevision, _field_rev: &FieldRevision, _group_id: &str) { + todo!() + } +} + +pub struct CheckboxGroupGenerator(); +impl GroupGenerator for CheckboxGroupGenerator { + type ConfigurationType = CheckboxGroupConfigurationPB; + type TypeOptionType = CheckboxTypeOptionPB; + + fn generate_groups( + field_id: &str, + _configuration: &Option, + _type_option: &Option, + ) -> Vec { + let check_group = Group::new( + "true".to_string(), + field_id.to_owned(), + "".to_string(), + CHECK.to_string(), + ); + let uncheck_group = Group::new( + "false".to_string(), + field_id.to_owned(), + "".to_string(), + UNCHECK.to_string(), + ); + vec![check_group, uncheck_group] + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/group/group_generator/group_controller.rs b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/group_controller.rs new file mode 100644 index 0000000000..a1d5df2154 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/group_controller.rs @@ -0,0 +1,296 @@ +use crate::entities::{GroupPB, GroupRowsChangesetPB, RowPB}; +use crate::services::cell::{decode_any_cell_data, CellBytesParser}; +use bytes::Bytes; +use flowy_error::FlowyResult; +use flowy_grid_data_model::revision::{ + FieldRevision, GroupConfigurationRevision, RowChangeset, RowRevision, TypeOptionDataDeserializer, +}; +use indexmap::IndexMap; +use std::marker::PhantomData; +use std::sync::Arc; + +pub trait GroupGenerator { + type ConfigurationType; + type TypeOptionType; + + fn generate_groups( + field_id: &str, + configuration: &Option, + type_option: &Option, + ) -> Vec; +} + +pub trait Groupable: Send + Sync { + type CellDataType; + fn can_group(&self, content: &str, cell_data: &Self::CellDataType) -> bool; + fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec; + fn remove_row_if_match( + &mut self, + row_rev: &RowRevision, + cell_data: &Self::CellDataType, + ) -> Vec; + + fn move_row_if_match( + &mut self, + field_rev: &FieldRevision, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + cell_data: &Self::CellDataType, + to_row_id: &str, + ) -> Vec; +} + +pub trait GroupController: GroupControllerSharedAction + Send + Sync { + fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str); +} + +pub trait GroupControllerSharedAction: Send + Sync { + // The field that is used for grouping the rows + fn field_id(&self) -> &str; + fn groups(&self) -> Vec; + fn group_rows(&mut self, row_revs: &[Arc], field_rev: &FieldRevision) -> FlowyResult<()>; + fn did_update_row( + &mut self, + row_rev: &RowRevision, + field_rev: &FieldRevision, + ) -> FlowyResult>; + + fn did_delete_row( + &mut self, + row_rev: &RowRevision, + field_rev: &FieldRevision, + ) -> FlowyResult>; + + fn did_move_row( + &mut self, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + field_rev: &FieldRevision, + to_row_id: &str, + ) -> FlowyResult>; +} + +const DEFAULT_GROUP_ID: &str = "default_group"; + +/// C: represents the group configuration structure +/// T: the type option data deserializer that impl [TypeOptionDataDeserializer] +/// G: the group generator, [GroupGenerator] +/// P: the parser that impl [CellBytesParser] for the CellBytes +pub struct GenericGroupController { + pub field_id: String, + pub groups_map: IndexMap, + default_group: Group, + pub type_option: Option, + pub configuration: Option, + group_action_phantom: PhantomData, + cell_parser_phantom: PhantomData

, +} + +#[derive(Clone)] +pub struct Group { + pub id: String, + pub field_id: String, + pub desc: String, + rows: Vec, + pub content: String, +} + +impl std::convert::From for GroupPB { + fn from(group: Group) -> Self { + Self { + field_id: group.field_id, + group_id: group.id, + desc: group.desc, + rows: group.rows, + } + } +} + +impl Group { + pub fn new(id: String, field_id: String, desc: String, content: String) -> Self { + Self { + id, + field_id, + desc, + rows: vec![], + content, + } + } + + pub fn contains_row(&self, row_id: &str) -> bool { + self.rows.iter().any(|row| row.id == row_id) + } + + pub fn remove_row(&mut self, row_id: &str) { + match self.rows.iter().position(|row| row.id == row_id) { + None => {} + Some(pos) => { + self.rows.remove(pos); + } + } + } + + pub fn add_row(&mut self, row_pb: RowPB) { + match self.rows.iter().find(|row| row.id == row_pb.id) { + None => { + self.rows.push(row_pb); + } + Some(_) => {} + } + } + + pub fn insert_row(&mut self, index: usize, row_pb: RowPB) { + if index < self.rows.len() { + self.rows.insert(index, row_pb); + } else { + tracing::error!("Insert row index:{} beyond the bounds:{},", index, self.rows.len()); + } + } + + pub fn index_of_row(&self, row_id: &str) -> Option { + self.rows.iter().position(|row| row.id == row_id) + } + + pub fn number_of_row(&self) -> usize { + self.rows.len() + } +} + +impl GenericGroupController +where + C: TryFrom, + T: TypeOptionDataDeserializer, + G: GroupGenerator, +{ + pub fn new(field_rev: &Arc, configuration: GroupConfigurationRevision) -> FlowyResult { + let configuration = match configuration.content { + None => None, + Some(content) => Some(C::try_from(Bytes::from(content))?), + }; + let field_type_rev = field_rev.ty; + let type_option = field_rev.get_type_option_entry::(field_type_rev); + let groups = G::generate_groups(&field_rev.id, &configuration, &type_option); + + let default_group = Group::new( + DEFAULT_GROUP_ID.to_owned(), + field_rev.id.clone(), + format!("No {}", field_rev.name), + "".to_string(), + ); + + Ok(Self { + field_id: field_rev.id.clone(), + groups_map: groups.into_iter().map(|group| (group.id.clone(), group)).collect(), + default_group, + type_option, + configuration, + group_action_phantom: PhantomData, + cell_parser_phantom: PhantomData, + }) + } +} + +impl GroupControllerSharedAction for GenericGroupController +where + P: CellBytesParser, + Self: Groupable, +{ + fn field_id(&self) -> &str { + &self.field_id + } + + fn groups(&self) -> Vec { + let default_group = self.default_group.clone(); + let mut groups: Vec = self.groups_map.values().cloned().collect(); + if !default_group.rows.is_empty() { + groups.push(default_group); + } + groups + } + + fn group_rows(&mut self, row_revs: &[Arc], field_rev: &FieldRevision) -> FlowyResult<()> { + if self.configuration.is_none() { + return Ok(()); + } + + for row_rev in row_revs { + if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { + let mut records: Vec = vec![]; + let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); + let cell_data = cell_bytes.parser::

()?; + for group in self.groups_map.values() { + if self.can_group(&group.content, &cell_data) { + records.push(GroupRecord { + row: row_rev.into(), + group_id: group.id.clone(), + }); + } + } + + if records.is_empty() { + self.default_group.rows.push(row_rev.into()); + } else { + for record in records { + if let Some(group) = self.groups_map.get_mut(&record.group_id) { + group.rows.push(record.row); + } + } + } + } else { + self.default_group.rows.push(row_rev.into()); + } + } + + Ok(()) + } + + fn did_update_row( + &mut self, + row_rev: &RowRevision, + field_rev: &FieldRevision, + ) -> FlowyResult> { + if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { + let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); + let cell_data = cell_bytes.parser::

()?; + Ok(self.add_row_if_match(row_rev, &cell_data)) + } else { + Ok(vec![]) + } + } + + fn did_delete_row( + &mut self, + row_rev: &RowRevision, + field_rev: &FieldRevision, + ) -> FlowyResult> { + if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { + let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); + let cell_data = cell_bytes.parser::

()?; + Ok(self.remove_row_if_match(row_rev, &cell_data)) + } else { + Ok(vec![]) + } + } + + fn did_move_row( + &mut self, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + field_rev: &FieldRevision, + to_row_id: &str, + ) -> FlowyResult> { + if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { + let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), field_rev); + let cell_data = cell_bytes.parser::

()?; + tracing::trace!("Move row:{} to row:{}", row_rev.id, to_row_id); + Ok(self.move_row_if_match(field_rev, row_rev, row_changeset, &cell_data, to_row_id)) + } else { + Ok(vec![]) + } + } +} + +struct GroupRecord { + row: RowPB, + group_id: String, +} diff --git a/frontend/rust-lib/flowy-grid/src/services/group/group_generator/mod.rs b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/mod.rs new file mode 100644 index 0000000000..08e691a75e --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/mod.rs @@ -0,0 +1,7 @@ +mod checkbox_group; +mod group_controller; +mod select_option_group; + +pub use checkbox_group::*; +pub use group_controller::*; +pub use select_option_group::*; diff --git a/frontend/rust-lib/flowy-grid/src/services/group/group_generator/select_option_group.rs b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/select_option_group.rs new file mode 100644 index 0000000000..6e569a7b54 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_generator/select_option_group.rs @@ -0,0 +1,287 @@ +use crate::entities::{GroupRowsChangesetPB, InsertedRowPB, RowPB, SelectOptionGroupConfigurationPB}; +use crate::services::cell::insert_select_option_cell; +use crate::services::field::{ + MultiSelectTypeOptionPB, SelectOptionCellDataPB, SelectOptionCellDataParser, SingleSelectTypeOptionPB, +}; +use crate::services::group::{GenericGroupController, Group, GroupController, GroupGenerator, Groupable}; + +use flowy_grid_data_model::revision::{FieldRevision, RowChangeset, RowRevision}; + +// SingleSelect +pub type SingleSelectGroupController = GenericGroupController< + SelectOptionGroupConfigurationPB, + SingleSelectTypeOptionPB, + SingleSelectGroupGenerator, + SelectOptionCellDataParser, +>; + +impl Groupable for SingleSelectGroupController { + type CellDataType = SelectOptionCellDataPB; + fn can_group(&self, content: &str, cell_data: &SelectOptionCellDataPB) -> bool { + cell_data.select_options.iter().any(|option| option.id == content) + } + + fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec { + let mut changesets = vec![]; + self.groups_map.iter_mut().for_each(|(_, group): (_, &mut Group)| { + add_row(group, &mut changesets, cell_data, row_rev); + }); + changesets + } + + fn remove_row_if_match( + &mut self, + row_rev: &RowRevision, + cell_data: &Self::CellDataType, + ) -> Vec { + let mut changesets = vec![]; + self.groups_map.iter_mut().for_each(|(_, group): (_, &mut Group)| { + remove_row(group, &mut changesets, cell_data, row_rev); + }); + changesets + } + + fn move_row_if_match( + &mut self, + field_rev: &FieldRevision, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + cell_data: &Self::CellDataType, + to_row_id: &str, + ) -> Vec { + let mut group_changeset = vec![]; + self.groups_map.iter_mut().for_each(|(_, group): (_, &mut Group)| { + move_row( + group, + &mut group_changeset, + field_rev, + row_rev, + row_changeset, + cell_data, + to_row_id, + ); + }); + group_changeset + } +} + +impl GroupController for SingleSelectGroupController { + fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str) { + let group: Option<&mut Group> = self.groups_map.get_mut(group_id); + match group { + None => {} + Some(group) => { + let cell_rev = insert_select_option_cell(group.id.clone(), field_rev); + row_rev.cells.insert(field_rev.id.clone(), cell_rev); + group.add_row(RowPB::from(row_rev)); + } + } + } +} + +pub struct SingleSelectGroupGenerator(); +impl GroupGenerator for SingleSelectGroupGenerator { + type ConfigurationType = SelectOptionGroupConfigurationPB; + type TypeOptionType = SingleSelectTypeOptionPB; + fn generate_groups( + field_id: &str, + _configuration: &Option, + type_option: &Option, + ) -> Vec { + match type_option { + None => vec![], + Some(type_option) => type_option + .options + .iter() + .map(|option| { + Group::new( + option.id.clone(), + field_id.to_owned(), + option.name.clone(), + option.id.clone(), + ) + }) + .collect(), + } + } +} + +// MultiSelect +pub type MultiSelectGroupController = GenericGroupController< + SelectOptionGroupConfigurationPB, + MultiSelectTypeOptionPB, + MultiSelectGroupGenerator, + SelectOptionCellDataParser, +>; + +impl Groupable for MultiSelectGroupController { + type CellDataType = SelectOptionCellDataPB; + + fn can_group(&self, content: &str, cell_data: &SelectOptionCellDataPB) -> bool { + cell_data.select_options.iter().any(|option| option.id == content) + } + + fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec { + let mut changesets = vec![]; + self.groups_map.iter_mut().for_each(|(_, group): (_, &mut Group)| { + add_row(group, &mut changesets, cell_data, row_rev); + }); + changesets + } + + fn remove_row_if_match( + &mut self, + row_rev: &RowRevision, + cell_data: &Self::CellDataType, + ) -> Vec { + let mut changesets = vec![]; + self.groups_map.iter_mut().for_each(|(_, group): (_, &mut Group)| { + remove_row(group, &mut changesets, cell_data, row_rev); + }); + changesets + } + + fn move_row_if_match( + &mut self, + field_rev: &FieldRevision, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + cell_data: &Self::CellDataType, + to_row_id: &str, + ) -> Vec { + let mut group_changeset = vec![]; + self.groups_map.iter_mut().for_each(|(_, group): (_, &mut Group)| { + move_row( + group, + &mut group_changeset, + field_rev, + row_rev, + row_changeset, + cell_data, + to_row_id, + ); + }); + group_changeset + } +} + +impl GroupController for MultiSelectGroupController { + fn will_create_row(&mut self, row_rev: &mut RowRevision, field_rev: &FieldRevision, group_id: &str) { + let group: Option<&Group> = self.groups_map.get(group_id); + match group { + None => tracing::warn!("Can not find the group: {}", group_id), + Some(group) => { + let cell_rev = insert_select_option_cell(group.id.clone(), field_rev); + row_rev.cells.insert(field_rev.id.clone(), cell_rev); + } + } + } +} + +pub struct MultiSelectGroupGenerator(); +impl GroupGenerator for MultiSelectGroupGenerator { + type ConfigurationType = SelectOptionGroupConfigurationPB; + type TypeOptionType = MultiSelectTypeOptionPB; + + fn generate_groups( + field_id: &str, + _configuration: &Option, + type_option: &Option, + ) -> Vec { + match type_option { + None => vec![], + Some(type_option) => type_option + .options + .iter() + .map(|option| { + Group::new( + option.id.clone(), + field_id.to_owned(), + option.name.clone(), + option.id.clone(), + ) + }) + .collect(), + } + } +} + +fn add_row( + group: &mut Group, + changesets: &mut Vec, + cell_data: &SelectOptionCellDataPB, + row_rev: &RowRevision, +) { + cell_data.select_options.iter().for_each(|option| { + if option.id == group.id { + if !group.contains_row(&row_rev.id) { + let row_pb = RowPB::from(row_rev); + changesets.push(GroupRowsChangesetPB::insert( + group.id.clone(), + vec![InsertedRowPB::new(row_pb.clone())], + )); + group.add_row(row_pb); + } + } else if group.contains_row(&row_rev.id) { + changesets.push(GroupRowsChangesetPB::delete(group.id.clone(), vec![row_rev.id.clone()])); + group.remove_row(&row_rev.id); + } + }); +} + +fn remove_row( + group: &mut Group, + changesets: &mut Vec, + cell_data: &SelectOptionCellDataPB, + row_rev: &RowRevision, +) { + cell_data.select_options.iter().for_each(|option| { + if option.id == group.id && group.contains_row(&row_rev.id) { + changesets.push(GroupRowsChangesetPB::delete(group.id.clone(), vec![row_rev.id.clone()])); + group.remove_row(&row_rev.id); + } + }); +} + +fn move_row( + group: &mut Group, + group_changeset: &mut Vec, + field_rev: &FieldRevision, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + cell_data: &SelectOptionCellDataPB, + to_row_id: &str, +) { + cell_data.select_options.iter().for_each(|option| { + // Remove the row in which group contains the row + let is_group_contains = group.contains_row(&row_rev.id); + let to_index = group.index_of_row(to_row_id); + + if option.id == group.id && is_group_contains { + group_changeset.push(GroupRowsChangesetPB::delete(group.id.clone(), vec![row_rev.id.clone()])); + group.remove_row(&row_rev.id); + } + + // Find the inserted group + if let Some(to_index) = to_index { + let row_pb = RowPB::from(row_rev); + let inserted_row = InsertedRowPB { + row: row_pb.clone(), + index: Some(to_index as i32), + }; + group_changeset.push(GroupRowsChangesetPB::insert(group.id.clone(), vec![inserted_row])); + if group.number_of_row() == to_index { + group.add_row(row_pb); + } else { + group.insert_row(to_index, row_pb); + } + } + + // If the inserted row comes from other group, it needs to update the corresponding cell content. + if to_index.is_some() && option.id != group.id { + // Update the corresponding row's cell content. + let cell_rev = insert_select_option_cell(group.id.clone(), field_rev); + row_changeset.cell_by_field_id.insert(field_rev.id.clone(), cell_rev); + } + }); +} diff --git a/frontend/rust-lib/flowy-grid/src/services/group/group_service.rs b/frontend/rust-lib/flowy-grid/src/services/group/group_service.rs new file mode 100644 index 0000000000..0e152bf27c --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_service.rs @@ -0,0 +1,227 @@ +use crate::entities::{ + CheckboxGroupConfigurationPB, DateGroupConfigurationPB, FieldType, GroupRowsChangesetPB, + NumberGroupConfigurationPB, SelectOptionGroupConfigurationPB, TextGroupConfigurationPB, UrlGroupConfigurationPB, +}; +use crate::services::group::{ + CheckboxGroupController, Group, GroupController, MultiSelectGroupController, SingleSelectGroupController, +}; +use bytes::Bytes; +use flowy_error::FlowyResult; +use flowy_grid_data_model::revision::{ + gen_grid_group_id, FieldRevision, GroupConfigurationRevision, RowChangeset, RowRevision, +}; +use lib_infra::future::AFFuture; +use std::future::Future; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub trait GroupConfigurationDelegate: Send + Sync + 'static { + fn get_group_configuration(&self, field_rev: Arc) -> AFFuture; +} + +pub(crate) struct GroupService { + delegate: Box, + group_controller: Option>>, +} + +impl GroupService { + pub(crate) async fn new(delegate: Box) -> Self { + Self { + delegate, + group_controller: None, + } + } + + pub(crate) async fn groups(&self) -> Vec { + if let Some(group_action_handler) = self.group_controller.as_ref() { + group_action_handler.read().await.groups() + } else { + vec![] + } + } + + pub(crate) async fn load_groups( + &mut self, + field_revs: &[Arc], + row_revs: Vec>, + ) -> Option> { + let field_rev = find_group_field(field_revs)?; + let field_type: FieldType = field_rev.ty.into(); + let configuration = self.delegate.get_group_configuration(field_rev.clone()).await; + match self + .build_groups(&field_type, &field_rev, row_revs, configuration) + .await + { + Ok(groups) => Some(groups), + Err(_) => None, + } + } + + pub(crate) async fn will_create_row(&self, row_rev: &mut RowRevision, group_id: &str, get_field_fn: F) + where + F: FnOnce(String) -> O, + O: Future>> + Send + Sync + 'static, + { + if let Some(group_controller) = self.group_controller.as_ref() { + let field_id = group_controller.read().await.field_id().to_owned(); + match get_field_fn(field_id).await { + None => {} + Some(field_rev) => { + group_controller + .write() + .await + .will_create_row(row_rev, &field_rev, group_id); + } + } + } + } + + pub(crate) async fn did_delete_row( + &self, + row_rev: &RowRevision, + get_field_fn: F, + ) -> Option> + where + F: FnOnce(String) -> O, + O: Future>> + Send + Sync + 'static, + { + let group_controller = self.group_controller.as_ref()?; + let field_id = group_controller.read().await.field_id().to_owned(); + let field_rev = get_field_fn(field_id).await?; + + match group_controller.write().await.did_delete_row(row_rev, &field_rev) { + Ok(changesets) => Some(changesets), + Err(e) => { + tracing::error!("Delete group data failed, {:?}", e); + None + } + } + } + + pub(crate) async fn did_move_row( + &self, + row_rev: &RowRevision, + row_changeset: &mut RowChangeset, + upper_row_id: &str, + get_field_fn: F, + ) -> Option> + where + F: FnOnce(String) -> O, + O: Future>> + Send + Sync + 'static, + { + let group_controller = self.group_controller.as_ref()?; + let field_id = group_controller.read().await.field_id().to_owned(); + let field_rev = get_field_fn(field_id).await?; + + match group_controller + .write() + .await + .did_move_row(row_rev, row_changeset, &field_rev, upper_row_id) + { + Ok(changesets) => Some(changesets), + Err(e) => { + tracing::error!("Move group data failed, {:?}", e); + None + } + } + } + + #[tracing::instrument(level = "trace", skip_all)] + pub(crate) async fn did_update_row( + &self, + row_rev: &RowRevision, + get_field_fn: F, + ) -> Option> + where + F: FnOnce(String) -> O, + O: Future>> + Send + Sync + 'static, + { + let group_controller = self.group_controller.as_ref()?; + let field_id = group_controller.read().await.field_id().to_owned(); + let field_rev = get_field_fn(field_id).await?; + + match group_controller.write().await.did_update_row(row_rev, &field_rev) { + Ok(changeset) => Some(changeset), + Err(e) => { + tracing::error!("Update group data failed, {:?}", e); + None + } + } + } + + #[tracing::instrument(level = "trace", skip_all, err)] + async fn build_groups( + &mut self, + field_type: &FieldType, + field_rev: &Arc, + row_revs: Vec>, + configuration: GroupConfigurationRevision, + ) -> FlowyResult> { + match field_type { + FieldType::RichText => { + // let generator = GroupGenerator::::from_configuration(configuration); + } + FieldType::Number => { + // let generator = GroupGenerator::::from_configuration(configuration); + } + FieldType::DateTime => { + // let generator = GroupGenerator::::from_configuration(configuration); + } + FieldType::SingleSelect => { + let controller = SingleSelectGroupController::new(field_rev, configuration)?; + self.group_controller = Some(Arc::new(RwLock::new(controller))); + } + FieldType::MultiSelect => { + let controller = MultiSelectGroupController::new(field_rev, configuration)?; + self.group_controller = Some(Arc::new(RwLock::new(controller))); + } + FieldType::Checkbox => { + let controller = CheckboxGroupController::new(field_rev, configuration)?; + self.group_controller = Some(Arc::new(RwLock::new(controller))); + } + FieldType::URL => { + // let generator = GroupGenerator::::from_configuration(configuration); + } + }; + + let mut groups = vec![]; + if let Some(group_action_handler) = self.group_controller.as_ref() { + let mut write_guard = group_action_handler.write().await; + let _ = write_guard.group_rows(&row_revs, field_rev)?; + groups = write_guard.groups(); + drop(write_guard); + } + + Ok(groups) + } +} + +fn find_group_field(field_revs: &[Arc]) -> Option> { + let field_rev = field_revs + .iter() + .find(|field_rev| { + let field_type: FieldType = field_rev.ty.into(); + field_type.can_be_group() + }) + .cloned(); + field_rev +} + +pub fn default_group_configuration(field_rev: &FieldRevision) -> GroupConfigurationRevision { + let field_type: FieldType = field_rev.ty.into(); + let bytes: Bytes = match field_type { + FieldType::RichText => TextGroupConfigurationPB::default().try_into().unwrap(), + FieldType::Number => NumberGroupConfigurationPB::default().try_into().unwrap(), + FieldType::DateTime => DateGroupConfigurationPB::default().try_into().unwrap(), + FieldType::SingleSelect => SelectOptionGroupConfigurationPB::default().try_into().unwrap(), + FieldType::MultiSelect => SelectOptionGroupConfigurationPB::default().try_into().unwrap(), + FieldType::Checkbox => CheckboxGroupConfigurationPB::default().try_into().unwrap(), + FieldType::URL => UrlGroupConfigurationPB::default().try_into().unwrap(), + }; + GroupConfigurationRevision { + id: gen_grid_group_id(), + field_id: field_rev.id.clone(), + field_type_rev: field_rev.ty, + content: Some(bytes.to_vec()), + } +} diff --git a/frontend/rust-lib/flowy-grid/src/services/group/mod.rs b/frontend/rust-lib/flowy-grid/src/services/group/mod.rs new file mode 100644 index 0000000000..15b401f4c0 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/src/services/group/mod.rs @@ -0,0 +1,5 @@ +mod group_generator; +mod group_service; + +pub(crate) use group_generator::*; +pub(crate) use group_service::*; diff --git a/frontend/rust-lib/flowy-grid/src/services/mod.rs b/frontend/rust-lib/flowy-grid/src/services/mod.rs index 6e670bae8c..ab864c544a 100644 --- a/frontend/rust-lib/flowy-grid/src/services/mod.rs +++ b/frontend/rust-lib/flowy-grid/src/services/mod.rs @@ -1,12 +1,17 @@ mod util; +pub mod block_editor; mod block_manager; -pub mod block_revision_editor; +mod block_manager_trait_impl; pub mod cell; pub mod field; mod filter; pub mod grid_editor; mod grid_editor_task; +mod grid_editor_trait_impl; +pub mod grid_view_editor; +pub mod grid_view_manager; +pub mod group; pub mod persistence; pub mod row; pub mod setting; diff --git a/frontend/rust-lib/flowy-grid/src/services/persistence/migration.rs b/frontend/rust-lib/flowy-grid/src/services/persistence/migration.rs index bf3aa7adfb..cb99d8eb6d 100644 --- a/frontend/rust-lib/flowy-grid/src/services/persistence/migration.rs +++ b/frontend/rust-lib/flowy-grid/src/services/persistence/migration.rs @@ -1,19 +1,17 @@ use crate::manager::GridUser; - use crate::services::persistence::GridDatabase; use flowy_database::kv::KV; use flowy_error::FlowyResult; use flowy_grid_data_model::revision::GridRevision; -use flowy_revision::disk::{RevisionRecord, SQLiteGridRevisionPersistence}; -use flowy_revision::{mk_grid_block_revision_disk_cache, RevisionLoader, RevisionPersistence}; +use flowy_revision::disk::SQLiteGridRevisionPersistence; +use flowy_revision::reset::{RevisionResettable, RevisionStructReset}; use flowy_sync::client_grid::{make_grid_rev_json_str, GridRevisionPad}; use flowy_sync::entities::revision::Revision; - -use lib_ot::core::TextDeltaBuilder; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; +use flowy_sync::util::md5; use std::sync::Arc; +const V1_MIGRATION: &str = "GRID_V1_MIGRATION"; + pub(crate) struct GridMigration { user: Arc, database: Arc, @@ -24,90 +22,52 @@ impl GridMigration { Self { user, database } } - pub async fn migration_grid_if_need(&self, grid_id: &str) -> FlowyResult<()> { - match KV::get_str(grid_id) { - None => { - let _ = self.reset_grid_rev(grid_id).await?; - let _ = self.save_migrate_record(grid_id)?; - } - Some(s) => { - let mut record = MigrationGridRecord::from_str(&s)?; - let empty_json = self.empty_grid_rev_json()?; - if record.len < empty_json.len() { - let _ = self.reset_grid_rev(grid_id).await?; - record.len = empty_json.len(); - KV::set_str(grid_id, record.to_string()); - } - } - } - Ok(()) - } - - async fn reset_grid_rev(&self, grid_id: &str) -> FlowyResult<()> { + pub async fn run_v1_migration(&self, grid_id: &str) -> FlowyResult<()> { let user_id = self.user.user_id()?; - let pool = self.database.db_pool()?; - let grid_rev_pad = self.get_grid_revision_pad(grid_id).await?; - let json = grid_rev_pad.json_str()?; - let delta_data = TextDeltaBuilder::new().insert(&json).build().json_bytes(); - let revision = Revision::initial_revision(&user_id, grid_id, delta_data); - let record = RevisionRecord::new(revision); - // - let disk_cache = mk_grid_block_revision_disk_cache(&user_id, pool); - let _ = disk_cache.delete_and_insert_records(grid_id, None, vec![record]); + let key = migration_flag_key(&user_id, V1_MIGRATION, grid_id); + if KV::get_bool(&key) { + return Ok(()); + } + let _ = self.migration_grid_rev_struct(grid_id).await?; + tracing::trace!("Run grid:{} v1 migration", grid_id); + KV::set_bool(&key, true); Ok(()) } - fn save_migrate_record(&self, grid_id: &str) -> FlowyResult<()> { - let empty_json_str = self.empty_grid_rev_json()?; - let record = MigrationGridRecord { + pub async fn migration_grid_rev_struct(&self, grid_id: &str) -> FlowyResult<()> { + let object = GridRevisionResettable { grid_id: grid_id.to_owned(), - len: empty_json_str.len(), }; - KV::set_str(grid_id, record.to_string()); - Ok(()) - } - - fn empty_grid_rev_json(&self) -> FlowyResult { - let empty_grid_rev = GridRevision::default(); - let empty_json = make_grid_rev_json_str(&empty_grid_rev)?; - Ok(empty_json) - } - - async fn get_grid_revision_pad(&self, grid_id: &str) -> FlowyResult { - let pool = self.database.db_pool()?; let user_id = self.user.user_id()?; + let pool = self.database.db_pool()?; let disk_cache = SQLiteGridRevisionPersistence::new(&user_id, pool); - let rev_persistence = Arc::new(RevisionPersistence::new(&user_id, grid_id, disk_cache)); - let (revisions, _) = RevisionLoader { - object_id: grid_id.to_owned(), - user_id, - cloud: None, - rev_persistence, - } - .load() - .await?; - - let pad = GridRevisionPad::from_revisions(revisions)?; - Ok(pad) + let reset = RevisionStructReset::new(&user_id, object, Arc::new(disk_cache)); + reset.run().await } } -#[derive(Serialize, Deserialize)] -struct MigrationGridRecord { +fn migration_flag_key(user_id: &str, version: &str, grid_id: &str) -> String { + md5(format!("{}{}{}", user_id, version, grid_id,)) +} + +pub struct GridRevisionResettable { grid_id: String, - len: usize, } -impl FromStr for MigrationGridRecord { - type Err = serde_json::Error; +impl RevisionResettable for GridRevisionResettable { + fn target_id(&self) -> &str { + &self.grid_id + } - fn from_str(s: &str) -> Result { - serde_json::from_str::(s) - } -} - -impl ToString for MigrationGridRecord { - fn to_string(&self) -> String { - serde_json::to_string(self).unwrap_or_else(|_| "".to_string()) + fn target_reset_rev_str(&self, revisions: Vec) -> FlowyResult { + let pad = GridRevisionPad::from_revisions(revisions)?; + let json = pad.json_str()?; + Ok(json) + } + + fn default_target_rev_str(&self) -> FlowyResult { + let grid_rev = GridRevision::default(); + let json = make_grid_rev_json_str(&grid_rev)?; + Ok(json) } } diff --git a/frontend/rust-lib/flowy-grid/src/services/row/row_builder.rs b/frontend/rust-lib/flowy-grid/src/services/row/row_builder.rs index 43153f1311..ee586b3851 100644 --- a/frontend/rust-lib/flowy-grid/src/services/row/row_builder.rs +++ b/frontend/rust-lib/flowy-grid/src/services/row/row_builder.rs @@ -1,18 +1,21 @@ -use crate::services::cell::apply_cell_data_changeset; -use crate::services::field::SelectOptionCellChangeset; -use flowy_error::{FlowyError, FlowyResult}; +use crate::services::cell::{ + insert_checkbox_cell, insert_date_cell, insert_number_cell, insert_select_option_cell, insert_text_cell, + insert_url_cell, +}; + use flowy_grid_data_model::revision::{gen_row_id, CellRevision, FieldRevision, RowRevision, DEFAULT_ROW_HEIGHT}; use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; pub struct RowRevisionBuilder<'a> { + block_id: String, field_rev_map: HashMap<&'a String, Arc>, payload: CreateRowRevisionPayload, } impl<'a> RowRevisionBuilder<'a> { - pub fn new(fields: &'a [Arc]) -> Self { + pub fn new(block_id: &str, fields: &'a [Arc]) -> Self { let field_rev_map = fields .iter() .map(|field| (&field.id, field.clone())) @@ -25,36 +28,77 @@ impl<'a> RowRevisionBuilder<'a> { visibility: true, }; - Self { field_rev_map, payload } + let block_id = block_id.to_string(); + + Self { + block_id, + field_rev_map, + payload, + } } - pub fn insert_cell(&mut self, field_id: &str, data: String) -> FlowyResult<()> { + pub fn insert_text_cell(&mut self, field_id: &str, data: String) { match self.field_rev_map.get(&field_id.to_owned()) { - None => { - let msg = format!("Can't find the field with id: {}", field_id); - Err(FlowyError::internal().context(msg)) - } + None => tracing::warn!("Can't find the text field with id: {}", field_id), Some(field_rev) => { - let data = apply_cell_data_changeset(data, None, field_rev)?; - let cell = CellRevision::new(data); - self.payload.cell_by_field_id.insert(field_id.to_owned(), cell); - Ok(()) + self.payload + .cell_by_field_id + .insert(field_id.to_owned(), insert_text_cell(data, field_rev)); } } } - pub fn insert_select_option_cell(&mut self, field_id: &str, data: String) -> FlowyResult<()> { + pub fn insert_url_cell(&mut self, field_id: &str, data: String) { match self.field_rev_map.get(&field_id.to_owned()) { - None => { - let msg = format!("Invalid field_id: {}", field_id); - Err(FlowyError::internal().context(msg)) - } + None => tracing::warn!("Can't find the url field with id: {}", field_id), Some(field_rev) => { - let cell_data = SelectOptionCellChangeset::from_insert(&data).to_str(); - let data = apply_cell_data_changeset(cell_data, None, field_rev)?; - let cell = CellRevision::new(data); - self.payload.cell_by_field_id.insert(field_id.to_owned(), cell); - Ok(()) + self.payload + .cell_by_field_id + .insert(field_id.to_owned(), insert_url_cell(data, field_rev)); + } + } + } + + pub fn insert_number_cell(&mut self, field_id: &str, num: i64) { + match self.field_rev_map.get(&field_id.to_owned()) { + None => tracing::warn!("Can't find the number field with id: {}", field_id), + Some(field_rev) => { + self.payload + .cell_by_field_id + .insert(field_id.to_owned(), insert_number_cell(num, field_rev)); + } + } + } + + pub fn insert_checkbox_cell(&mut self, field_id: &str, is_check: bool) { + match self.field_rev_map.get(&field_id.to_owned()) { + None => tracing::warn!("Can't find the checkbox field with id: {}", field_id), + Some(field_rev) => { + self.payload + .cell_by_field_id + .insert(field_id.to_owned(), insert_checkbox_cell(is_check, field_rev)); + } + } + } + + pub fn insert_date_cell(&mut self, field_id: &str, timestamp: i64) { + match self.field_rev_map.get(&field_id.to_owned()) { + None => tracing::warn!("Can't find the date field with id: {}", field_id), + Some(field_rev) => { + self.payload + .cell_by_field_id + .insert(field_id.to_owned(), insert_date_cell(timestamp, field_rev)); + } + } + } + + pub fn insert_select_option_cell(&mut self, field_id: &str, data: String) { + match self.field_rev_map.get(&field_id.to_owned()) { + None => tracing::warn!("Can't find the select option field with id: {}", field_id), + Some(field_rev) => { + self.payload + .cell_by_field_id + .insert(field_id.to_owned(), insert_select_option_cell(data, field_rev)); } } } @@ -71,10 +115,10 @@ impl<'a> RowRevisionBuilder<'a> { self } - pub fn build(self, block_id: &str) -> RowRevision { + pub fn build(self) -> RowRevision { RowRevision { id: self.payload.row_id, - block_id: block_id.to_owned(), + block_id: self.block_id, cells: self.payload.cell_by_field_id, height: self.payload.height, visibility: self.payload.visibility, diff --git a/frontend/rust-lib/flowy-grid/src/services/row/row_loader.rs b/frontend/rust-lib/flowy-grid/src/services/row/row_loader.rs index 3a5aa58c6d..1a0d0eaff6 100644 --- a/frontend/rust-lib/flowy-grid/src/services/row/row_loader.rs +++ b/frontend/rust-lib/flowy-grid/src/services/row/row_loader.rs @@ -1,4 +1,4 @@ -use crate::entities::{GridBlockPB, GridRowPB, RepeatedGridBlockPB}; +use crate::entities::{BlockPB, RepeatedBlockPB, RowPB}; use flowy_error::FlowyResult; use flowy_grid_data_model::revision::RowRevision; use std::collections::HashMap; @@ -9,14 +9,14 @@ pub struct GridBlockSnapshot { pub row_revs: Vec>, } -pub(crate) fn block_from_row_orders(row_orders: Vec) -> Vec { - let mut map: HashMap = HashMap::new(); +pub(crate) fn block_from_row_orders(row_orders: Vec) -> Vec { + let mut map: HashMap = HashMap::new(); row_orders.into_iter().for_each(|row_info| { // Memory Optimization: escape clone block_id let block_id = row_info.block_id().to_owned(); let cloned_block_id = block_id.clone(); map.entry(block_id) - .or_insert_with(|| GridBlockPB::new(&cloned_block_id, vec![])) + .or_insert_with(|| BlockPB::new(&cloned_block_id, vec![])) .rows .push(row_info); }); @@ -35,16 +35,16 @@ pub(crate) fn block_from_row_orders(row_orders: Vec) -> Vec]) -> Vec { - row_revs.iter().map(GridRowPB::from).collect::>() +pub(crate) fn make_row_orders_from_row_revs(row_revs: &[Arc]) -> Vec { + row_revs.iter().map(RowPB::from).collect::>() } -pub(crate) fn make_row_from_row_rev(row_rev: Arc) -> Option { - make_rows_from_row_revs(&[row_rev]).pop() +pub(crate) fn make_row_from_row_rev(row_rev: Arc) -> RowPB { + make_rows_from_row_revs(&[row_rev]).pop().unwrap() } -pub(crate) fn make_rows_from_row_revs(row_revs: &[Arc]) -> Vec { - let make_row = |row_rev: &Arc| GridRowPB { +pub(crate) fn make_rows_from_row_revs(row_revs: &[Arc]) -> Vec { + let make_row = |row_rev: &Arc| RowPB { block_id: row_rev.block_id.clone(), id: row_rev.id.clone(), height: row_rev.height, @@ -56,15 +56,15 @@ pub(crate) fn make_rows_from_row_revs(row_revs: &[Arc]) -> Vec>, block_snapshots: Vec, -) -> FlowyResult { +) -> FlowyResult { match block_ids { None => Ok(block_snapshots .into_iter() .map(|snapshot| { let row_orders = make_row_orders_from_row_revs(&snapshot.row_revs); - GridBlockPB::new(&snapshot.block_id, row_orders) + BlockPB::new(&snapshot.block_id, row_orders) }) - .collect::>() + .collect::>() .into()), Some(block_ids) => { let block_meta_data_map: HashMap<&String, &Vec>> = block_snapshots @@ -78,7 +78,7 @@ pub(crate) fn make_grid_blocks( None => {} Some(row_revs) => { let row_orders = make_row_orders_from_row_revs(row_revs); - grid_blocks.push(GridBlockPB::new(&block_id, row_orders)); + grid_blocks.push(BlockPB::new(&block_id, row_orders)); } } } diff --git a/frontend/rust-lib/flowy-grid/src/services/setting/setting_builder.rs b/frontend/rust-lib/flowy-grid/src/services/setting/setting_builder.rs index be283981cf..7d285fbe2b 100644 --- a/frontend/rust-lib/flowy-grid/src/services/setting/setting_builder.rs +++ b/frontend/rust-lib/flowy-grid/src/services/setting/setting_builder.rs @@ -1,7 +1,8 @@ use crate::entities::{ - GridLayoutPB, GridLayoutType, GridSettingPB, RepeatedGridFilterPB, RepeatedGridGroupPB, RepeatedGridSortPB, + GridLayout, GridLayoutPB, GridSettingPB, RepeatedGridConfigurationFilterPB, RepeatedGridGroupConfigurationPB, + RepeatedGridSortPB, }; -use flowy_grid_data_model::revision::{FieldRevision, GridSettingRevision}; +use flowy_grid_data_model::revision::{FieldRevision, SettingRevision}; use flowy_sync::entities::grid::{CreateGridFilterParams, DeleteFilterParams, GridSettingChangesetParams}; use std::collections::HashMap; use std::sync::Arc; @@ -11,7 +12,7 @@ pub struct GridSettingChangesetBuilder { } impl GridSettingChangesetBuilder { - pub fn new(grid_id: &str, layout_type: &GridLayoutType) -> Self { + pub fn new(grid_id: &str, layout_type: &GridLayout) -> Self { let params = GridSettingChangesetParams { grid_id: grid_id.to_string(), layout_type: layout_type.clone().into(), @@ -40,24 +41,24 @@ impl GridSettingChangesetBuilder { } } -pub fn make_grid_setting(grid_setting_rev: &GridSettingRevision, field_revs: &[Arc]) -> GridSettingPB { - let current_layout_type: GridLayoutType = grid_setting_rev.layout.clone().into(); +pub fn make_grid_setting(grid_setting_rev: &SettingRevision, field_revs: &[Arc]) -> GridSettingPB { + let current_layout_type: GridLayout = grid_setting_rev.layout.clone().into(); let filters_by_field_id = grid_setting_rev - .get_all_filter(field_revs) + .get_all_filters(field_revs) .map(|filters_by_field_id| { filters_by_field_id .into_iter() .map(|(k, v)| (k, v.into())) - .collect::>() + .collect::>() }) .unwrap_or_default(); let groups_by_field_id = grid_setting_rev - .get_all_group() + .get_all_groups(field_revs) .map(|groups_by_field_id| { groups_by_field_id .into_iter() .map(|(k, v)| (k, v.into())) - .collect::>() + .collect::>() }) .unwrap_or_default(); let sorts_by_field_id = grid_setting_rev @@ -73,8 +74,8 @@ pub fn make_grid_setting(grid_setting_rev: &GridSettingRevision, field_revs: &[A GridSettingPB { layouts: GridLayoutPB::all(), current_layout_type, - filters_by_field_id, - groups_by_field_id, + filter_configuration_by_field_id: filters_by_field_id, + group_configuration_by_field_id: groups_by_field_id, sorts_by_field_id, } } diff --git a/frontend/rust-lib/flowy-grid/src/services/tasks/queue.rs b/frontend/rust-lib/flowy-grid/src/services/tasks/queue.rs index 1ba97afd9a..a20fdab7c6 100644 --- a/frontend/rust-lib/flowy-grid/src/services/tasks/queue.rs +++ b/frontend/rust-lib/flowy-grid/src/services/tasks/queue.rs @@ -27,6 +27,7 @@ impl GridTaskQueue { let task_type = match task.content.as_ref().unwrap() { TaskContent::Snapshot => TaskType::Snapshot, + TaskContent::Group => TaskType::Group, TaskContent::Filter { .. } => TaskType::Filter, }; let pending_task = PendingTask { diff --git a/frontend/rust-lib/flowy-grid/src/services/tasks/task.rs b/frontend/rust-lib/flowy-grid/src/services/tasks/task.rs index 92950b02aa..6b88e4598f 100644 --- a/frontend/rust-lib/flowy-grid/src/services/tasks/task.rs +++ b/frontend/rust-lib/flowy-grid/src/services/tasks/task.rs @@ -10,6 +10,8 @@ pub enum TaskType { Filter, /// Generate snapshot for grid, unused by now. Snapshot, + + Group, } impl PartialEq for TaskType { @@ -44,9 +46,15 @@ impl PartialOrd for PendingTask { impl Ord for PendingTask { fn cmp(&self, other: &Self) -> Ordering { match (self.ty, other.ty) { + // Snapshot (TaskType::Snapshot, TaskType::Snapshot) => Ordering::Equal, (TaskType::Snapshot, _) => Ordering::Greater, (_, TaskType::Snapshot) => Ordering::Less, + // Group + (TaskType::Group, TaskType::Group) => self.id.cmp(&other.id).reverse(), + (TaskType::Group, _) => Ordering::Greater, + (_, TaskType::Group) => Ordering::Greater, + // Filter (TaskType::Filter, TaskType::Filter) => self.id.cmp(&other.id).reverse(), } } @@ -59,6 +67,7 @@ pub(crate) struct FilterTaskContext { pub(crate) enum TaskContent { #[allow(dead_code)] Snapshot, + Group, Filter(FilterTaskContext), } diff --git a/frontend/rust-lib/flowy-grid/src/util.rs b/frontend/rust-lib/flowy-grid/src/util.rs index 3b48e313a9..90bf2f2a26 100644 --- a/frontend/rust-lib/flowy-grid/src/util.rs +++ b/frontend/rust-lib/flowy-grid/src/util.rs @@ -1,7 +1,9 @@ use crate::entities::FieldType; use crate::services::field::*; +use crate::services::row::RowRevisionBuilder; use flowy_grid_data_model::revision::BuildGridContext; use flowy_sync::client_grid::GridBuilder; +use lib_infra::util::timestamp; pub fn make_default_grid() -> BuildGridContext { let mut grid_builder = GridBuilder::new(); @@ -30,3 +32,101 @@ pub fn make_default_grid() -> BuildGridContext { grid_builder.add_empty_row(); grid_builder.build() } + +pub fn make_default_board() -> BuildGridContext { + let mut grid_builder = GridBuilder::new(); + // text + let text_field = FieldBuilder::new(RichTextTypeOptionBuilder::default()) + .name("Name") + .visibility(true) + .primary(true) + .build(); + let text_field_id = text_field.id.clone(); + grid_builder.add_field(text_field); + + // date + let date_type_option = DateTypeOptionBuilder::default(); + let date_field = FieldBuilder::new(date_type_option) + .name("Date") + .visibility(true) + .build(); + let date_field_id = date_field.id.clone(); + let timestamp = timestamp(); + grid_builder.add_field(date_field); + + // single select + let in_progress_option = SelectOptionPB::new("In progress"); + let not_started_option = SelectOptionPB::new("Not started"); + let done_option = SelectOptionPB::new("Done"); + let single_select_type_option = SingleSelectTypeOptionBuilder::default() + .add_option(not_started_option.clone()) + .add_option(in_progress_option) + .add_option(done_option); + let single_select_field = FieldBuilder::new(single_select_type_option) + .name("Status") + .visibility(true) + .build(); + let single_select_field_id = single_select_field.id.clone(); + grid_builder.add_field(single_select_field); + + // MultiSelect + let apple_option = SelectOptionPB::new("Apple"); + let banana_option = SelectOptionPB::new("Banana"); + let pear_option = SelectOptionPB::new("Pear"); + let multi_select_type_option = MultiSelectTypeOptionBuilder::default() + .add_option(banana_option.clone()) + .add_option(apple_option.clone()) + .add_option(pear_option); + let multi_select_field = FieldBuilder::new(multi_select_type_option) + .name("Fruit") + .visibility(true) + .build(); + let multi_select_field_id = multi_select_field.id.clone(); + grid_builder.add_field(multi_select_field); + + // Number + let number_type_option = NumberTypeOptionBuilder::default().set_format(NumberFormat::USD); + let number_field = FieldBuilder::new(number_type_option) + .name("Price") + .visibility(true) + .build(); + let number_field_id = number_field.id.clone(); + grid_builder.add_field(number_field); + + // Checkbox + let checkbox_type_option = CheckboxTypeOptionBuilder::default(); + let checkbox_field = FieldBuilder::new(checkbox_type_option).name("Reimbursement").build(); + let checkbox_field_id = checkbox_field.id.clone(); + grid_builder.add_field(checkbox_field); + + // Url + let url_type_option = URLTypeOptionBuilder::default(); + let url_field = FieldBuilder::new(url_type_option).name("Shop Link").build(); + let url_field_id = url_field.id.clone(); + grid_builder.add_field(url_field); + + // Insert rows + for i in 0..10 { + // insert single select + let mut row_builder = RowRevisionBuilder::new(grid_builder.block_id(), grid_builder.field_revs()); + row_builder.insert_select_option_cell(&single_select_field_id, not_started_option.id.clone()); + // insert multi select + row_builder.insert_select_option_cell(&multi_select_field_id, apple_option.id.clone()); + row_builder.insert_select_option_cell(&multi_select_field_id, banana_option.id.clone()); + // insert text + row_builder.insert_text_cell(&text_field_id, format!("Card {}", i)); + // insert date + row_builder.insert_date_cell(&date_field_id, timestamp); + // number + row_builder.insert_number_cell(&number_field_id, i); + // checkbox + row_builder.insert_checkbox_cell(&checkbox_field_id, i % 2 == 0); + // url + row_builder.insert_url_cell(&url_field_id, "https://appflowy.io".to_string()); + + let row = row_builder.build(); + grid_builder.add_row(row); + } + + grid_builder.build() +} diff --git a/frontend/rust-lib/flowy-grid/tests/grid/block_test/row_test.rs b/frontend/rust-lib/flowy-grid/tests/grid/block_test/row_test.rs index 85bfed576d..72b3a288a1 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/block_test/row_test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/block_test/row_test.rs @@ -3,7 +3,7 @@ use crate::grid::block_test::script::{CreateRowScriptBuilder, GridRowTest}; use crate::grid::grid_editor::{COMPLETED, FACEBOOK, GOOGLE, PAUSED, TWITTER}; use flowy_grid::entities::FieldType; use flowy_grid::services::field::{SELECTION_IDS_SEPARATOR, UNCHECK}; -use flowy_grid_data_model::revision::RowMetaChangeset; +use flowy_grid_data_model::revision::RowChangeset; #[tokio::test] async fn grid_create_row_count_test() { @@ -24,7 +24,7 @@ async fn grid_create_row_count_test() { async fn grid_update_row() { let mut test = GridRowTest::new().await; let row_rev = test.row_builder().build(); - let changeset = RowMetaChangeset { + let changeset = RowChangeset { row_id: row_rev.id.clone(), height: None, visibility: None, diff --git a/frontend/rust-lib/flowy-grid/tests/grid/block_test/script.rs b/frontend/rust-lib/flowy-grid/tests/grid/block_test/script.rs index 14671c2eb2..3aa22cc488 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/block_test/script.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/block_test/script.rs @@ -2,10 +2,10 @@ use crate::grid::block_test::script::RowScript::{AssertCell, CreateRow}; use crate::grid::block_test::util::GridRowTestBuilder; use crate::grid::grid_editor::GridEditorTest; -use flowy_grid::entities::{FieldType, GridCellIdParams, GridRowPB}; +use flowy_grid::entities::{CreateRowParams, FieldType, GridCellIdParams, GridLayout, RowPB}; use flowy_grid::services::field::*; use flowy_grid_data_model::revision::{ - GridBlockMetaRevision, GridBlockMetaRevisionChangeset, RowMetaChangeset, RowRevision, + GridBlockMetaRevision, GridBlockMetaRevisionChangeset, RowChangeset, RowRevision, }; use std::collections::HashMap; use std::sync::Arc; @@ -17,7 +17,7 @@ pub enum RowScript { row_rev: RowRevision, }, UpdateRow { - changeset: RowMetaChangeset, + changeset: RowChangeset, }, AssertRow { expected_row: RowRevision, @@ -56,7 +56,7 @@ pub struct GridRowTest { impl GridRowTest { pub async fn new() -> Self { - let editor_test = GridEditorTest::new().await; + let editor_test = GridEditorTest::new_table().await; Self { inner: editor_test } } @@ -77,7 +77,13 @@ impl GridRowTest { pub async fn run_script(&mut self, script: RowScript) { match script { RowScript::CreateEmptyRow => { - let row_order = self.editor.create_row(None).await.unwrap(); + let params = CreateRowParams { + grid_id: self.editor.grid_id.clone(), + start_row_id: None, + group_id: None, + layout: GridLayout::Table, + }; + let row_order = self.editor.create_row(params).await.unwrap(); self.row_order_by_row_id .insert(row_order.row_id().to_owned(), row_order); self.row_revs = self.get_row_revs().await; @@ -97,7 +103,7 @@ impl GridRowTest { let row_orders = row_ids .into_iter() .map(|row_id| self.row_order_by_row_id.get(&row_id).unwrap().clone()) - .collect::>(); + .collect::>(); self.editor.delete_rows(row_orders).await.unwrap(); self.row_revs = self.get_row_revs().await; @@ -162,7 +168,7 @@ impl GridRowTest { .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(TextCellDataParser()) + .parser::() .unwrap(); assert_eq!(cell_data.as_ref(), &expected); @@ -170,14 +176,14 @@ impl GridRowTest { FieldType::Number => { let field_rev = self.editor.get_field_rev(&cell_id.field_id).await.unwrap(); let number_type_option = field_rev - .get_type_option_entry::(FieldType::Number.into()) + .get_type_option_entry::(FieldType::Number.into()) .unwrap(); let cell_data = self .editor .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(NumberCellDataParser(number_type_option.format)) + .custom_parser(NumberCellCustomDataParser(number_type_option.format)) .unwrap(); assert_eq!(cell_data.to_string(), expected); } @@ -187,7 +193,7 @@ impl GridRowTest { .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(DateCellDataParser()) + .parser::() .unwrap(); assert_eq!(cell_data.date, expected); @@ -198,7 +204,7 @@ impl GridRowTest { .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(SelectOptionCellDataParser()) + .parser::() .unwrap(); let select_option = cell_data.select_options.first().unwrap(); assert_eq!(select_option.name, expected); @@ -209,7 +215,7 @@ impl GridRowTest { .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(SelectOptionCellDataParser()) + .parser::() .unwrap(); let s = cell_data @@ -228,7 +234,7 @@ impl GridRowTest { .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(CheckboxCellDataParser()) + .parser::() .unwrap(); assert_eq!(cell_data.to_string(), expected); } @@ -238,7 +244,7 @@ impl GridRowTest { .get_cell_bytes(&cell_id) .await .unwrap() - .with_parser(URLCellDataParser()) + .parser::() .unwrap(); assert_eq!(cell_data.content, expected); diff --git a/frontend/rust-lib/flowy-grid/tests/grid/block_test/util.rs b/frontend/rust-lib/flowy-grid/tests/grid/block_test/util.rs index 216a4d4acd..06eafe584f 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/block_test/util.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/block_test/util.rs @@ -2,7 +2,7 @@ use flowy_grid::entities::FieldType; use std::sync::Arc; use flowy_grid::services::field::{ - DateCellChangesetPB, MultiSelectTypeOption, SelectOptionPB, SingleSelectTypeOptionPB, SELECTION_IDS_SEPARATOR, + DateCellChangesetPB, MultiSelectTypeOptionPB, SelectOptionPB, SingleSelectTypeOptionPB, SELECTION_IDS_SEPARATOR, }; use flowy_grid::services::row::RowRevisionBuilder; use flowy_grid_data_model::revision::{FieldRevision, RowRevision}; @@ -10,7 +10,6 @@ use flowy_grid_data_model::revision::{FieldRevision, RowRevision}; use strum::EnumCount; pub struct GridRowTestBuilder<'a> { - block_id: String, field_revs: &'a [Arc], inner_builder: RowRevisionBuilder<'a>, } @@ -18,9 +17,8 @@ pub struct GridRowTestBuilder<'a> { impl<'a> GridRowTestBuilder<'a> { pub fn new(block_id: &str, field_revs: &'a [Arc]) -> Self { assert_eq!(field_revs.len(), FieldType::COUNT); - let inner_builder = RowRevisionBuilder::new(field_revs); + let inner_builder = RowRevisionBuilder::new(block_id, field_revs); Self { - block_id: block_id.to_owned(), field_revs, inner_builder, } @@ -28,18 +26,14 @@ impl<'a> GridRowTestBuilder<'a> { pub fn insert_text_cell(&mut self, data: &str) -> String { let text_field = self.field_rev_with_type(&FieldType::RichText); - self.inner_builder - .insert_cell(&text_field.id, data.to_string()) - .unwrap(); + self.inner_builder.insert_text_cell(&text_field.id, data.to_string()); text_field.id.clone() } pub fn insert_number_cell(&mut self, data: &str) -> String { let number_field = self.field_rev_with_type(&FieldType::Number); - self.inner_builder - .insert_cell(&number_field.id, data.to_string()) - .unwrap(); + self.inner_builder.insert_text_cell(&number_field.id, data.to_string()); number_field.id.clone() } @@ -50,22 +44,21 @@ impl<'a> GridRowTestBuilder<'a> { }) .unwrap(); let date_field = self.field_rev_with_type(&FieldType::DateTime); - self.inner_builder.insert_cell(&date_field.id, value).unwrap(); + self.inner_builder.insert_text_cell(&date_field.id, value); date_field.id.clone() } pub fn insert_checkbox_cell(&mut self, data: &str) -> String { let checkbox_field = self.field_rev_with_type(&FieldType::Checkbox); self.inner_builder - .insert_cell(&checkbox_field.id, data.to_string()) - .unwrap(); + .insert_text_cell(&checkbox_field.id, data.to_string()); checkbox_field.id.clone() } pub fn insert_url_cell(&mut self, data: &str) -> String { let url_field = self.field_rev_with_type(&FieldType::URL); - self.inner_builder.insert_cell(&url_field.id, data.to_string()).unwrap(); + self.inner_builder.insert_text_cell(&url_field.id, data.to_string()); url_field.id.clone() } @@ -77,8 +70,7 @@ impl<'a> GridRowTestBuilder<'a> { let type_option = SingleSelectTypeOptionPB::from(&single_select_field); let option = f(type_option.options); self.inner_builder - .insert_select_option_cell(&single_select_field.id, option.id) - .unwrap(); + .insert_select_option_cell(&single_select_field.id, option.id); single_select_field.id.clone() } @@ -88,7 +80,7 @@ impl<'a> GridRowTestBuilder<'a> { F: Fn(Vec) -> Vec, { let multi_select_field = self.field_rev_with_type(&FieldType::MultiSelect); - let type_option = MultiSelectTypeOption::from(&multi_select_field); + let type_option = MultiSelectTypeOptionPB::from(&multi_select_field); let options = f(type_option.options); let ops_ids = options .iter() @@ -96,8 +88,7 @@ impl<'a> GridRowTestBuilder<'a> { .collect::>() .join(SELECTION_IDS_SEPARATOR); self.inner_builder - .insert_select_option_cell(&multi_select_field.id, ops_ids) - .unwrap(); + .insert_select_option_cell(&multi_select_field.id, ops_ids); multi_select_field.id.clone() } @@ -106,7 +97,7 @@ impl<'a> GridRowTestBuilder<'a> { self.field_revs .iter() .find(|field_rev| { - let t_field_type: FieldType = field_rev.field_type_rev.into(); + let t_field_type: FieldType = field_rev.ty.into(); &t_field_type == field_type }) .unwrap() @@ -115,7 +106,7 @@ impl<'a> GridRowTestBuilder<'a> { } pub fn build(self) -> RowRevision { - self.inner_builder.build(&self.block_id) + self.inner_builder.build() } } diff --git a/frontend/rust-lib/flowy-grid/tests/grid/cell_test/script.rs b/frontend/rust-lib/flowy-grid/tests/grid/cell_test/script.rs index 4def72696d..670ba6327c 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/cell_test/script.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/cell_test/script.rs @@ -11,7 +11,7 @@ pub struct GridCellTest { impl GridCellTest { pub async fn new() -> Self { - let inner = GridEditorTest::new().await; + let inner = GridEditorTest::new_table().await; Self { inner } } diff --git a/frontend/rust-lib/flowy-grid/tests/grid/cell_test/test.rs b/frontend/rust-lib/flowy-grid/tests/grid/cell_test/test.rs index e8435c2d00..2d500d2712 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/cell_test/test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/cell_test/test.rs @@ -3,7 +3,7 @@ use crate::grid::cell_test::script::GridCellTest; use crate::grid::field_test::util::make_date_cell_string; use flowy_grid::entities::{CellChangesetPB, FieldType}; use flowy_grid::services::field::selection_type_option::SelectOptionCellChangeset; -use flowy_grid::services::field::{MultiSelectTypeOption, SingleSelectTypeOptionPB}; +use flowy_grid::services::field::{MultiSelectTypeOptionPB, SingleSelectTypeOptionPB}; #[tokio::test] async fn grid_cell_update() { @@ -18,7 +18,7 @@ async fn grid_cell_update() { let mut scripts = vec![]; for (_, row_rev) in row_revs.iter().enumerate() { for field_rev in field_revs { - let field_type: FieldType = field_rev.field_type_rev.into(); + let field_type: FieldType = field_rev.ty.into(); let data = match field_type { FieldType::RichText => "".to_string(), FieldType::Number => "123".to_string(), @@ -28,7 +28,7 @@ async fn grid_cell_update() { SelectOptionCellChangeset::from_insert(&type_option.options.first().unwrap().id).to_str() } FieldType::MultiSelect => { - let type_option = MultiSelectTypeOption::from(field_rev); + let type_option = MultiSelectTypeOptionPB::from(field_rev); SelectOptionCellChangeset::from_insert(&type_option.options.first().unwrap().id).to_str() } FieldType::Checkbox => "1".to_string(), @@ -40,7 +40,7 @@ async fn grid_cell_update() { grid_id: block_id.to_string(), row_id: row_rev.id.clone(), field_id: field_rev.id.clone(), - content: Some(data), + content: data, }, is_err: false, }); diff --git a/frontend/rust-lib/flowy-grid/tests/grid/field_test/script.rs b/frontend/rust-lib/flowy-grid/tests/grid/field_test/script.rs index 6d910f3516..ebe3d3adf2 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/field_test/script.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/field_test/script.rs @@ -26,7 +26,7 @@ pub struct GridFieldTest { impl GridFieldTest { pub async fn new() -> Self { - let editor_test = GridEditorTest::new().await; + let editor_test = GridEditorTest::new_table().await; Self { inner: editor_test } } diff --git a/frontend/rust-lib/flowy-grid/tests/grid/field_test/util.rs b/frontend/rust-lib/flowy-grid/tests/grid/field_test/util.rs index 01424cef74..fa2f003baa 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/field_test/util.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/field_test/util.rs @@ -12,16 +12,16 @@ pub fn create_text_field(grid_id: &str) -> (InsertFieldParams, FieldRevision) { let cloned_field_rev = field_rev.clone(); let type_option_data = field_rev - .get_type_option_entry::(field_rev.field_type_rev) + .get_type_option_entry::(field_rev.ty) .unwrap() .protobuf_bytes() .to_vec(); - let field = GridFieldPB { + let field = FieldPB { id: field_rev.id, name: field_rev.name, desc: field_rev.desc, - field_type: field_rev.field_type_rev.into(), + field_type: field_rev.ty.into(), frozen: field_rev.frozen, visibility: field_rev.visibility, width: field_rev.width, @@ -39,22 +39,22 @@ pub fn create_text_field(grid_id: &str) -> (InsertFieldParams, FieldRevision) { pub fn create_single_select_field(grid_id: &str) -> (InsertFieldParams, FieldRevision) { let single_select = SingleSelectTypeOptionBuilder::default() - .option(SelectOptionPB::new("Done")) - .option(SelectOptionPB::new("Progress")); + .add_option(SelectOptionPB::new("Done")) + .add_option(SelectOptionPB::new("Progress")); let field_rev = FieldBuilder::new(single_select).name("Name").visibility(true).build(); let cloned_field_rev = field_rev.clone(); let type_option_data = field_rev - .get_type_option_entry::(field_rev.field_type_rev) + .get_type_option_entry::(field_rev.ty) .unwrap() .protobuf_bytes() .to_vec(); - let field = GridFieldPB { + let field = FieldPB { id: field_rev.id, name: field_rev.name, desc: field_rev.desc, - field_type: field_rev.field_type_rev.into(), + field_type: field_rev.ty.into(), frozen: field_rev.frozen, visibility: field_rev.visibility, width: field_rev.width, diff --git a/frontend/rust-lib/flowy-grid/tests/grid/filter_test/script.rs b/frontend/rust-lib/flowy-grid/tests/grid/filter_test/script.rs index 267cb570eb..650564d8d5 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/filter_test/script.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/filter_test/script.rs @@ -3,7 +3,7 @@ #![allow(dead_code)] #![allow(unused_imports)] -use flowy_grid::entities::{CreateGridFilterPayloadPB, GridLayoutType, GridSettingPB}; +use flowy_grid::entities::{CreateGridFilterPayloadPB, GridLayout, GridSettingPB}; use flowy_grid::services::setting::GridSettingChangesetBuilder; use flowy_grid_data_model::revision::{FieldRevision, FieldTypeRevision}; use flowy_sync::entities::grid::{CreateGridFilterParams, DeleteFilterParams, GridSettingChangesetParams}; @@ -36,7 +36,7 @@ pub struct GridFilterTest { impl GridFilterTest { pub async fn new() -> Self { - let editor_test = GridEditorTest::new().await; + let editor_test = GridEditorTest::new_table().await; Self { inner: editor_test } @@ -55,21 +55,20 @@ impl GridFilterTest { } FilterScript::InsertGridTableFilter { payload } => { let params: CreateGridFilterParams = payload.try_into().unwrap(); - let layout_type = GridLayoutType::Table; + let layout_type = GridLayout::Table; let params = GridSettingChangesetBuilder::new(&self.grid_id, &layout_type) .insert_filter(params) .build(); let _ = self.editor.update_grid_setting(params).await.unwrap(); } FilterScript::AssertTableFilterCount { count } => { - let layout_type = GridLayoutType::Table; - let filters = self.editor.get_grid_filter(&layout_type).await.unwrap(); + let filters = self.editor.get_grid_filter().await.unwrap(); assert_eq!(count as usize, filters.len()); } FilterScript::DeleteGridTableFilter { filter_id, field_rev} => { - let layout_type = GridLayoutType::Table; + let layout_type = GridLayout::Table; let params = GridSettingChangesetBuilder::new(&self.grid_id, &layout_type) - .delete_filter(DeleteFilterParams { field_id: field_rev.id, filter_id, field_type_rev: field_rev.field_type_rev }) + .delete_filter(DeleteFilterParams { field_id: field_rev.id, filter_id, field_type_rev: field_rev.ty }) .build(); let _ = self.editor.update_grid_setting(params).await.unwrap(); } diff --git a/frontend/rust-lib/flowy-grid/tests/grid/grid_editor.rs b/frontend/rust-lib/flowy-grid/tests/grid/grid_editor.rs index f924b3e803..dbd5df1484 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/grid_editor.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/grid_editor.rs @@ -32,16 +32,29 @@ pub struct GridEditorTest { pub block_meta_revs: Vec>, pub row_revs: Vec>, pub field_count: usize, - pub row_order_by_row_id: HashMap, + pub row_order_by_row_id: HashMap, } impl GridEditorTest { - pub async fn new() -> Self { + pub async fn new_table() -> Self { + Self::new(GridLayout::Table).await + } + + pub async fn new_board() -> Self { + Self::new(GridLayout::Board).await + } + + pub async fn new(layout: GridLayout) -> Self { let sdk = FlowySDKTest::default(); let _ = sdk.init_user().await; let build_context = make_test_grid(); let view_data: Bytes = build_context.into(); - let test = ViewTest::new_grid_view(&sdk, view_data.to_vec()).await; + + let test = match layout { + GridLayout::Table => ViewTest::new_grid_view(&sdk, view_data.to_vec()).await, + GridLayout::Board => ViewTest::new_board_view(&sdk, view_data.to_vec()).await, + }; + let editor = sdk.grid_manager.open_grid(&test.view.id).await.unwrap(); let field_revs = editor.get_field_revs(None).await.unwrap(); let block_meta_revs = editor.get_block_meta_revs().await.unwrap(); @@ -75,16 +88,15 @@ impl GridEditorTest { .row_revs } - pub async fn grid_filters(&self) -> Vec { - let layout_type = GridLayoutType::Table; - self.editor.get_grid_filter(&layout_type).await.unwrap() + pub async fn grid_filters(&self) -> Vec { + self.editor.get_grid_filter().await.unwrap() } pub fn get_field_rev(&self, field_type: FieldType) -> &Arc { self.field_revs .iter() .filter(|field_rev| { - let t_field_type: FieldType = field_rev.field_type_rev.into(); + let t_field_type: FieldType = field_rev.ty.into(); t_field_type == field_type }) .collect::>() @@ -138,18 +150,18 @@ fn make_test_grid() -> BuildGridContext { FieldType::SingleSelect => { // Single Select let single_select = SingleSelectTypeOptionBuilder::default() - .option(SelectOptionPB::new(COMPLETED)) - .option(SelectOptionPB::new(PLANNED)) - .option(SelectOptionPB::new(PAUSED)); + .add_option(SelectOptionPB::new(COMPLETED)) + .add_option(SelectOptionPB::new(PLANNED)) + .add_option(SelectOptionPB::new(PAUSED)); let single_select_field = FieldBuilder::new(single_select).name("Status").visibility(true).build(); grid_builder.add_field(single_select_field); } FieldType::MultiSelect => { // MultiSelect let multi_select = MultiSelectTypeOptionBuilder::default() - .option(SelectOptionPB::new(GOOGLE)) - .option(SelectOptionPB::new(FACEBOOK)) - .option(SelectOptionPB::new(TWITTER)); + .add_option(SelectOptionPB::new(GOOGLE)) + .add_option(SelectOptionPB::new(FACEBOOK)) + .add_option(SelectOptionPB::new(TWITTER)); let multi_select_field = FieldBuilder::new(multi_select) .name("Platform") .visibility(true) diff --git a/frontend/rust-lib/flowy-grid/tests/grid/grid_test.rs b/frontend/rust-lib/flowy-grid/tests/grid/grid_test.rs index 63bea4cc3d..11866b7d1c 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/grid_test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/grid_test.rs @@ -2,7 +2,7 @@ use crate::grid::script::EditorScript::*; use crate::grid::script::*; use chrono::NaiveDateTime; use flowy_grid::services::field::{ - DateCellContentChangeset, DateCellData, MultiSelectTypeOption, SelectOption, SelectOptionCellContentChangeset, + DateCellContentChangeset, DateCellData, MultiSelectTypeOptionPB, SelectOption, SelectOptionCellContentChangeset, SingleSelectTypeOption, SELECTION_IDS_SEPARATOR, }; use flowy_grid::services::row::{decode_cell_data_from_type_option_cell_data, CreateRowMetaBuilder}; @@ -250,7 +250,7 @@ async fn grid_row_add_cells_test() { builder.add_select_option_cell(&field.id, option.id.clone()).unwrap(); } FieldType::MultiSelect => { - let type_option = MultiSelectTypeOption::from(field); + let type_option = MultiSelectTypeOptionPB::from(field); let ops_ids = type_option .options .iter() @@ -327,7 +327,7 @@ async fn grid_cell_update() { SelectOptionCellContentChangeset::from_insert(&type_option.options.first().unwrap().id).to_str() } FieldType::MultiSelect => { - let type_option = MultiSelectTypeOption::from(field_meta); + let type_option = MultiSelectTypeOptionPB::from(field_meta); SelectOptionCellContentChangeset::from_insert(&type_option.options.first().unwrap().id).to_str() } FieldType::Checkbox => "1".to_string(), diff --git a/frontend/rust-lib/flowy-grid/tests/grid/group_test/mod.rs b/frontend/rust-lib/flowy-grid/tests/grid/group_test/mod.rs new file mode 100644 index 0000000000..63d424afaf --- /dev/null +++ b/frontend/rust-lib/flowy-grid/tests/grid/group_test/mod.rs @@ -0,0 +1,2 @@ +mod script; +mod test; diff --git a/frontend/rust-lib/flowy-grid/tests/grid/group_test/script.rs b/frontend/rust-lib/flowy-grid/tests/grid/group_test/script.rs new file mode 100644 index 0000000000..1900e89bff --- /dev/null +++ b/frontend/rust-lib/flowy-grid/tests/grid/group_test/script.rs @@ -0,0 +1,154 @@ +use crate::grid::grid_editor::GridEditorTest; +use flowy_grid::entities::{CreateRowParams, FieldType, GridLayout, GroupPB, MoveRowParams, RowPB}; +use flowy_grid::services::cell::insert_select_option_cell; +use flowy_grid_data_model::revision::RowChangeset; + +pub enum GroupScript { + AssertGroup { + group_index: usize, + row_count: usize, + }, + AssertGroupCount(usize), + AssertRow { + group_index: usize, + row_index: usize, + row: RowPB, + }, + MoveRow { + from_group_index: usize, + from_row_index: usize, + to_group_index: usize, + to_row_index: usize, + }, + CreateRow { + group_index: usize, + }, + DeleteRow { + group_index: usize, + row_index: usize, + }, + UpdateRow { + from_group_index: usize, + row_index: usize, + to_group_index: usize, + }, +} + +pub struct GridGroupTest { + inner: GridEditorTest, +} + +impl GridGroupTest { + pub async fn new() -> Self { + let editor_test = GridEditorTest::new_board().await; + Self { inner: editor_test } + } + + pub async fn run_scripts(&mut self, scripts: Vec) { + for script in scripts { + self.run_script(script).await; + } + } + + pub async fn run_script(&mut self, script: GroupScript) { + match script { + GroupScript::AssertGroup { group_index, row_count } => { + assert_eq!(row_count, self.group_at_index(group_index).await.rows.len()); + } + GroupScript::AssertGroupCount(count) => { + let groups = self.editor.load_groups().await.unwrap(); + assert_eq!(count, groups.len()); + } + GroupScript::MoveRow { + from_group_index, + from_row_index, + to_group_index, + to_row_index, + } => { + let groups: Vec = self.editor.load_groups().await.unwrap().items; + let from_row = groups.get(from_group_index).unwrap().rows.get(from_row_index).unwrap(); + let to_row = groups.get(to_group_index).unwrap().rows.get(to_row_index).unwrap(); + let params = MoveRowParams { + view_id: self.inner.grid_id.clone(), + from_row_id: from_row.id.clone(), + to_row_id: to_row.id.clone(), + }; + + self.editor.move_row(params).await.unwrap(); + } + GroupScript::AssertRow { + group_index, + row_index, + row, + } => { + // + let group = self.group_at_index(group_index).await; + let compare_row = group.rows.get(row_index).unwrap().clone(); + + assert_eq!(row.id, compare_row.id); + } + GroupScript::CreateRow { group_index } => { + // + let group = self.group_at_index(group_index).await; + let params = CreateRowParams { + grid_id: self.editor.grid_id.clone(), + start_row_id: None, + group_id: Some(group.group_id.clone()), + layout: GridLayout::Board, + }; + let _ = self.editor.create_row(params).await.unwrap(); + } + GroupScript::DeleteRow { group_index, row_index } => { + let row = self.row_at_index(group_index, row_index).await; + self.editor.delete_row(&row.id).await.unwrap(); + } + GroupScript::UpdateRow { + from_group_index, + row_index, + to_group_index, + } => { + let from_group = self.group_at_index(from_group_index).await; + let to_group = self.group_at_index(to_group_index).await; + let field_id = from_group.field_id; + let field_rev = self.editor.get_field_rev(&field_id).await.unwrap(); + let field_type: FieldType = field_rev.ty.into(); + let cell_rev = match field_type { + FieldType::SingleSelect => insert_select_option_cell(to_group.group_id.clone(), &field_rev), + FieldType::MultiSelect => insert_select_option_cell(to_group.group_id.clone(), &field_rev), + _ => { + panic!("Unsupported group field type"); + } + }; + + let row_id = self.row_at_index(from_group_index, row_index).await.id; + let mut row_changeset = RowChangeset::new(row_id); + row_changeset.cell_by_field_id.insert(field_id, cell_rev); + self.editor.update_row(row_changeset).await.unwrap(); + } + } + } + + pub async fn group_at_index(&self, index: usize) -> GroupPB { + let groups = self.editor.load_groups().await.unwrap().items; + groups.get(index).unwrap().clone() + } + + pub async fn row_at_index(&self, group_index: usize, row_index: usize) -> RowPB { + let groups = self.group_at_index(group_index).await; + groups.rows.get(row_index).unwrap().clone() + } +} + +impl std::ops::Deref for GridGroupTest { + type Target = GridEditorTest; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl std::ops::DerefMut for GridGroupTest { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs b/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs new file mode 100644 index 0000000000..798f478439 --- /dev/null +++ b/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs @@ -0,0 +1,201 @@ +use crate::grid::group_test::script::GridGroupTest; +use crate::grid::group_test::script::GroupScript::*; + +#[tokio::test] +async fn board_init_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + AssertGroupCount(3), + AssertGroup { + group_index: 0, + row_count: 2, + }, + AssertGroup { + group_index: 1, + row_count: 2, + }, + AssertGroup { + group_index: 2, + row_count: 1, + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_move_row_test() { + let mut test = GridGroupTest::new().await; + let group = test.group_at_index(0).await; + let scripts = vec![ + // Move the row at 0 in group0 to group1 at 1 + MoveRow { + from_group_index: 0, + from_row_index: 0, + to_group_index: 0, + to_row_index: 1, + }, + AssertGroup { + group_index: 0, + row_count: 2, + }, + AssertRow { + group_index: 0, + row_index: 1, + row: group.rows.get(0).unwrap().clone(), + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_move_row_to_other_group_test() { + let mut test = GridGroupTest::new().await; + let group = test.group_at_index(0).await; + let scripts = vec![ + MoveRow { + from_group_index: 0, + from_row_index: 0, + to_group_index: 1, + to_row_index: 1, + }, + AssertGroup { + group_index: 0, + row_count: 1, + }, + AssertGroup { + group_index: 1, + row_count: 3, + }, + AssertRow { + group_index: 1, + row_index: 1, + row: group.rows.get(0).unwrap().clone(), + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_move_row_to_other_group_and_reorder_test() { + let mut test = GridGroupTest::new().await; + let group = test.group_at_index(0).await; + let scripts = vec![ + MoveRow { + from_group_index: 0, + from_row_index: 0, + to_group_index: 1, + to_row_index: 1, + }, + MoveRow { + from_group_index: 1, + from_row_index: 1, + to_group_index: 1, + to_row_index: 2, + }, + AssertRow { + group_index: 1, + row_index: 2, + row: group.rows.get(0).unwrap().clone(), + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_create_row_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + CreateRow { group_index: 0 }, + AssertGroup { + group_index: 0, + row_count: 3, + }, + CreateRow { group_index: 1 }, + CreateRow { group_index: 1 }, + AssertGroup { + group_index: 1, + row_count: 4, + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_delete_row_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + DeleteRow { + group_index: 0, + row_index: 0, + }, + AssertGroup { + group_index: 0, + row_count: 1, + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_delete_all_row_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + DeleteRow { + group_index: 0, + row_index: 0, + }, + DeleteRow { + group_index: 0, + row_index: 0, + }, + AssertGroup { + group_index: 0, + row_count: 0, + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_update_row_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + // Update the row at 0 in group0 by setting the row's group field data + UpdateRow { + from_group_index: 0, + row_index: 0, + to_group_index: 1, + }, + AssertGroup { + group_index: 0, + row_count: 1, + }, + AssertGroup { + group_index: 1, + row_count: 3, + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn board_reorder_group_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + // Update the row at 0 in group0 by setting the row's group field data + UpdateRow { + from_group_index: 0, + row_index: 0, + to_group_index: 1, + }, + AssertGroup { + group_index: 0, + row_count: 1, + }, + AssertGroup { + group_index: 1, + row_count: 3, + }, + ]; + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/flowy-grid/tests/grid/mod.rs b/frontend/rust-lib/flowy-grid/tests/grid/mod.rs index 8865bf01c2..4b250b9524 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/mod.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/mod.rs @@ -3,3 +3,4 @@ mod cell_test; mod field_test; mod filter_test; mod grid_editor; +mod group_test; diff --git a/frontend/rust-lib/flowy-grid/tests/grid/script.rs b/frontend/rust-lib/flowy-grid/tests/grid/script.rs index c1fa47c7a9..c9ea583a14 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/script.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/script.rs @@ -259,7 +259,7 @@ pub fn create_text_field(grid_id: &str) -> (InsertFieldParams, FieldMeta) { let cloned_field_meta = field_meta.clone(); let type_option_data = field_meta - .get_type_option_entry::(&field_meta.field_type) + .get_type_option_entry::(&field_meta.field_type) .unwrap() .protobuf_bytes() .to_vec(); 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 338697f432..387ff4e885 100644 --- a/frontend/rust-lib/flowy-net/src/local_server/server.rs +++ b/frontend/rust-lib/flowy-net/src/local_server/server.rs @@ -305,7 +305,7 @@ impl FolderCouldServiceV1 for LocalServer { let time = timestamp(); let view = ViewRevision { id: params.view_id, - belong_to_id: params.belong_to_id, + app_id: params.belong_to_id, name: params.name, desc: params.desc, data_type: params.data_type.into(), @@ -315,7 +315,7 @@ impl FolderCouldServiceV1 for LocalServer { create_time: time, ext_data: "".to_string(), thumbnail: params.thumbnail, - plugin_type: params.plugin_type, + layout: params.layout.into(), }; FutureResult::new(async { Ok(view) }) } diff --git a/frontend/rust-lib/flowy-revision/Cargo.toml b/frontend/rust-lib/flowy-revision/Cargo.toml index 90257699db..e0fdde3271 100644 --- a/frontend/rust-lib/flowy-revision/Cargo.toml +++ b/frontend/rust-lib/flowy-revision/Cargo.toml @@ -23,6 +23,7 @@ dashmap = "5" serde = { version = "1.0", features = ["derive"] } futures-util = "0.3.15" async-stream = "0.3.2" +serde_json = {version = "1.0"} [features] flowy_unit_test = ["lib-ot/flowy_unit_test"] \ No newline at end of file diff --git a/frontend/rust-lib/flowy-revision/src/cache/disk/text_rev_impl.rs b/frontend/rust-lib/flowy-revision/src/cache/disk/document_impl.rs similarity index 99% rename from frontend/rust-lib/flowy-revision/src/cache/disk/text_rev_impl.rs rename to frontend/rust-lib/flowy-revision/src/cache/disk/document_impl.rs index 165864d5db..31b69e0291 100644 --- a/frontend/rust-lib/flowy-revision/src/cache/disk/text_rev_impl.rs +++ b/frontend/rust-lib/flowy-revision/src/cache/disk/document_impl.rs @@ -1,6 +1,5 @@ use crate::cache::disk::RevisionDiskCache; -use crate::disk::{RevisionChangeset, RevisionRecord, RevisionState}; - +use crate::disk::{RevisionChangeset, RevisionRecord}; use bytes::Bytes; use diesel::{sql_types::Integer, update, SqliteConnection}; use flowy_database::{ diff --git a/frontend/rust-lib/flowy-revision/src/cache/disk/folder_rev_impl.rs b/frontend/rust-lib/flowy-revision/src/cache/disk/folder_rev_impl.rs deleted file mode 100644 index 8b13789179..0000000000 --- a/frontend/rust-lib/flowy-revision/src/cache/disk/folder_rev_impl.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/rust-lib/flowy-revision/src/cache/disk/grid_block_meta_rev_impl.rs b/frontend/rust-lib/flowy-revision/src/cache/disk/grid_block_impl.rs similarity index 90% rename from frontend/rust-lib/flowy-revision/src/cache/disk/grid_block_meta_rev_impl.rs rename to frontend/rust-lib/flowy-revision/src/cache/disk/grid_block_impl.rs index 52b01440e6..2fcdb7e748 100644 --- a/frontend/rust-lib/flowy-revision/src/cache/disk/grid_block_meta_rev_impl.rs +++ b/frontend/rust-lib/flowy-revision/src/cache/disk/grid_block_impl.rs @@ -1,5 +1,5 @@ use crate::cache::disk::RevisionDiskCache; -use crate::disk::{RevisionChangeset, RevisionRecord, RevisionState}; +use crate::disk::{RevisionChangeset, RevisionRecord}; use bytes::Bytes; use diesel::{sql_types::Integer, update, SqliteConnection}; use flowy_database::{ @@ -103,7 +103,7 @@ impl GridMetaRevisionSql { record.revision.object_id, record.revision.rev_id ); - let rev_state: GridMetaRevisionState = record.state.into(); + let rev_state: GridBlockRevisionState = record.state.into(); ( dsl::object_id.eq(record.revision.object_id), dsl::base_rev_id.eq(record.revision.base_rev_id), @@ -121,7 +121,7 @@ impl GridMetaRevisionSql { } fn update(changeset: RevisionChangeset, conn: &SqliteConnection) -> Result<(), FlowyError> { - let state: GridMetaRevisionState = changeset.state.clone().into(); + let state: GridBlockRevisionState = changeset.state.clone().into(); let filter = dsl::grid_meta_rev_table .filter(dsl::rev_id.eq(changeset.rev_id.as_ref())) .filter(dsl::object_id.eq(changeset.object_id)); @@ -146,7 +146,7 @@ impl GridMetaRevisionSql { if let Some(rev_ids) = rev_ids { sql = sql.filter(dsl::rev_id.eq_any(rev_ids)); } - let rows = sql.order(dsl::rev_id.asc()).load::(conn)?; + let rows = sql.order(dsl::rev_id.asc()).load::(conn)?; let records = rows .into_iter() .map(|row| mk_revision_record_from_table(user_id, row)) @@ -166,7 +166,7 @@ impl GridMetaRevisionSql { .filter(dsl::rev_id.le(range.end)) .filter(dsl::object_id.eq(object_id)) .order(dsl::rev_id.asc()) - .load::(conn)?; + .load::(conn)?; let revisions = rev_tables .into_iter() @@ -192,31 +192,31 @@ impl GridMetaRevisionSql { #[derive(PartialEq, Clone, Debug, Queryable, Identifiable, Insertable, Associations)] #[table_name = "grid_meta_rev_table"] -struct GridMetaRevisionTable { +struct GridBlockRevisionTable { id: i32, object_id: String, base_rev_id: i64, rev_id: i64, data: Vec, - state: GridMetaRevisionState, + state: GridBlockRevisionState, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, FromSqlRow, AsExpression)] #[repr(i32)] #[sql_type = "Integer"] -pub enum GridMetaRevisionState { +pub enum GridBlockRevisionState { Sync = 0, Ack = 1, } -impl_sql_integer_expression!(GridMetaRevisionState); -impl_rev_state_map!(GridMetaRevisionState); -impl std::default::Default for GridMetaRevisionState { +impl_sql_integer_expression!(GridBlockRevisionState); +impl_rev_state_map!(GridBlockRevisionState); +impl std::default::Default for GridBlockRevisionState { fn default() -> Self { - GridMetaRevisionState::Sync + GridBlockRevisionState::Sync } } -fn mk_revision_record_from_table(user_id: &str, table: GridMetaRevisionTable) -> RevisionRecord { +fn mk_revision_record_from_table(user_id: &str, table: GridBlockRevisionTable) -> RevisionRecord { let md5 = md5(&table.data); let revision = Revision::new( &table.object_id, diff --git a/frontend/rust-lib/flowy-revision/src/cache/disk/grid_rev_impl.rs b/frontend/rust-lib/flowy-revision/src/cache/disk/grid_impl.rs similarity index 99% rename from frontend/rust-lib/flowy-revision/src/cache/disk/grid_rev_impl.rs rename to frontend/rust-lib/flowy-revision/src/cache/disk/grid_impl.rs index 9ddb21bc8c..bb6e561c91 100644 --- a/frontend/rust-lib/flowy-revision/src/cache/disk/grid_rev_impl.rs +++ b/frontend/rust-lib/flowy-revision/src/cache/disk/grid_impl.rs @@ -1,6 +1,5 @@ use crate::cache::disk::RevisionDiskCache; -use crate::disk::{RevisionChangeset, RevisionRecord, RevisionState}; - +use crate::disk::{RevisionChangeset, RevisionRecord}; use bytes::Bytes; use diesel::{sql_types::Integer, update, SqliteConnection}; use flowy_database::{ diff --git a/frontend/rust-lib/flowy-revision/src/cache/disk/grid_view_impl.rs b/frontend/rust-lib/flowy-revision/src/cache/disk/grid_view_impl.rs new file mode 100644 index 0000000000..2d7cbd59b2 --- /dev/null +++ b/frontend/rust-lib/flowy-revision/src/cache/disk/grid_view_impl.rs @@ -0,0 +1,233 @@ +use crate::disk::{RevisionChangeset, RevisionDiskCache, RevisionRecord}; +use bytes::Bytes; +use diesel::{sql_types::Integer, update, SqliteConnection}; +use flowy_database::{ + impl_sql_integer_expression, insert_or_ignore_into, + prelude::*, + schema::{grid_view_rev_table, grid_view_rev_table::dsl}, + ConnectionPool, +}; +use flowy_error::{internal_error, FlowyError, FlowyResult}; +use flowy_sync::{ + entities::revision::{Revision, RevisionRange}, + util::md5, +}; +use std::sync::Arc; + +pub struct SQLiteGridViewRevisionPersistence { + user_id: String, + pub(crate) pool: Arc, +} + +impl SQLiteGridViewRevisionPersistence { + pub fn new(user_id: &str, pool: Arc) -> Self { + Self { + user_id: user_id.to_owned(), + pool, + } + } +} + +impl RevisionDiskCache for SQLiteGridViewRevisionPersistence { + type Error = FlowyError; + + fn create_revision_records(&self, revision_records: Vec) -> Result<(), Self::Error> { + let conn = self.pool.get().map_err(internal_error)?; + let _ = GridViewRevisionSql::create(revision_records, &*conn)?; + Ok(()) + } + + fn read_revision_records( + &self, + object_id: &str, + rev_ids: Option>, + ) -> Result, Self::Error> { + let conn = self.pool.get().map_err(internal_error)?; + let records = GridViewRevisionSql::read(&self.user_id, object_id, rev_ids, &*conn)?; + Ok(records) + } + + fn read_revision_records_with_range( + &self, + object_id: &str, + range: &RevisionRange, + ) -> Result, Self::Error> { + let conn = &*self.pool.get().map_err(internal_error)?; + let revisions = GridViewRevisionSql::read_with_range(&self.user_id, object_id, range.clone(), conn)?; + Ok(revisions) + } + + fn update_revision_record(&self, changesets: Vec) -> FlowyResult<()> { + let conn = &*self.pool.get().map_err(internal_error)?; + let _ = conn.immediate_transaction::<_, FlowyError, _>(|| { + for changeset in changesets { + let _ = GridViewRevisionSql::update(changeset, conn)?; + } + Ok(()) + })?; + Ok(()) + } + + fn delete_revision_records(&self, object_id: &str, rev_ids: Option>) -> Result<(), Self::Error> { + let conn = &*self.pool.get().map_err(internal_error)?; + let _ = GridViewRevisionSql::delete(object_id, rev_ids, conn)?; + Ok(()) + } + + fn delete_and_insert_records( + &self, + object_id: &str, + deleted_rev_ids: Option>, + inserted_records: Vec, + ) -> Result<(), Self::Error> { + let conn = self.pool.get().map_err(internal_error)?; + conn.immediate_transaction::<_, FlowyError, _>(|| { + let _ = GridViewRevisionSql::delete(object_id, deleted_rev_ids, &*conn)?; + let _ = GridViewRevisionSql::create(inserted_records, &*conn)?; + Ok(()) + }) + } +} + +struct GridViewRevisionSql(); +impl GridViewRevisionSql { + fn create(revision_records: Vec, conn: &SqliteConnection) -> Result<(), FlowyError> { + // Batch insert: https://diesel.rs/guides/all-about-inserts.html + let records = revision_records + .into_iter() + .map(|record| { + tracing::trace!( + "[GridViewRevisionSql] create revision: {}:{:?}", + record.revision.object_id, + record.revision.rev_id + ); + let rev_state: GridViewRevisionState = record.state.into(); + ( + dsl::object_id.eq(record.revision.object_id), + dsl::base_rev_id.eq(record.revision.base_rev_id), + dsl::rev_id.eq(record.revision.rev_id), + dsl::data.eq(record.revision.delta_data), + dsl::state.eq(rev_state), + ) + }) + .collect::>(); + + let _ = insert_or_ignore_into(dsl::grid_view_rev_table) + .values(&records) + .execute(conn)?; + Ok(()) + } + + fn update(changeset: RevisionChangeset, conn: &SqliteConnection) -> Result<(), FlowyError> { + let state: GridViewRevisionState = changeset.state.clone().into(); + let filter = dsl::grid_view_rev_table + .filter(dsl::rev_id.eq(changeset.rev_id.as_ref())) + .filter(dsl::object_id.eq(changeset.object_id)); + let _ = update(filter).set(dsl::state.eq(state)).execute(conn)?; + tracing::debug!( + "[GridViewRevisionSql] update revision:{} state:to {:?}", + changeset.rev_id, + changeset.state + ); + Ok(()) + } + + fn read( + user_id: &str, + object_id: &str, + rev_ids: Option>, + conn: &SqliteConnection, + ) -> Result, FlowyError> { + let mut sql = dsl::grid_view_rev_table + .filter(dsl::object_id.eq(object_id)) + .into_boxed(); + if let Some(rev_ids) = rev_ids { + sql = sql.filter(dsl::rev_id.eq_any(rev_ids)); + } + let rows = sql.order(dsl::rev_id.asc()).load::(conn)?; + let records = rows + .into_iter() + .map(|row| mk_revision_record_from_table(user_id, row)) + .collect::>(); + + Ok(records) + } + + fn read_with_range( + user_id: &str, + object_id: &str, + range: RevisionRange, + conn: &SqliteConnection, + ) -> Result, FlowyError> { + let rev_tables = dsl::grid_view_rev_table + .filter(dsl::rev_id.ge(range.start)) + .filter(dsl::rev_id.le(range.end)) + .filter(dsl::object_id.eq(object_id)) + .order(dsl::rev_id.asc()) + .load::(conn)?; + + let revisions = rev_tables + .into_iter() + .map(|table| mk_revision_record_from_table(user_id, table)) + .collect::>(); + Ok(revisions) + } + + fn delete(object_id: &str, rev_ids: Option>, conn: &SqliteConnection) -> Result<(), FlowyError> { + let mut sql = diesel::delete(dsl::grid_view_rev_table).into_boxed(); + sql = sql.filter(dsl::object_id.eq(object_id)); + + if let Some(rev_ids) = rev_ids { + tracing::trace!("[GridViewRevisionSql] Delete revision: {}:{:?}", object_id, rev_ids); + sql = sql.filter(dsl::rev_id.eq_any(rev_ids)); + } + + let affected_row = sql.execute(conn)?; + tracing::trace!("[GridViewRevisionSql] Delete {} rows", affected_row); + Ok(()) + } +} + +#[derive(PartialEq, Clone, Debug, Queryable, Identifiable, Insertable, Associations)] +#[table_name = "grid_view_rev_table"] +struct GridViewRevisionTable { + id: i32, + object_id: String, + base_rev_id: i64, + rev_id: i64, + data: Vec, + state: GridViewRevisionState, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, FromSqlRow, AsExpression)] +#[repr(i32)] +#[sql_type = "Integer"] +pub enum GridViewRevisionState { + Sync = 0, + Ack = 1, +} +impl_sql_integer_expression!(GridViewRevisionState); +impl_rev_state_map!(GridViewRevisionState); + +impl std::default::Default for GridViewRevisionState { + fn default() -> Self { + GridViewRevisionState::Sync + } +} + +fn mk_revision_record_from_table(user_id: &str, table: GridViewRevisionTable) -> RevisionRecord { + let md5 = md5(&table.data); + let revision = Revision::new( + &table.object_id, + table.base_rev_id, + table.rev_id, + Bytes::from(table.data), + user_id, + md5, + ); + RevisionRecord { + revision, + state: table.state.into(), + write_to_disk: false, + } +} diff --git a/frontend/rust-lib/flowy-revision/src/cache/disk/mod.rs b/frontend/rust-lib/flowy-revision/src/cache/disk/mod.rs index 991d8f9b9f..501d1e591b 100644 --- a/frontend/rust-lib/flowy-revision/src/cache/disk/mod.rs +++ b/frontend/rust-lib/flowy-revision/src/cache/disk/mod.rs @@ -1,16 +1,17 @@ -mod folder_rev_impl; -mod grid_block_meta_rev_impl; -mod grid_rev_impl; -mod text_rev_impl; +mod document_impl; +mod grid_block_impl; +mod grid_impl; +mod grid_view_impl; -pub use folder_rev_impl::*; -pub use grid_block_meta_rev_impl::*; -pub use grid_rev_impl::*; -pub use text_rev_impl::*; +pub use document_impl::*; +pub use grid_block_impl::*; +pub use grid_impl::*; +pub use grid_view_impl::*; -use flowy_error::FlowyResult; +use flowy_error::{FlowyError, FlowyResult}; use flowy_sync::entities::revision::{RevId, Revision, RevisionRange}; use std::fmt::Debug; +use std::sync::Arc; pub trait RevisionDiskCache: Sync + Send { type Error: Debug; @@ -45,6 +46,50 @@ pub trait RevisionDiskCache: Sync + Send { ) -> Result<(), Self::Error>; } +impl RevisionDiskCache for Arc +where + T: RevisionDiskCache, +{ + type Error = FlowyError; + + fn create_revision_records(&self, revision_records: Vec) -> Result<(), Self::Error> { + (**self).create_revision_records(revision_records) + } + + fn read_revision_records( + &self, + object_id: &str, + rev_ids: Option>, + ) -> Result, Self::Error> { + (**self).read_revision_records(object_id, rev_ids) + } + + fn read_revision_records_with_range( + &self, + object_id: &str, + range: &RevisionRange, + ) -> Result, Self::Error> { + (**self).read_revision_records_with_range(object_id, range) + } + + fn update_revision_record(&self, changesets: Vec) -> FlowyResult<()> { + (**self).update_revision_record(changesets) + } + + fn delete_revision_records(&self, object_id: &str, rev_ids: Option>) -> Result<(), Self::Error> { + (**self).delete_revision_records(object_id, rev_ids) + } + + fn delete_and_insert_records( + &self, + object_id: &str, + deleted_rev_ids: Option>, + inserted_records: Vec, + ) -> Result<(), Self::Error> { + (**self).delete_and_insert_records(object_id, deleted_rev_ids, inserted_records) + } +} + #[derive(Clone, Debug)] pub struct RevisionRecord { pub revision: Revision, diff --git a/frontend/rust-lib/flowy-revision/src/cache/mod.rs b/frontend/rust-lib/flowy-revision/src/cache/mod.rs index 3e592c49b1..4f3ee5c19f 100644 --- a/frontend/rust-lib/flowy-revision/src/cache/mod.rs +++ b/frontend/rust-lib/flowy-revision/src/cache/mod.rs @@ -1,2 +1,3 @@ pub mod disk; pub(crate) mod memory; +pub mod reset; diff --git a/frontend/rust-lib/flowy-revision/src/cache/reset.rs b/frontend/rust-lib/flowy-revision/src/cache/reset.rs new file mode 100644 index 0000000000..bd1ef4ba58 --- /dev/null +++ b/frontend/rust-lib/flowy-revision/src/cache/reset.rs @@ -0,0 +1,114 @@ +use crate::disk::{RevisionDiskCache, RevisionRecord}; +use crate::{RevisionLoader, RevisionPersistence}; +use flowy_database::kv::KV; +use flowy_error::{FlowyError, FlowyResult}; +use flowy_sync::entities::revision::Revision; +use lib_ot::core::TextDeltaBuilder; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use std::sync::Arc; + +pub trait RevisionResettable { + fn target_id(&self) -> &str; + // String in json format + fn target_reset_rev_str(&self, revisions: Vec) -> FlowyResult; + + // String in json format + fn default_target_rev_str(&self) -> FlowyResult; +} + +pub struct RevisionStructReset { + user_id: String, + target: T, + disk_cache: Arc>, +} + +impl RevisionStructReset +where + T: RevisionResettable, +{ + pub fn new(user_id: &str, object: T, disk_cache: Arc>) -> Self { + Self { + user_id: user_id.to_owned(), + target: object, + disk_cache, + } + } + + pub async fn run(&self) -> FlowyResult<()> { + match KV::get_str(self.target.target_id()) { + None => { + let _ = self.reset_object().await?; + let _ = self.save_migrate_record()?; + } + Some(s) => { + let mut record = MigrationGridRecord::from_str(&s)?; + let rev_str = self.target.default_target_rev_str()?; + if record.len < rev_str.len() { + let _ = self.reset_object().await?; + record.len = rev_str.len(); + KV::set_str(self.target.target_id(), record.to_string()); + } + } + } + Ok(()) + } + + async fn reset_object(&self) -> FlowyResult<()> { + let rev_persistence = Arc::new(RevisionPersistence::from_disk_cache( + &self.user_id, + self.target.target_id(), + self.disk_cache.clone(), + )); + let (revisions, _) = RevisionLoader { + object_id: self.target.target_id().to_owned(), + user_id: self.user_id.clone(), + cloud: None, + rev_persistence, + } + .load() + .await?; + + let s = self.target.target_reset_rev_str(revisions)?; + let delta_data = TextDeltaBuilder::new().insert(&s).build().json_bytes(); + let revision = Revision::initial_revision(&self.user_id, self.target.target_id(), delta_data); + let record = RevisionRecord::new(revision); + + tracing::trace!("Reset {} revision record object", self.target.target_id()); + let _ = self + .disk_cache + .delete_and_insert_records(self.target.target_id(), None, vec![record]); + + Ok(()) + } + + fn save_migrate_record(&self) -> FlowyResult<()> { + let rev_str = self.target.default_target_rev_str()?; + let record = MigrationGridRecord { + object_id: self.target.target_id().to_owned(), + len: rev_str.len(), + }; + KV::set_str(self.target.target_id(), record.to_string()); + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct MigrationGridRecord { + object_id: String, + len: usize, +} + +impl FromStr for MigrationGridRecord { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str::(s) + } +} + +impl ToString for MigrationGridRecord { + fn to_string(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| "".to_string()) + } +} diff --git a/frontend/rust-lib/flowy-revision/src/rev_persistence.rs b/frontend/rust-lib/flowy-revision/src/rev_persistence.rs index eb3da339b7..2124598400 100644 --- a/frontend/rust-lib/flowy-revision/src/rev_persistence.rs +++ b/frontend/rust-lib/flowy-revision/src/rev_persistence.rs @@ -28,9 +28,17 @@ impl RevisionPersistence { where C: 'static + RevisionDiskCache, { + let disk_cache = Arc::new(disk_cache) as Arc>; + Self::from_disk_cache(user_id, object_id, disk_cache) + } + + pub fn from_disk_cache( + user_id: &str, + object_id: &str, + disk_cache: Arc>, + ) -> RevisionPersistence { let object_id = object_id.to_owned(); let user_id = user_id.to_owned(); - let disk_cache = Arc::new(disk_cache) as Arc>; let sync_seq = RwLock::new(RevisionSyncSequence::new()); let memory_cache = Arc::new(RevisionMemoryCache::new(&object_id, Arc::new(disk_cache.clone()))); Self { @@ -65,12 +73,13 @@ impl RevisionPersistence { revision: &'a Revision, compactor: &Arc, ) -> FlowyResult { - let result = self.sync_seq.read().await.compact(); + let mut sync_seq_write_guard = self.sync_seq.write().await; + let result = sync_seq_write_guard.compact(); match result { None => { tracing::Span::current().record("rev_id", &revision.rev_id); self.add(revision.clone(), RevisionState::Sync, true).await?; - self.sync_seq.write().await.add(revision.rev_id)?; + sync_seq_write_guard.add(revision.rev_id)?; Ok(revision.rev_id) } Some((range, mut compact_seq)) => { @@ -93,8 +102,10 @@ impl RevisionPersistence { // replace the revisions in range with compact revision self.compact(&range, compact_revision).await?; - debug_assert_eq!(self.sync_seq.read().await.len(), compact_seq.len()); - self.sync_seq.write().await.reset(compact_seq); + // + debug_assert_eq!(compact_seq.len(), 2); + debug_assert_eq!(sync_seq_write_guard.len(), compact_seq.len()); + sync_seq_write_guard.reset(compact_seq); Ok(rev_id) } } @@ -307,6 +318,8 @@ impl RevisionSyncSequence { // Compact the rev_ids into one except the current synchronizing rev_id. fn compact(&self) -> Option<(RevisionRange, VecDeque)> { + // Make sure there are two rev_id going to sync. No need to compact if there is only + // one rev_id in queue. self.next_rev_id()?; let mut new_seq = self.0.clone(); 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 24bb71d89c..0dc51c1a09 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 @@ -1,6 +1,6 @@ use bytes::Bytes; use flowy_database::ConnectionPool; -use flowy_folder::entities::ViewDataType; +use flowy_folder::entities::{ViewDataTypePB, ViewLayoutTypePB}; use flowy_folder::manager::{ViewDataProcessor, ViewDataProcessorMap}; use flowy_folder::{ errors::{internal_error, FlowyError}, @@ -8,7 +8,7 @@ use flowy_folder::{ manager::FolderManager, }; use flowy_grid::manager::{make_grid_view_data, GridManager}; -use flowy_grid::util::make_default_grid; +use flowy_grid::util::{make_default_board, make_default_grid}; use flowy_grid_data_model::revision::BuildGridContext; use flowy_net::ClientServerConfiguration; use flowy_net::{ @@ -66,7 +66,7 @@ fn make_view_data_processor( text_block_manager: Arc, grid_manager: Arc, ) -> ViewDataProcessorMap { - let mut map: HashMap> = HashMap::new(); + let mut map: HashMap> = HashMap::new(); let block_data_impl = TextBlockViewDataProcessor(text_block_manager); map.insert(block_data_impl.data_type(), Arc::new(block_data_impl)); @@ -180,7 +180,13 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { }) } - fn create_default_view(&self, user_id: &str, view_id: &str) -> FutureResult { + fn create_default_view( + &self, + user_id: &str, + view_id: &str, + layout: ViewLayoutTypePB, + ) -> FutureResult { + debug_assert_eq!(layout, ViewLayoutTypePB::Document); let user_id = user_id.to_string(); let view_id = view_id.to_string(); let manager = self.0.clone(); @@ -203,8 +209,8 @@ impl ViewDataProcessor for TextBlockViewDataProcessor { FutureResult::new(async move { Ok(Bytes::from(data)) }) } - fn data_type(&self) -> ViewDataType { - ViewDataType::TextBlock + fn data_type(&self) -> ViewDataTypePB { + ViewDataTypePB::Text } } @@ -252,12 +258,24 @@ impl ViewDataProcessor for GridViewDataProcessor { }) } - fn create_default_view(&self, user_id: &str, view_id: &str) -> FutureResult { - let build_context = make_default_grid(); + fn create_default_view( + &self, + user_id: &str, + view_id: &str, + layout: ViewLayoutTypePB, + ) -> FutureResult { + let build_context = match layout { + ViewLayoutTypePB::Grid => make_default_grid(), + ViewLayoutTypePB::Board => make_default_board(), + ViewLayoutTypePB::Document => { + return FutureResult::new(async move { + Err(FlowyError::internal().context(format!("Can't handle {:?} layout type", layout))) + }); + } + }; let user_id = user_id.to_string(); let view_id = view_id.to_string(); let grid_manager = self.0.clone(); - FutureResult::new(async move { make_grid_view_data(&user_id, &view_id, grid_manager, build_context).await }) } @@ -278,7 +296,7 @@ impl ViewDataProcessor for GridViewDataProcessor { }) } - fn data_type(&self) -> ViewDataType { - ViewDataType::Grid + fn data_type(&self) -> ViewDataTypePB { + ViewDataTypePB::Database } } diff --git a/frontend/rust-lib/flowy-sdk/src/lib.rs b/frontend/rust-lib/flowy-sdk/src/lib.rs index 00a3785122..ce5920e532 100644 --- a/frontend/rust-lib/flowy-sdk/src/lib.rs +++ b/frontend/rust-lib/flowy-sdk/src/lib.rs @@ -74,7 +74,8 @@ fn crate_log_filter(level: String) -> String { filters.push(format!("lib_ot={}", level)); filters.push(format!("lib_ws={}", level)); filters.push(format!("lib_infra={}", level)); - filters.push(format!("flowy_sync={}", level)); + // filters.push(format!("flowy_sync={}", level)); + // filters.push(format!("flowy_revision={}", level)); // filters.push(format!("lib_dispatch={}", level)); filters.push(format!("dart_ffi={}", "info")); diff --git a/frontend/rust-lib/flowy-test/src/event_builder.rs b/frontend/rust-lib/flowy-test/src/event_builder.rs index d9841d6b4a..75cdff5f38 100644 --- a/frontend/rust-lib/flowy-test/src/event_builder.rs +++ b/frontend/rust-lib/flowy-test/src/event_builder.rs @@ -83,12 +83,22 @@ where R: FromBytes, { let response = self.get_response(); - match response.parse::() { + match response.clone().parse::() { Ok(Ok(data)) => data, Ok(Err(e)) => { - panic!("parse failed: {:?}", e) + panic!( + "Parser {:?} failed: {:?}, response {:?}", + std::any::type_name::(), + e, + response + ) } - Err(e) => panic!("Internal error: {:?}", e), + Err(e) => panic!( + "Dispatch {:?} failed: {:?}, response {:?}", + std::any::type_name::(), + e, + response + ), } } diff --git a/frontend/rust-lib/flowy-test/src/helper.rs b/frontend/rust-lib/flowy-test/src/helper.rs index 1265bd695c..ef530b3888 100644 --- a/frontend/rust-lib/flowy-test/src/helper.rs +++ b/frontend/rust-lib/flowy-test/src/helper.rs @@ -25,11 +25,11 @@ pub struct ViewTest { impl ViewTest { #[allow(dead_code)] - pub async fn new(sdk: &FlowySDKTest, data_type: ViewDataType, data: Vec) -> Self { + pub async fn new(sdk: &FlowySDKTest, data_type: ViewDataTypePB, layout: ViewLayoutTypePB, data: Vec) -> Self { let workspace = create_workspace(sdk, "Workspace", "").await; open_workspace(sdk, &workspace.id).await; let app = create_app(sdk, "App", "AppFlowy GitHub Project", &workspace.id).await; - let view = create_view(sdk, &app.id, data_type, data).await; + let view = create_view(sdk, &app.id, data_type, layout, data).await; Self { sdk: sdk.clone(), workspace, @@ -39,11 +39,15 @@ impl ViewTest { } pub async fn new_grid_view(sdk: &FlowySDKTest, data: Vec) -> Self { - Self::new(sdk, ViewDataType::Grid, data).await + Self::new(sdk, ViewDataTypePB::Database, ViewLayoutTypePB::Grid, data).await + } + + pub async fn new_board_view(sdk: &FlowySDKTest, data: Vec) -> Self { + Self::new(sdk, ViewDataTypePB::Database, ViewLayoutTypePB::Board, data).await } pub async fn new_text_block_view(sdk: &FlowySDKTest) -> Self { - Self::new(sdk, ViewDataType::TextBlock, vec![]).await + Self::new(sdk, ViewDataTypePB::Text, ViewLayoutTypePB::Document, vec![]).await } } @@ -90,15 +94,21 @@ async fn create_app(sdk: &FlowySDKTest, name: &str, desc: &str, workspace_id: &s app } -async fn create_view(sdk: &FlowySDKTest, app_id: &str, data_type: ViewDataType, data: Vec) -> ViewPB { +async fn create_view( + sdk: &FlowySDKTest, + app_id: &str, + data_type: ViewDataTypePB, + layout: ViewLayoutTypePB, + data: Vec, +) -> ViewPB { let request = CreateViewPayloadPB { belong_to_id: app_id.to_string(), name: "View A".to_string(), desc: "".to_string(), thumbnail: Some("http://1.png".to_string()), data_type, - plugin_type: 0, - data, + layout, + view_content_data: data, }; let view = FolderEventBuilder::new(sdk.clone()) diff --git a/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs b/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs index 71259509f2..792af0c146 100644 --- a/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs +++ b/frontend/rust-lib/flowy-user/src/entities/parser/mod.rs @@ -1,11 +1,13 @@ // https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ mod user_email; +mod user_icon; mod user_id; mod user_name; mod user_password; mod user_workspace; pub use user_email::*; +pub use user_icon::*; pub use user_id::*; pub use user_name::*; pub use user_password::*; diff --git a/frontend/rust-lib/flowy-user/src/entities/parser/user_icon.rs b/frontend/rust-lib/flowy-user/src/entities/parser/user_icon.rs new file mode 100644 index 0000000000..69258ca848 --- /dev/null +++ b/frontend/rust-lib/flowy-user/src/entities/parser/user_icon.rs @@ -0,0 +1,16 @@ +use crate::errors::ErrorCode; + +#[derive(Debug)] +pub struct UserIcon(pub String); + +impl UserIcon { + pub fn parse(s: String) -> Result { + Ok(Self(s)) + } +} + +impl AsRef for UserIcon { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs index 276894ffc8..4b423db3af 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_profile.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_profile.rs @@ -2,7 +2,7 @@ use flowy_derive::ProtoBuf; use std::convert::TryInto; use crate::{ - entities::parser::{UserEmail, UserId, UserName, UserPassword}, + entities::parser::{UserEmail, UserIcon, UserId, UserName, UserPassword}, errors::ErrorCode, }; @@ -25,6 +25,9 @@ pub struct UserProfilePB { #[pb(index = 4)] pub token: String, + + #[pb(index = 5)] + pub icon_url: String, } #[derive(ProtoBuf, Default)] @@ -40,6 +43,9 @@ pub struct UpdateUserProfilePayloadPB { #[pb(index = 4, one_of)] pub password: Option, + + #[pb(index = 5, one_of)] + pub icon_url: Option, } impl UpdateUserProfilePayloadPB { @@ -64,6 +70,11 @@ impl UpdateUserProfilePayloadPB { self.password = Some(password.to_owned()); self } + + pub fn icon_url(mut self, icon_url: &str) -> Self { + self.icon_url = Some(icon_url.to_owned()); + self + } } #[derive(ProtoBuf, Default, Clone, Debug)] @@ -79,6 +90,9 @@ pub struct UpdateUserProfileParams { #[pb(index = 4, one_of)] pub password: Option, + + #[pb(index = 5, one_of)] + pub icon_url: Option, } impl UpdateUserProfileParams { @@ -88,6 +102,7 @@ impl UpdateUserProfileParams { name: None, email: None, password: None, + icon_url: None, } } @@ -105,6 +120,11 @@ impl UpdateUserProfileParams { self.password = Some(password.to_owned()); self } + + pub fn icon_url(mut self, icon_url: &str) -> Self { + self.icon_url = Some(icon_url.to_owned()); + self + } } impl TryInto for UpdateUserProfilePayloadPB { @@ -128,11 +148,17 @@ impl TryInto for UpdateUserProfilePayloadPB { Some(password) => Some(UserPassword::parse(password)?.0), }; + let icon_url = match self.icon_url { + None => None, + Some(icon_url) => Some(UserIcon::parse(icon_url)?.0), + }; + Ok(UpdateUserProfileParams { id, name, email, password, + icon_url, }) } } diff --git a/frontend/rust-lib/flowy-user/src/services/database.rs b/frontend/rust-lib/flowy-user/src/services/database.rs index 64ebd705a7..9e94ea2c9d 100644 --- a/frontend/rust-lib/flowy-user/src/services/database.rs +++ b/frontend/rust-lib/flowy-user/src/services/database.rs @@ -37,8 +37,8 @@ impl UserDB { Some(database) => return Ok(database.get_pool()), } - tracing::trace!("open user db {}", user_id); let dir = format!("{}/{}", self.db_dir, user_id); + tracing::trace!("open user db {} at path: {}", user_id, dir); let db = flowy_database::init(&dir).map_err(|e| { log::error!("open user: {} db failed, {:?}", user_id, e); FlowyError::internal().context(e) @@ -82,6 +82,7 @@ pub struct UserTable { pub(crate) token: String, pub(crate) email: String, pub(crate) workspace: String, // deprecated + pub(crate) icon_url: String, } impl UserTable { @@ -91,6 +92,7 @@ impl UserTable { name, email, token, + icon_url: "".to_owned(), workspace: "".to_owned(), } } @@ -120,6 +122,7 @@ impl std::convert::From for UserProfilePB { email: table.email, name: table.name, token: table.token, + icon_url: table.icon_url, } } } @@ -131,6 +134,7 @@ pub struct UserTableChangeset { pub workspace: Option, // deprecated pub name: Option, pub email: Option, + pub icon_url: Option, } impl UserTableChangeset { @@ -140,6 +144,7 @@ impl UserTableChangeset { workspace: None, name: params.name, email: params.email, + icon_url: params.icon_url, } } } diff --git a/frontend/rust-lib/lib-dispatch/src/byte_trait.rs b/frontend/rust-lib/lib-dispatch/src/byte_trait.rs index 9f8c2a6545..0e48813afd 100644 --- a/frontend/rust-lib/lib-dispatch/src/byte_trait.rs +++ b/frontend/rust-lib/lib-dispatch/src/byte_trait.rs @@ -14,7 +14,12 @@ where fn into_bytes(self) -> Result { match self.try_into() { Ok(data) => Ok(data), - Err(e) => Err(InternalError::ProtobufError(format!("{:?}", e)).into()), + Err(e) => Err(InternalError::ProtobufError(format!( + "Serial {:?} to bytes failed:{:?}", + std::any::type_name::(), + e + )) + .into()), } } } diff --git a/frontend/rust-lib/lib-dispatch/src/data.rs b/frontend/rust-lib/lib-dispatch/src/data.rs index e331fe071e..74d5ab6f17 100644 --- a/frontend/rust-lib/lib-dispatch/src/data.rs +++ b/frontend/rust-lib/lib-dispatch/src/data.rs @@ -55,7 +55,10 @@ where { fn respond_to(self, _request: &EventRequest) -> EventResponse { match self.into_inner().into_bytes() { - Ok(bytes) => ResponseBuilder::Ok().data(bytes).build(), + Ok(bytes) => { + log::trace!("Serialize Data: {:?} to event response", std::any::type_name::()); + ResponseBuilder::Ok().data(bytes).build() + } Err(e) => e.into(), } } @@ -86,7 +89,11 @@ where T: FromBytes, { match payload { - Payload::None => Err(InternalError::UnexpectedNone("Parse fail, expected payload".to_string()).into()), + Payload::None => Err(InternalError::UnexpectedNone(format!( + "Parse fail, expected payload:{:?}", + std::any::type_name::() + )) + .into()), Payload::Bytes(bytes) => { let data = T::parse_from_bytes(bytes.clone())?; Ok(Data(data)) diff --git a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs index fd7296a70d..961f19986b 100644 --- a/frontend/rust-lib/lib-dispatch/src/dispatcher.rs +++ b/frontend/rust-lib/lib-dispatch/src/dispatcher.rs @@ -54,16 +54,18 @@ impl EventDispatcher { callback: Some(Box::new(callback)), }; let join_handle = dispatch.runtime.spawn(async move { - service - .call(service_ctx) - .await - .unwrap_or_else(|e| InternalError::Other(format!("{:?}", e)).as_response()) + service.call(service_ctx).await.unwrap_or_else(|e| { + tracing::error!("Dispatch runtime error: {:?}", e); + InternalError::Other(format!("{:?}", e)).as_response() + }) }); DispatchFuture { fut: Box::pin(async move { join_handle.await.unwrap_or_else(|e| { - let error = InternalError::JoinError(format!("EVENT_DISPATCH join error: {:?}", e)); + let msg = format!("EVENT_DISPATCH join error: {:?}", e); + tracing::error!("{}", msg); + let error = InternalError::JoinError(msg); error.as_response() }) }), diff --git a/shared-lib/Cargo.lock b/shared-lib/Cargo.lock index 9b8e5f6081..da8bc0ff1c 100644 --- a/shared-lib/Cargo.lock +++ b/shared-lib/Cargo.lock @@ -436,6 +436,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "tracing", ] [[package]] diff --git a/shared-lib/flowy-error-code/src/code.rs b/shared-lib/flowy-error-code/src/code.rs index 352c4abbdb..63f7ac7749 100644 --- a/shared-lib/flowy-error-code/src/code.rs +++ b/shared-lib/flowy-error-code/src/code.rs @@ -91,6 +91,9 @@ pub enum ErrorCode { #[display(fmt = "Grid id is empty")] GridIdIsEmpty = 410, + #[display(fmt = "Grid view id is empty")] + GridViewIdIsEmpty = 411, + #[display(fmt = "Grid block id is empty")] BlockIdIsEmpty = 420, #[display(fmt = "Row id is empty")] @@ -111,6 +114,9 @@ pub enum ErrorCode { #[display(fmt = "Field's type option data should not be empty")] TypeOptionDataIsEmpty = 450, + #[display(fmt = "Group id is empty")] + GroupIdIsEmpty = 460, + #[display(fmt = "Invalid date time format")] InvalidDateTimeFormat = 500, diff --git a/shared-lib/flowy-folder-data-model/Cargo.toml b/shared-lib/flowy-folder-data-model/Cargo.toml index bacd1c9509..15b8e47bac 100644 --- a/shared-lib/flowy-folder-data-model/Cargo.toml +++ b/shared-lib/flowy-folder-data-model/Cargo.toml @@ -17,7 +17,7 @@ log = "0.4.14" nanoid = "0.4.0" chrono = { version = "0.4" } flowy-error-code = { path = "../flowy-error-code"} -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" serde_repr = "0.1" diff --git a/shared-lib/flowy-folder-data-model/src/revision/folder_rev.rs b/shared-lib/flowy-folder-data-model/src/revision/folder_rev.rs new file mode 100644 index 0000000000..f7d48b93fd --- /dev/null +++ b/shared-lib/flowy-folder-data-model/src/revision/folder_rev.rs @@ -0,0 +1,9 @@ +use crate::revision::{TrashRevision, WorkspaceRevision}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Default, Deserialize, Serialize, Clone, Eq, PartialEq)] +pub struct FolderRevision { + pub workspaces: Vec>, + pub trash: Vec>, +} diff --git a/shared-lib/flowy-folder-data-model/src/revision/mod.rs b/shared-lib/flowy-folder-data-model/src/revision/mod.rs index 6a837ac035..a1e9d5e33e 100644 --- a/shared-lib/flowy-folder-data-model/src/revision/mod.rs +++ b/shared-lib/flowy-folder-data-model/src/revision/mod.rs @@ -1,9 +1,11 @@ mod app_rev; +mod folder_rev; mod trash_rev; mod view_rev; mod workspace_rev; pub use app_rev::*; +pub use folder_rev::*; pub use trash_rev::*; pub use view_rev::*; pub use workspace_rev::*; diff --git a/shared-lib/flowy-folder-data-model/src/revision/trash_rev.rs b/shared-lib/flowy-folder-data-model/src/revision/trash_rev.rs index c72fc61dad..0855fb3d29 100644 --- a/shared-lib/flowy-folder-data-model/src/revision/trash_rev.rs +++ b/shared-lib/flowy-folder-data-model/src/revision/trash_rev.rs @@ -33,7 +33,7 @@ impl<'de> serde::Deserialize<'de> for TrashTypeRevision { type Value = TrashTypeRevision; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("u8") + formatter.write_str("expected enum TrashTypeRevision with type: u8") } fn visit_i8(self, v: i8) -> Result diff --git a/shared-lib/flowy-folder-data-model/src/revision/view_rev.rs b/shared-lib/flowy-folder-data-model/src/revision/view_rev.rs index 7a81bbb191..f5bf1d7ffc 100644 --- a/shared-lib/flowy-folder-data-model/src/revision/view_rev.rs +++ b/shared-lib/flowy-folder-data-model/src/revision/view_rev.rs @@ -9,7 +9,9 @@ pub fn gen_view_id() -> String { pub struct ViewRevision { pub id: String, - pub belong_to_id: String, + // Maybe app_id or vi + #[serde(rename = "belong_to_id")] + pub app_id: String, pub name: String, @@ -18,7 +20,7 @@ pub struct ViewRevision { #[serde(default)] pub data_type: ViewDataTypeRevision, - pub version: i64, + pub version: i64, // Deprecated pub belongings: Vec, @@ -33,9 +35,10 @@ pub struct ViewRevision { pub thumbnail: String, #[serde(default = "DEFAULT_PLUGIN_TYPE")] - pub plugin_type: i32, + #[serde(rename = "plugin_type")] + pub layout: ViewLayoutTypeRevision, } -const DEFAULT_PLUGIN_TYPE: fn() -> i32 = || 0; +const DEFAULT_PLUGIN_TYPE: fn() -> ViewLayoutTypeRevision = || ViewLayoutTypeRevision::Document; impl std::convert::From for TrashRevision { fn from(view_rev: ViewRevision) -> Self { @@ -52,12 +55,27 @@ impl std::convert::From for TrashRevision { #[derive(Eq, PartialEq, Debug, Clone, Serialize_repr, Deserialize_repr)] #[repr(u8)] pub enum ViewDataTypeRevision { - TextBlock = 0, - Grid = 1, + Text = 0, + Database = 1, } impl std::default::Default for ViewDataTypeRevision { fn default() -> Self { - ViewDataTypeRevision::TextBlock + ViewDataTypeRevision::Text + } +} + +#[derive(Eq, PartialEq, Debug, Clone, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum ViewLayoutTypeRevision { + Document = 0, + // The for historical reasons, the value of Grid is not 1. + Grid = 3, + Board = 4, +} + +impl std::default::Default for ViewLayoutTypeRevision { + fn default() -> Self { + ViewLayoutTypeRevision::Document } } diff --git a/shared-lib/flowy-folder-data-model/src/user_default.rs b/shared-lib/flowy-folder-data-model/src/user_default.rs index 166bc32508..bb4eacc4dc 100644 --- a/shared-lib/flowy-folder-data-model/src/user_default.rs +++ b/shared-lib/flowy-folder-data-model/src/user_default.rs @@ -1,5 +1,6 @@ use crate::revision::{ - gen_app_id, gen_view_id, gen_workspace_id, AppRevision, ViewDataTypeRevision, ViewRevision, WorkspaceRevision, + gen_app_id, gen_view_id, gen_workspace_id, AppRevision, ViewDataTypeRevision, ViewLayoutTypeRevision, ViewRevision, + WorkspaceRevision, }; use chrono::Utc; @@ -46,16 +47,16 @@ fn create_default_view(app_id: String, time: chrono::DateTime) -> ViewRevis ViewRevision { id: view_id, - belong_to_id: app_id, + app_id, name, desc: "".to_string(), - data_type: ViewDataTypeRevision::TextBlock, + data_type: ViewDataTypeRevision::Text, version: 0, belongings: vec![], modified_time: time.timestamp(), create_time: time.timestamp(), ext_data: "".to_string(), thumbnail: "".to_string(), - plugin_type: 0, + layout: ViewLayoutTypeRevision::Document, } } diff --git a/shared-lib/flowy-grid-data-model/Cargo.toml b/shared-lib/flowy-grid-data-model/Cargo.toml index b0162c82a8..3e640ec1af 100644 --- a/shared-lib/flowy-grid-data-model/Cargo.toml +++ b/shared-lib/flowy-grid-data-model/Cargo.toml @@ -13,6 +13,7 @@ serde_repr = "0.1" nanoid = "0.4.0" flowy-error-code = { path = "../flowy-error-code"} indexmap = {version = "1.8.1", features = ["serde"]} +tracing = { version = "0.1", features = ["log"] } [build-dependencies] lib-infra = { path = "../lib-infra", features = ["protobuf_file_gen"] } diff --git a/shared-lib/flowy-grid-data-model/src/revision/grid_block.rs b/shared-lib/flowy-grid-data-model/src/revision/grid_block.rs new file mode 100644 index 0000000000..5464d83877 --- /dev/null +++ b/shared-lib/flowy-grid-data-model/src/revision/grid_block.rs @@ -0,0 +1,76 @@ +use indexmap::IndexMap; +use nanoid::nanoid; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; + +pub fn gen_row_id() -> String { + nanoid!(6) +} + +pub const DEFAULT_ROW_HEIGHT: i32 = 42; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GridBlockRevision { + pub block_id: String, + pub rows: Vec>, +} + +pub type FieldId = String; +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RowRevision { + pub id: String, + pub block_id: String, + /// cells contains key/value pairs. + /// key: field id, + /// value: CellMeta + #[serde(with = "indexmap::serde_seq")] + pub cells: IndexMap, + pub height: i32, + pub visibility: bool, +} + +impl RowRevision { + pub fn new(block_id: &str) -> Self { + Self { + id: gen_row_id(), + block_id: block_id.to_owned(), + cells: Default::default(), + height: DEFAULT_ROW_HEIGHT, + visibility: true, + } + } +} +#[derive(Debug, Clone, Default)] +pub struct RowChangeset { + pub row_id: String, + pub height: Option, + pub visibility: Option, + pub cell_by_field_id: HashMap, +} + +impl RowChangeset { + pub fn new(row_id: String) -> Self { + Self { + row_id, + height: None, + visibility: None, + cell_by_field_id: Default::default(), + } + } + + pub fn has_changed(&self) -> bool { + self.height.is_some() || self.visibility.is_some() || !self.cell_by_field_id.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CellRevision { + pub data: String, +} + +impl CellRevision { + pub fn new(data: String) -> Self { + Self { data } + } +} diff --git a/shared-lib/flowy-grid-data-model/src/revision/grid_filter_rev.rs b/shared-lib/flowy-grid-data-model/src/revision/grid_filter_rev.rs deleted file mode 100644 index 540a67b9ea..0000000000 --- a/shared-lib/flowy-grid-data-model/src/revision/grid_filter_rev.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::entities::NumberFilterCondition; -use indexmap::IndexMap; -use nanoid::nanoid; -use serde::{Deserialize, Serialize}; -use serde_repr::*; -use std::str::FromStr; - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct GridFilterRevision { - pub id: String, - pub field_id: String, - pub condition: u8, - pub content: Option, -} - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum TextFilterConditionRevision { - Is = 0, - IsNot = 1, - Contains = 2, - DoesNotContain = 3, - StartsWith = 4, - EndsWith = 5, - IsEmpty = 6, - IsNotEmpty = 7, -} - -impl ToString for TextFilterConditionRevision { - fn to_string(&self) -> String { - (self.clone() as u8).to_string() - } -} - -impl FromStr for TextFilterConditionRevision { - type Err = serde_json::Error; - - fn from_str(s: &str) -> Result { - let rev = serde_json::from_str(s)?; - Ok(rev) - } -} - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum NumberFilterConditionRevision { - Equal = 0, - NotEqual = 1, - GreaterThan = 2, - LessThan = 3, - GreaterThanOrEqualTo = 4, - LessThanOrEqualTo = 5, - IsEmpty = 6, - IsNotEmpty = 7, -} - -impl ToString for NumberFilterConditionRevision { - fn to_string(&self) -> String { - (self.clone() as u8).to_string() - } -} - -impl FromStr for NumberFilterConditionRevision { - type Err = serde_json::Error; - - fn from_str(s: &str) -> Result { - let rev = serde_json::from_str(s)?; - Ok(rev) - } -} - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum SelectOptionConditionRevision { - OptionIs = 0, - OptionIsNot = 1, - OptionIsEmpty = 2, - OptionIsNotEmpty = 3, -} - -impl ToString for SelectOptionConditionRevision { - fn to_string(&self) -> String { - (self.clone() as u8).to_string() - } -} - -impl FromStr for SelectOptionConditionRevision { - type Err = serde_json::Error; - - fn from_str(s: &str) -> Result { - let rev = serde_json::from_str(s)?; - Ok(rev) - } -} diff --git a/shared-lib/flowy-grid-data-model/src/revision/grid_rev.rs b/shared-lib/flowy-grid-data-model/src/revision/grid_rev.rs index 09056f827d..fedac5c591 100644 --- a/shared-lib/flowy-grid-data-model/src/revision/grid_rev.rs +++ b/shared-lib/flowy-grid-data-model/src/revision/grid_rev.rs @@ -1,13 +1,10 @@ -use crate::revision::GridSettingRevision; +use crate::revision::GridBlockRevision; use bytes::Bytes; use indexmap::IndexMap; use nanoid::nanoid; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::sync::Arc; -pub const DEFAULT_ROW_HEIGHT: i32 = 42; - pub fn gen_grid_id() -> String { // nanoid calculator https://zelark.github.io/nano-id-cc/ nanoid!(10) @@ -17,10 +14,6 @@ pub fn gen_block_id() -> String { nanoid!(10) } -pub fn gen_row_id() -> String { - nanoid!(6) -} - pub fn gen_field_id() -> String { nanoid!(6) } @@ -30,9 +23,6 @@ pub struct GridRevision { pub grid_id: String, pub fields: Vec>, pub blocks: Vec>, - - #[serde(default)] - pub setting: GridSettingRevision, } impl GridRevision { @@ -41,7 +31,6 @@ impl GridRevision { grid_id: grid_id.to_owned(), fields: vec![], blocks: vec![], - setting: GridSettingRevision::default(), } } @@ -49,8 +38,7 @@ impl GridRevision { Self { grid_id: grid_id.to_owned(), fields: context.field_revs, - blocks: context.blocks.into_iter().map(Arc::new).collect(), - setting: Default::default(), + blocks: context.block_metas.into_iter().map(Arc::new).collect(), } } } @@ -88,21 +76,15 @@ pub struct GridBlockMetaRevisionChangeset { } impl GridBlockMetaRevisionChangeset { - pub fn from_row_count(block_id: &str, row_count: i32) -> Self { + pub fn from_row_count(block_id: String, row_count: i32) -> Self { Self { - block_id: block_id.to_string(), + block_id, start_row_index: None, row_count: Some(row_count), } } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GridBlockRevision { - pub block_id: String, - pub rows: Vec>, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] pub struct FieldRevision { pub id: String, @@ -112,7 +94,7 @@ pub struct FieldRevision { pub desc: String, #[serde(rename = "field_type")] - pub field_type_rev: FieldTypeRevision, + pub ty: FieldTypeRevision, pub frozen: bool, @@ -152,7 +134,7 @@ impl FieldRevision { id: gen_field_id(), name: name.to_string(), desc: desc.to_string(), - field_type_rev: field_type.into(), + ty: field_type.into(), frozen: false, visibility: true, width, @@ -165,12 +147,13 @@ impl FieldRevision { where T: TypeOptionDataEntry + ?Sized, { - let id = self.field_type_rev.to_string(); + let id = self.ty.to_string(); self.type_options.insert(id, entry.json_str()); } pub fn get_type_option_entry(&self, field_type_rev: FieldTypeRevision) -> Option { let id = field_type_rev.to_string(); + // TODO: cache the deserialized type option self.type_options.get(&id).map(|s| T::from_json_str(s)) } @@ -200,55 +183,11 @@ pub trait TypeOptionDataDeserializer { fn from_protobuf_bytes(bytes: Bytes) -> Self; } -pub type FieldId = String; -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct RowRevision { - pub id: String, - pub block_id: String, - /// cells contains key/value pairs. - /// key: field id, - /// value: CellMeta - #[serde(with = "indexmap::serde_seq")] - pub cells: IndexMap, - pub height: i32, - pub visibility: bool, -} - -impl RowRevision { - pub fn new(block_id: &str) -> Self { - Self { - id: gen_row_id(), - block_id: block_id.to_owned(), - cells: Default::default(), - height: DEFAULT_ROW_HEIGHT, - visibility: true, - } - } -} -#[derive(Debug, Clone, Default)] -pub struct RowMetaChangeset { - pub row_id: String, - pub height: Option, - pub visibility: Option, - pub cell_by_field_id: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub struct CellRevision { - pub data: String, -} - -impl CellRevision { - pub fn new(data: String) -> Self { - Self { data } - } -} - #[derive(Clone, Default, Deserialize, Serialize)] pub struct BuildGridContext { pub field_revs: Vec>, - pub blocks: Vec, - pub blocks_meta_data: Vec, + pub block_metas: Vec, + pub blocks: Vec, } impl BuildGridContext { diff --git a/shared-lib/flowy-grid-data-model/src/revision/grid_setting_rev.rs b/shared-lib/flowy-grid-data-model/src/revision/grid_setting_rev.rs index d29d9d767b..da366f39ab 100644 --- a/shared-lib/flowy-grid-data-model/src/revision/grid_setting_rev.rs +++ b/shared-lib/flowy-grid-data-model/src/revision/grid_setting_rev.rs @@ -4,6 +4,7 @@ use nanoid::nanoid; use serde::{Deserialize, Serialize}; use serde_repr::*; use std::collections::HashMap; +use std::fmt::Debug; use std::sync::Arc; pub fn gen_grid_filter_id() -> String { @@ -18,187 +19,239 @@ pub fn gen_grid_sort_id() -> String { nanoid!(6) } -/// Each layout contains multiple key/value. -/// Key: field_id -/// Value: this value also contains key/value. -/// Key: FieldType, -/// Value: the corresponding filter. -/// -/// This overall struct is described below: -/// GridSettingRevision -/// layout: -/// field_id: -/// FieldType: GridFilterRevision -/// FieldType: GridFilterRevision -/// field_id: -/// FieldType: GridFilterRevision -/// FieldType: GridFilterRevision -/// layout: -/// field_id: -/// FieldType: GridFilterRevision -/// FieldType: GridFilterRevision -/// -/// Group and sorts will be the same structure as filters. -#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)] -pub struct GridSettingRevision { - pub layout: GridLayoutRevision, - - #[serde(with = "indexmap::serde_seq")] - filters: IndexMap>, - - #[serde(skip, with = "indexmap::serde_seq")] - pub groups: IndexMap>, - - #[serde(skip, with = "indexmap::serde_seq")] - pub sorts: IndexMap>, -} - -pub type FiltersByFieldId = HashMap>>; -pub type GroupsByFieldId = HashMap>>; -pub type SortsByFieldId = HashMap>>; -impl GridSettingRevision { - pub fn get_all_group(&self) -> Option { - None - } - - pub fn get_all_sort(&self) -> Option { - None - } - - /// Return the Filters of the current layout - pub fn get_all_filter(&self, field_revs: &[Arc]) -> Option { - let layout = &self.layout; - // Acquire the read lock of the filters. - let filter_rev_map_by_field_id = self.filters.get(layout)?; - // Get the filters according to the FieldType, so we need iterate the field_revs. - let filters_by_field_id = field_revs - .iter() - .flat_map(|field_rev| { - let field_type = &field_rev.field_type_rev; - let field_id = &field_rev.id; - - let filter_rev_map: &GridFilterRevisionMap = filter_rev_map_by_field_id.get(field_id)?; - let filters: Vec> = filter_rev_map.get(field_type)?.clone(); - Some((field_rev.id.clone(), filters)) - }) - .collect::(); - Some(filters_by_field_id) - } - - #[allow(dead_code)] - fn get_filter_rev_map(&self, layout: &GridLayoutRevision, field_id: &str) -> Option<&GridFilterRevisionMap> { - let filter_rev_map_by_field_id = self.filters.get(layout)?; - filter_rev_map_by_field_id.get(field_id) - } - - pub fn get_mut_filters( - &mut self, - layout: &GridLayoutRevision, - field_id: &str, - field_type: &FieldTypeRevision, - ) -> Option<&mut Vec>> { - self.filters - .get_mut(layout) - .and_then(|filter_rev_map_by_field_id| filter_rev_map_by_field_id.get_mut(field_id)) - .and_then(|filter_rev_map| filter_rev_map.get_mut(field_type)) - } - - pub fn get_filters( - &self, - layout: &GridLayoutRevision, - field_id: &str, - field_type_rev: &FieldTypeRevision, - ) -> Option>> { - self.filters - .get(layout) - .and_then(|filter_rev_map_by_field_id| filter_rev_map_by_field_id.get(field_id)) - .and_then(|filter_rev_map| filter_rev_map.get(field_type_rev)) - .cloned() - } - - pub fn insert_filter( - &mut self, - layout: &GridLayoutRevision, - field_id: &str, - field_type: &FieldTypeRevision, - filter_rev: GridFilterRevision, - ) { - let filter_rev_map_by_field_id = self.filters.entry(layout.clone()).or_insert_with(IndexMap::new); - let filter_rev_map = filter_rev_map_by_field_id - .entry(field_id.to_string()) - .or_insert_with(GridFilterRevisionMap::new); - - filter_rev_map - .entry(field_type.to_owned()) - .or_insert_with(Vec::new) - .push(Arc::new(filter_rev)) - } -} +pub type FilterConfiguration = Configuration; +pub type FilterConfigurationsByFieldId = HashMap>>; +// +pub type GroupConfiguration = Configuration; +pub type GroupConfigurationsByFieldId = HashMap>>; +// +pub type SortConfiguration = Configuration; +pub type SortConfigurationsByFieldId = HashMap>>; #[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)] -#[serde(transparent)] -pub struct GridFilterRevisionMap { - #[serde(with = "indexmap::serde_seq")] - pub filter_by_field_type: IndexMap>>, -} +pub struct SettingRevision { + pub layout: LayoutRevision, -impl GridFilterRevisionMap { - pub fn new() -> Self { - GridFilterRevisionMap::default() - } -} + pub filters: FilterConfiguration, -impl std::ops::Deref for GridFilterRevisionMap { - type Target = IndexMap>>; + #[serde(default)] + pub groups: GroupConfiguration, - fn deref(&self) -> &Self::Target { - &self.filter_by_field_type - } -} - -impl std::ops::DerefMut for GridFilterRevisionMap { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.filter_by_field_type - } + #[serde(skip)] + pub sorts: SortConfiguration, } #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize_repr, Deserialize_repr)] #[repr(u8)] -pub enum GridLayoutRevision { +pub enum LayoutRevision { Table = 0, Board = 1, } -impl ToString for GridLayoutRevision { +impl ToString for LayoutRevision { fn to_string(&self) -> String { let layout_rev = self.clone() as u8; layout_rev.to_string() } } -impl std::default::Default for GridLayoutRevision { +impl std::default::Default for LayoutRevision { fn default() -> Self { - GridLayoutRevision::Table + LayoutRevision::Table } } +impl SettingRevision { + pub fn get_all_groups(&self, field_revs: &[Arc]) -> Option { + self.groups.get_all_objects(field_revs) + } + + pub fn get_groups( + &self, + field_id: &str, + field_type_rev: &FieldTypeRevision, + ) -> Option>> { + self.groups.get_objects(field_id, field_type_rev) + } + + pub fn get_mut_groups( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + ) -> Option<&mut Vec>> { + self.groups.get_mut_objects(field_id, field_type) + } + + pub fn insert_group( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + group_rev: GroupConfigurationRevision, + ) { + // only one group can be set + self.groups.remove_all(); + self.groups.insert_object(field_id, field_type, group_rev); + } + + pub fn get_all_filters(&self, field_revs: &[Arc]) -> Option { + self.filters.get_all_objects(field_revs) + } + + pub fn get_filters( + &self, + field_id: &str, + field_type_rev: &FieldTypeRevision, + ) -> Option>> { + self.filters.get_objects(field_id, field_type_rev) + } + + pub fn get_mut_filters( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + ) -> Option<&mut Vec>> { + self.filters.get_mut_objects(field_id, field_type) + } + + pub fn insert_filter( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + filter_rev: FilterConfigurationRevision, + ) { + self.filters.insert_object(field_id, field_type, filter_rev); + } + + pub fn get_all_sort(&self) -> Option { + None + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct SortConfigurationRevision { + pub id: String, + pub field_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)] +#[serde(transparent)] +pub struct Configuration +where + T: Debug + Clone + Default + Eq + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static, +{ + /// Key: field_id + /// Value: this value contains key/value. + /// Key: FieldType, + /// Value: the corresponding objects. + #[serde(with = "indexmap::serde_seq")] + inner: IndexMap>, +} + +impl Configuration +where + T: Debug + Clone + Default + Eq + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static, +{ + pub fn get_mut_objects(&mut self, field_id: &str, field_type: &FieldTypeRevision) -> Option<&mut Vec>> { + let value = self + .inner + .get_mut(field_id) + .and_then(|object_rev_map| object_rev_map.get_mut(field_type)); + if value.is_none() { + tracing::warn!("Can't find the {:?} with", std::any::type_name::()); + } + value + } + pub fn get_objects(&self, field_id: &str, field_type_rev: &FieldTypeRevision) -> Option>> { + self.inner + .get(field_id) + .and_then(|object_rev_map| object_rev_map.get(field_type_rev)) + .cloned() + } + + pub fn get_all_objects(&self, field_revs: &[Arc]) -> Option>>> { + // Get the objects according to the FieldType, so we need iterate the field_revs. + let objects_by_field_id = field_revs + .iter() + .flat_map(|field_rev| { + let field_type = &field_rev.ty; + let field_id = &field_rev.id; + + let object_rev_map = self.inner.get(field_id)?; + let objects: Vec> = object_rev_map.get(field_type)?.clone(); + Some((field_rev.id.clone(), objects)) + }) + .collect::>>>(); + Some(objects_by_field_id) + } + + pub fn insert_object(&mut self, field_id: &str, field_type: &FieldTypeRevision, object: T) { + let object_rev_map = self + .inner + .entry(field_id.to_string()) + .or_insert_with(ObjectIndexMap::::new); + + object_rev_map + .entry(field_type.to_owned()) + .or_insert_with(Vec::new) + .push(Arc::new(object)) + } + + pub fn remove_all(&mut self) { + self.inner.clear() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)] +#[serde(transparent)] +pub struct ObjectIndexMap +where + T: Debug + Clone + Default + Eq + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static, +{ + #[serde(with = "indexmap::serde_seq")] + pub object_by_field_type: IndexMap>>, +} + +impl ObjectIndexMap +where + T: Debug + Clone + Default + Eq + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static, +{ + pub fn new() -> Self { + ObjectIndexMap::default() + } +} + +impl std::ops::Deref for ObjectIndexMap +where + T: Debug + Clone + Default + Eq + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static, +{ + type Target = IndexMap>>; + + fn deref(&self) -> &Self::Target { + &self.object_by_field_type + } +} + +impl std::ops::DerefMut for ObjectIndexMap +where + T: Debug + Clone + Default + Eq + PartialEq + serde::Serialize + serde::de::DeserializeOwned + 'static, +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.object_by_field_type + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct GroupConfigurationRevision { + pub id: String, + pub field_id: String, + pub field_type_rev: FieldTypeRevision, + pub content: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] -pub struct GridFilterRevision { +pub struct FilterConfigurationRevision { pub id: String, pub field_id: String, pub condition: u8, pub content: Option, } - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct GridGroupRevision { - pub id: String, - pub field_id: Option, - pub sub_field_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -pub struct GridSortRevision { - pub id: String, - pub field_id: Option, -} diff --git a/shared-lib/flowy-grid-data-model/src/revision/grid_view.rs b/shared-lib/flowy-grid-data-model/src/revision/grid_view.rs new file mode 100644 index 0000000000..2fcc2fc4eb --- /dev/null +++ b/shared-lib/flowy-grid-data-model/src/revision/grid_view.rs @@ -0,0 +1,38 @@ +use crate::revision::SettingRevision; +use nanoid::nanoid; +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +pub fn gen_grid_view_id() -> String { + nanoid!(6) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GridViewRevision { + pub view_id: String, + + pub grid_id: String, + + pub setting: SettingRevision, + + // For the moment, we just use the order returned from the GridRevision + #[allow(dead_code)] + #[serde(skip, rename = "row")] + pub row_orders: Vec, +} + +impl GridViewRevision { + pub fn new(grid_id: String, view_id: String) -> Self { + GridViewRevision { + view_id, + grid_id, + setting: Default::default(), + row_orders: vec![], + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RowOrderRevision { + pub row_id: String, +} diff --git a/shared-lib/flowy-grid-data-model/src/revision/mod.rs b/shared-lib/flowy-grid-data-model/src/revision/mod.rs index a6581ad181..7ea98d78e3 100644 --- a/shared-lib/flowy-grid-data-model/src/revision/mod.rs +++ b/shared-lib/flowy-grid-data-model/src/revision/mod.rs @@ -1,5 +1,9 @@ +mod grid_block; mod grid_rev; mod grid_setting_rev; +mod grid_view; +pub use grid_block::*; pub use grid_rev::*; pub use grid_setting_rev::*; +pub use grid_view::*; diff --git a/shared-lib/flowy-grid-data-model/tests/serde_test.rs b/shared-lib/flowy-grid-data-model/tests/serde_test.rs index 1dff913547..b544e10588 100644 --- a/shared-lib/flowy-grid-data-model/tests/serde_test.rs +++ b/shared-lib/flowy-grid-data-model/tests/serde_test.rs @@ -6,8 +6,5 @@ fn grid_default_serde_test() { let grid = GridRevision::new(&grid_id); let json = serde_json::to_string(&grid).unwrap(); - assert_eq!( - json, - r#"{"grid_id":"1","fields":[],"blocks":[],"setting":{"layout":0,"filters":[]}}"# - ) + assert_eq!(json, r#"{"grid_id":"1","fields":[],"blocks":[]}"#) } diff --git a/shared-lib/flowy-sync/src/client_folder/builder.rs b/shared-lib/flowy-sync/src/client_folder/builder.rs index 3855d62834..6581591c25 100644 --- a/shared-lib/flowy-sync/src/client_folder/builder.rs +++ b/shared-lib/flowy-sync/src/client_folder/builder.rs @@ -1,20 +1,19 @@ use crate::entities::folder::FolderDelta; -use crate::util::make_delta_from_revisions; +use crate::util::make_text_delta_from_revisions; use crate::{ client_folder::{default_folder_delta, FolderPad}, entities::revision::Revision, - errors::{CollaborateError, CollaborateResult}, + errors::CollaborateResult, }; use flowy_folder_data_model::revision::{TrashRevision, WorkspaceRevision}; -use lib_ot::core::{PhantomAttributes, TextDelta, TextDeltaBuilder}; + use serde::{Deserialize, Serialize}; -use std::sync::Arc; #[derive(Serialize, Deserialize)] pub(crate) struct FolderPadBuilder { - workspaces: Vec>, - trash: Vec>, + workspaces: Vec, + trash: Vec, } impl FolderPadBuilder { @@ -25,43 +24,28 @@ impl FolderPadBuilder { } } + #[allow(dead_code)] pub(crate) fn with_workspace(mut self, workspaces: Vec) -> Self { - self.workspaces = workspaces.into_iter().map(Arc::new).collect(); + self.workspaces = workspaces; self } + #[allow(dead_code)] pub(crate) fn with_trash(mut self, trash: Vec) -> Self { - self.trash = trash.into_iter().map(Arc::new).collect::>(); + self.trash = trash; self } - pub(crate) fn build_with_delta(self, mut delta: TextDelta) -> CollaborateResult { - if delta.is_empty() { - delta = default_folder_delta(); - } - - // TODO: Reconvert from history if delta.to_str() failed. - let content = delta.content()?; - let mut folder: FolderPad = serde_json::from_str(&content).map_err(|e| { - tracing::error!("Deserialize folder from {} failed", content); - return CollaborateError::internal().context(format!("Deserialize delta to folder failed: {}", e)); - })?; - folder.delta = delta; - Ok(folder) - } - pub(crate) fn build_with_revisions(self, revisions: Vec) -> CollaborateResult { - let folder_delta: FolderDelta = make_delta_from_revisions::(revisions)?; - self.build_with_delta(folder_delta) + let mut folder_delta: FolderDelta = make_text_delta_from_revisions(revisions)?; + if folder_delta.is_empty() { + folder_delta = default_folder_delta(); + } + FolderPad::from_delta(folder_delta) } + #[allow(dead_code)] pub(crate) fn build(self) -> CollaborateResult { - let json = serde_json::to_string(&self) - .map_err(|e| CollaborateError::internal().context(format!("Serialize to folder json str failed: {}", e)))?; - Ok(FolderPad { - workspaces: self.workspaces, - trash: self.trash, - delta: TextDeltaBuilder::new().insert(&json).build(), - }) + FolderPad::new(self.workspaces, self.trash) } } diff --git a/shared-lib/flowy-sync/src/client_folder/folder_pad.rs b/shared-lib/flowy-sync/src/client_folder/folder_pad.rs index 5927be2c34..22296f95d8 100644 --- a/shared-lib/flowy-sync/src/client_folder/folder_pad.rs +++ b/shared-lib/flowy-sync/src/client_folder/folder_pad.rs @@ -8,26 +8,33 @@ use crate::{ }, errors::{CollaborateError, CollaborateResult}, }; -use flowy_folder_data_model::revision::{AppRevision, TrashRevision, ViewRevision, WorkspaceRevision}; +use flowy_folder_data_model::revision::{AppRevision, FolderRevision, TrashRevision, ViewRevision, WorkspaceRevision}; use lib_infra::util::move_vec_element; use lib_ot::core::*; -use serde::{Deserialize, Serialize}; + use std::sync::Arc; -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct FolderPad { - pub(crate) workspaces: Vec>, - pub(crate) trash: Vec>, - #[serde(skip)] - pub(crate) delta: FolderDelta, + folder_rev: FolderRevision, + delta: FolderDelta, } impl FolderPad { pub fn new(workspaces: Vec, trash: Vec) -> CollaborateResult { - FolderPadBuilder::new() - .with_workspace(workspaces) - .with_trash(trash) - .build() + let folder_rev = FolderRevision { + workspaces: workspaces.into_iter().map(Arc::new).collect(), + trash: trash.into_iter().map(Arc::new).collect(), + }; + Self::from_folder_rev(folder_rev) + } + + pub fn from_folder_rev(folder_rev: FolderRevision) -> CollaborateResult { + let json = serde_json::to_string(&folder_rev) + .map_err(|e| CollaborateError::internal().context(format!("Serialize to folder json str failed: {}", e)))?; + let delta = TextDeltaBuilder::new().insert(&json).build(); + + Ok(Self { folder_rev, delta }) } pub fn from_revisions(revisions: Vec) -> CollaborateResult { @@ -35,7 +42,14 @@ impl FolderPad { } pub fn from_delta(delta: FolderDelta) -> CollaborateResult { - FolderPadBuilder::new().build_with_delta(delta) + // TODO: Reconvert from history if delta.to_str() failed. + let content = delta.content()?; + let folder_rev: FolderRevision = serde_json::from_str(&content).map_err(|e| { + tracing::error!("Deserialize folder from {} failed", content); + return CollaborateError::internal().context(format!("Deserialize delta to folder failed: {}", e)); + })?; + + Ok(Self { folder_rev, delta }) } pub fn delta(&self) -> &FolderDelta { @@ -44,8 +58,7 @@ impl FolderPad { pub fn reset_folder(&mut self, delta: FolderDelta) -> CollaborateResult { let folder = FolderPad::from_delta(delta)?; - self.workspaces = folder.workspaces; - self.trash = folder.trash; + self.folder_rev = folder.folder_rev; self.delta = folder.delta; Ok(self.md5()) @@ -57,13 +70,13 @@ impl FolderPad { } pub fn is_empty(&self) -> bool { - self.workspaces.is_empty() && self.trash.is_empty() + self.folder_rev.workspaces.is_empty() && self.folder_rev.trash.is_empty() } #[tracing::instrument(level = "trace", skip(self, workspace_rev), fields(workspace_name=%workspace_rev.name), err)] - pub fn create_workspace(&mut self, workspace_rev: WorkspaceRevision) -> CollaborateResult> { + pub fn create_workspace(&mut self, workspace_rev: WorkspaceRevision) -> CollaborateResult> { let workspace = Arc::new(workspace_rev); - if self.workspaces.contains(&workspace) { + if self.folder_rev.workspaces.contains(&workspace) { tracing::warn!("[RootFolder]: Duplicate workspace"); return Ok(None); } @@ -79,7 +92,7 @@ impl FolderPad { workspace_id: &str, name: Option, desc: Option, - ) -> CollaborateResult> { + ) -> CollaborateResult> { self.with_workspace(workspace_id, |workspace| { if let Some(name) = name { workspace.name = name; @@ -96,6 +109,7 @@ impl FolderPad { match workspace_id { None => { let workspaces = self + .folder_rev .workspaces .iter() .map(|workspace| workspace.as_ref().clone()) @@ -103,7 +117,12 @@ impl FolderPad { Ok(workspaces) } Some(workspace_id) => { - if let Some(workspace) = self.workspaces.iter().find(|workspace| workspace.id == workspace_id) { + if let Some(workspace) = self + .folder_rev + .workspaces + .iter() + .find(|workspace| workspace.id == workspace_id) + { Ok(vec![workspace.as_ref().clone()]) } else { Err(CollaborateError::record_not_found() @@ -114,7 +133,7 @@ impl FolderPad { } #[tracing::instrument(level = "trace", skip(self), err)] - pub fn delete_workspace(&mut self, workspace_id: &str) -> CollaborateResult> { + pub fn delete_workspace(&mut self, workspace_id: &str) -> CollaborateResult> { self.modify_workspaces(|workspaces| { workspaces.retain(|w| w.id != workspace_id); Ok(Some(())) @@ -122,7 +141,7 @@ impl FolderPad { } #[tracing::instrument(level = "trace", skip(self), fields(app_name=%app_rev.name), err)] - pub fn create_app(&mut self, app_rev: AppRevision) -> CollaborateResult> { + pub fn create_app(&mut self, app_rev: AppRevision) -> CollaborateResult> { let workspace_id = app_rev.workspace_id.clone(); self.with_workspace(&workspace_id, move |workspace| { if workspace.apps.contains(&app_rev) { @@ -135,7 +154,7 @@ impl FolderPad { } pub fn read_app(&self, app_id: &str) -> CollaborateResult { - for workspace in &self.workspaces { + for workspace in &self.folder_rev.workspaces { if let Some(app) = workspace.apps.iter().find(|app| app.id == app_id) { return Ok(app.clone()); } @@ -148,7 +167,7 @@ impl FolderPad { app_id: &str, name: Option, desc: Option, - ) -> CollaborateResult> { + ) -> CollaborateResult> { self.with_app(app_id, move |app| { if let Some(name) = name { app.name = name; @@ -162,7 +181,7 @@ impl FolderPad { } #[tracing::instrument(level = "trace", skip(self), err)] - pub fn delete_app(&mut self, app_id: &str) -> CollaborateResult> { + pub fn delete_app(&mut self, app_id: &str) -> CollaborateResult> { let app = self.read_app(app_id)?; self.with_workspace(&app.workspace_id, |workspace| { workspace.apps.retain(|app| app.id != app_id); @@ -171,7 +190,7 @@ impl FolderPad { } #[tracing::instrument(level = "trace", skip(self), err)] - pub fn move_app(&mut self, app_id: &str, from: usize, to: usize) -> CollaborateResult> { + pub fn move_app(&mut self, app_id: &str, from: usize, to: usize) -> CollaborateResult> { let app = self.read_app(app_id)?; self.with_workspace(&app.workspace_id, |workspace| { match move_vec_element(&mut workspace.apps, |app| app.id == app_id, from, to).map_err(internal_error)? { @@ -182,8 +201,8 @@ impl FolderPad { } #[tracing::instrument(level = "trace", skip(self), fields(view_name=%view_rev.name), err)] - pub fn create_view(&mut self, view_rev: ViewRevision) -> CollaborateResult> { - let app_id = view_rev.belong_to_id.clone(); + pub fn create_view(&mut self, view_rev: ViewRevision) -> CollaborateResult> { + let app_id = view_rev.app_id.clone(); self.with_app(&app_id, move |app| { if app.belongings.contains(&view_rev) { tracing::warn!("[RootFolder]: Duplicate view"); @@ -195,7 +214,7 @@ impl FolderPad { } pub fn read_view(&self, view_id: &str) -> CollaborateResult { - for workspace in &self.workspaces { + for workspace in &self.folder_rev.workspaces { for app in &(*workspace.apps) { if let Some(view) = app.belongings.iter().find(|b| b.id == view_id) { return Ok(view.clone()); @@ -206,7 +225,7 @@ impl FolderPad { } pub fn read_views(&self, belong_to_id: &str) -> CollaborateResult> { - for workspace in &self.workspaces { + for workspace in &self.folder_rev.workspaces { for app in &(*workspace.apps) { if app.id == belong_to_id { return Ok(app.belongings.to_vec()); @@ -222,9 +241,9 @@ impl FolderPad { name: Option, desc: Option, modified_time: i64, - ) -> CollaborateResult> { + ) -> CollaborateResult> { let view = self.read_view(view_id)?; - self.with_view(&view.belong_to_id, view_id, |view| { + self.with_view(&view.app_id, view_id, |view| { if let Some(name) = name { view.name = name; } @@ -239,18 +258,18 @@ impl FolderPad { } #[tracing::instrument(level = "trace", skip(self), err)] - pub fn delete_view(&mut self, view_id: &str) -> CollaborateResult> { + pub fn delete_view(&mut self, view_id: &str) -> CollaborateResult> { let view = self.read_view(view_id)?; - self.with_app(&view.belong_to_id, |app| { + self.with_app(&view.app_id, |app| { app.belongings.retain(|view| view.id != view_id); Ok(Some(())) }) } #[tracing::instrument(level = "trace", skip(self), err)] - pub fn move_view(&mut self, view_id: &str, from: usize, to: usize) -> CollaborateResult> { + pub fn move_view(&mut self, view_id: &str, from: usize, to: usize) -> CollaborateResult> { let view = self.read_view(view_id)?; - self.with_app(&view.belong_to_id, |app| { + self.with_app(&view.app_id, |app| { match move_vec_element(&mut app.belongings, |view| view.id == view_id, from, to).map_err(internal_error)? { true => Ok(Some(())), false => Ok(None), @@ -258,7 +277,7 @@ impl FolderPad { }) } - pub fn create_trash(&mut self, trash: Vec) -> CollaborateResult> { + pub fn create_trash(&mut self, trash: Vec) -> CollaborateResult> { self.with_trash(|t| { let mut new_trash = trash.into_iter().map(Arc::new).collect::>>(); t.append(&mut new_trash); @@ -270,18 +289,19 @@ impl FolderPad { pub fn read_trash(&self, trash_id: Option) -> CollaborateResult> { match trash_id { None => Ok(self + .folder_rev .trash .iter() .map(|t| t.as_ref().clone()) .collect::>()), - Some(trash_id) => match self.trash.iter().find(|t| t.id == trash_id) { + Some(trash_id) => match self.folder_rev.trash.iter().find(|t| t.id == trash_id) { Some(trash) => Ok(vec![trash.as_ref().clone()]), None => Ok(vec![]), }, } } - pub fn delete_trash(&mut self, trash_ids: Option>) -> CollaborateResult> { + pub fn delete_trash(&mut self, trash_ids: Option>) -> CollaborateResult> { match trash_ids { None => self.with_trash(|trash| { trash.clear(); @@ -299,18 +319,23 @@ impl FolderPad { } pub fn to_json(&self) -> CollaborateResult { - serde_json::to_string(self) - .map_err(|e| CollaborateError::internal().context(format!("serial trash to json failed: {}", e))) + make_folder_rev_json_str(&self.folder_rev) } } +pub fn make_folder_rev_json_str(folder_rev: &FolderRevision) -> CollaborateResult { + let json = serde_json::to_string(folder_rev) + .map_err(|err| internal_error(format!("Serialize folder to json str failed. {:?}", err)))?; + Ok(json) +} + impl FolderPad { - fn modify_workspaces(&mut self, f: F) -> CollaborateResult> + fn modify_workspaces(&mut self, f: F) -> CollaborateResult> where F: FnOnce(&mut Vec>) -> CollaborateResult>, { let cloned_self = self.clone(); - match f(&mut self.workspaces)? { + match f(&mut self.folder_rev.workspaces)? { None => Ok(None), Some(_) => { let old = cloned_self.to_json()?; @@ -319,14 +344,14 @@ impl FolderPad { None => Ok(None), Some(delta) => { self.delta = self.delta.compose(&delta)?; - Ok(Some(FolderChange { delta, md5: self.md5() })) + Ok(Some(FolderChangeset { delta, md5: self.md5() })) } } } } } - fn with_workspace(&mut self, workspace_id: &str, f: F) -> CollaborateResult> + fn with_workspace(&mut self, workspace_id: &str, f: F) -> CollaborateResult> where F: FnOnce(&mut WorkspaceRevision) -> CollaborateResult>, { @@ -340,12 +365,12 @@ impl FolderPad { }) } - fn with_trash(&mut self, f: F) -> CollaborateResult> + fn with_trash(&mut self, f: F) -> CollaborateResult> where F: FnOnce(&mut Vec>) -> CollaborateResult>, { let cloned_self = self.clone(); - match f(&mut self.trash)? { + match f(&mut self.folder_rev.trash)? { None => Ok(None), Some(_) => { let old = cloned_self.to_json()?; @@ -354,18 +379,19 @@ impl FolderPad { None => Ok(None), Some(delta) => { self.delta = self.delta.compose(&delta)?; - Ok(Some(FolderChange { delta, md5: self.md5() })) + Ok(Some(FolderChangeset { delta, md5: self.md5() })) } } } } } - fn with_app(&mut self, app_id: &str, f: F) -> CollaborateResult> + fn with_app(&mut self, app_id: &str, f: F) -> CollaborateResult> where F: FnOnce(&mut AppRevision) -> CollaborateResult>, { let workspace_id = match self + .folder_rev .workspaces .iter() .find(|workspace| workspace.apps.iter().any(|app| app.id == app_id)) @@ -383,7 +409,7 @@ impl FolderPad { }) } - fn with_view(&mut self, belong_to_id: &str, view_id: &str, f: F) -> CollaborateResult> + fn with_view(&mut self, belong_to_id: &str, view_id: &str, f: F) -> CollaborateResult> where F: FnOnce(&mut ViewRevision) -> CollaborateResult>, { @@ -414,14 +440,13 @@ pub fn initial_folder_delta(folder_pad: &FolderPad) -> CollaborateResult Self { FolderPad { - workspaces: vec![], - trash: vec![], + folder_rev: FolderRevision::default(), delta: default_folder_delta(), } } } -pub struct FolderChange { +pub struct FolderChangeset { pub delta: FolderDelta, /// md5: the md5 of the FolderPad's delta after applying the change. pub md5: String, @@ -433,7 +458,9 @@ mod tests { use crate::{client_folder::folder_pad::FolderPad, entities::folder::FolderDelta}; use chrono::Utc; - use flowy_folder_data_model::revision::{AppRevision, TrashRevision, ViewRevision, WorkspaceRevision}; + use flowy_folder_data_model::revision::{ + AppRevision, FolderRevision, TrashRevision, ViewRevision, WorkspaceRevision, + }; use lib_ot::core::{OperationTransform, TextDelta, TextDeltaBuilder}; #[test] @@ -747,14 +774,16 @@ mod tests { } fn test_folder() -> (FolderPad, FolderDelta, WorkspaceRevision) { - let mut folder = FolderPad::default(); - let folder_json = serde_json::to_string(&folder).unwrap(); + let folder_rev = FolderRevision::default(); + let folder_json = serde_json::to_string(&folder_rev).unwrap(); let mut delta = TextDeltaBuilder::new().insert(&folder_json).build(); let mut workspace_rev = WorkspaceRevision::default(); workspace_rev.name = "😁 my first workspace".to_owned(); workspace_rev.id = "1".to_owned(); + let mut folder = FolderPad::from_folder_rev(folder_rev).unwrap(); + delta = delta .compose(&folder.create_workspace(workspace_rev.clone()).unwrap().unwrap().delta) .unwrap(); @@ -763,22 +792,22 @@ mod tests { } fn test_app_folder() -> (FolderPad, FolderDelta, AppRevision) { - let (mut folder, mut initial_delta, workspace) = test_folder(); + let (mut folder_rev, mut initial_delta, workspace) = test_folder(); let mut app_rev = AppRevision::default(); app_rev.workspace_id = workspace.id; app_rev.name = "😁 my first app".to_owned(); initial_delta = initial_delta - .compose(&folder.create_app(app_rev.clone()).unwrap().unwrap().delta) + .compose(&folder_rev.create_app(app_rev.clone()).unwrap().unwrap().delta) .unwrap(); - (folder, initial_delta, app_rev) + (folder_rev, initial_delta, app_rev) } fn test_view_folder() -> (FolderPad, FolderDelta, ViewRevision) { let (mut folder, mut initial_delta, app) = test_app_folder(); let mut view_rev = ViewRevision::default(); - view_rev.belong_to_id = app.id.clone(); + view_rev.app_id = app.id.clone(); view_rev.name = "🎃 my first view".to_owned(); initial_delta = initial_delta @@ -789,14 +818,14 @@ mod tests { } fn test_trash() -> (FolderPad, FolderDelta, TrashRevision) { - let mut folder = FolderPad::default(); - let folder_json = serde_json::to_string(&folder).unwrap(); + let folder_rev = FolderRevision::default(); + let folder_json = serde_json::to_string(&folder_rev).unwrap(); let mut delta = TextDeltaBuilder::new().insert(&folder_json).build(); let mut trash_rev = TrashRevision::default(); trash_rev.name = "🚽 my first trash".to_owned(); trash_rev.id = "1".to_owned(); - + let mut folder = FolderPad::from_folder_rev(folder_rev).unwrap(); delta = delta .compose( &folder @@ -823,8 +852,11 @@ mod tests { let json1 = old.to_json().unwrap(); let json2 = new.to_json().unwrap(); - let expect_folder: FolderPad = serde_json::from_str(expected).unwrap(); - assert_eq!(json1, expect_folder.to_json().unwrap()); + // format the json str + let folder_rev: FolderRevision = serde_json::from_str(expected).unwrap(); + let expected = serde_json::to_string(&folder_rev).unwrap(); + + assert_eq!(json1, expected); assert_eq!(json1, json2); } } diff --git a/shared-lib/flowy-sync/src/client_grid/grid_block_revsion_pad.rs b/shared-lib/flowy-sync/src/client_grid/block_revision_pad.rs similarity index 79% rename from shared-lib/flowy-sync/src/client_grid/grid_block_revsion_pad.rs rename to shared-lib/flowy-sync/src/client_grid/block_revision_pad.rs index 51a331ecf7..edb6ef37e5 100644 --- a/shared-lib/flowy-sync/src/client_grid/grid_block_revsion_pad.rs +++ b/shared-lib/flowy-sync/src/client_grid/block_revision_pad.rs @@ -1,35 +1,32 @@ use crate::entities::revision::{md5, RepeatedRevision, Revision}; use crate::errors::{CollaborateError, CollaborateResult}; -use crate::util::{cal_diff, make_delta_from_revisions}; +use crate::util::{cal_diff, make_text_delta_from_revisions}; use flowy_grid_data_model::revision::{ - gen_block_id, gen_row_id, CellRevision, GridBlockRevision, RowMetaChangeset, RowRevision, + gen_block_id, gen_row_id, CellRevision, GridBlockRevision, RowChangeset, RowRevision, }; use lib_ot::core::{OperationTransform, PhantomAttributes, TextDelta, TextDeltaBuilder}; use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; -pub type GridBlockRevisionDelta = TextDelta; -pub type GridBlockRevisionDeltaBuilder = TextDeltaBuilder; - #[derive(Debug, Clone)] pub struct GridBlockRevisionPad { - block_revision: GridBlockRevision, - pub(crate) delta: GridBlockRevisionDelta, + block: GridBlockRevision, + delta: TextDelta, } impl std::ops::Deref for GridBlockRevisionPad { type Target = GridBlockRevision; fn deref(&self) -> &Self::Target { - &self.block_revision + &self.block } } impl GridBlockRevisionPad { pub async fn duplicate_data(&self, duplicated_block_id: &str) -> GridBlockRevision { let duplicated_rows = self - .block_revision + .block .rows .iter() .map(|row| { @@ -45,18 +42,18 @@ impl GridBlockRevisionPad { } } - pub fn from_delta(delta: GridBlockRevisionDelta) -> CollaborateResult { + pub fn from_delta(delta: TextDelta) -> CollaborateResult { let s = delta.content()?; - let block_revision: GridBlockRevision = serde_json::from_str(&s).map_err(|e| { - let msg = format!("Deserialize delta to block meta failed: {}", e); + let revision: GridBlockRevision = serde_json::from_str(&s).map_err(|e| { + let msg = format!("Deserialize delta to GridBlockRevision failed: {}", e); tracing::error!("{}", s); CollaborateError::internal().context(msg) })?; - Ok(Self { block_revision, delta }) + Ok(Self { block: revision, delta }) } pub fn from_revisions(_grid_id: &str, revisions: Vec) -> CollaborateResult { - let block_delta: GridBlockRevisionDelta = make_delta_from_revisions::(revisions)?; + let block_delta: TextDelta = make_text_delta_from_revisions(revisions)?; Self::from_delta(block_delta) } @@ -65,7 +62,7 @@ impl GridBlockRevisionPad { &mut self, row: RowRevision, start_row_id: Option, - ) -> CollaborateResult> { + ) -> CollaborateResult> { self.modify(|rows| { if let Some(start_row_id) = start_row_id { if !start_row_id.is_empty() { @@ -81,7 +78,10 @@ impl GridBlockRevisionPad { }) } - pub fn delete_rows(&mut self, row_ids: Vec>) -> CollaborateResult> { + pub fn delete_rows( + &mut self, + row_ids: Vec>, + ) -> CollaborateResult> { self.modify(|rows| { rows.retain(|row| !row_ids.contains(&Cow::Borrowed(&row.id))); Ok(Some(())) @@ -93,10 +93,10 @@ impl GridBlockRevisionPad { T: AsRef + ToOwned + ?Sized, { match row_ids { - None => Ok(self.block_revision.rows.clone()), + None => Ok(self.block.rows.clone()), Some(row_ids) => { let row_map = self - .block_revision + .block .rows .iter() .map(|row| (row.id.as_str(), row.clone())) @@ -136,18 +136,14 @@ impl GridBlockRevisionPad { } pub fn number_of_rows(&self) -> i32 { - self.block_revision.rows.len() as i32 + self.block.rows.len() as i32 } - pub fn index_of_row(&self, row_id: &str) -> Option { - self.block_revision - .rows - .iter() - .position(|row| row.id == row_id) - .map(|index| index as i32) + pub fn index_of_row(&self, row_id: &str) -> Option { + self.block.rows.iter().position(|row| row.id == row_id) } - pub fn update_row(&mut self, changeset: RowMetaChangeset) -> CollaborateResult> { + pub fn update_row(&mut self, changeset: RowChangeset) -> CollaborateResult> { let row_id = changeset.row_id.clone(); self.modify_row(&row_id, |row| { let mut is_changed = None; @@ -172,46 +168,54 @@ impl GridBlockRevisionPad { }) } - pub fn move_row(&mut self, row_id: &str, from: usize, to: usize) -> CollaborateResult> { + pub fn move_row( + &mut self, + row_id: &str, + from: usize, + to: usize, + ) -> CollaborateResult> { self.modify(|row_revs| { if let Some(position) = row_revs.iter().position(|row_rev| row_rev.id == row_id) { debug_assert_eq!(from, position); let row_rev = row_revs.remove(position); - row_revs.insert(to, row_rev); - Ok(Some(())) + if to > row_revs.len() { + Err(CollaborateError::out_of_bound()) + } else { + row_revs.insert(to, row_rev); + Ok(Some(())) + } } else { Ok(None) } }) } - pub fn modify(&mut self, f: F) -> CollaborateResult> + pub fn modify(&mut self, f: F) -> CollaborateResult> where F: for<'a> FnOnce(&'a mut Vec>) -> CollaborateResult>, { let cloned_self = self.clone(); - match f(&mut self.block_revision.rows)? { + match f(&mut self.block.rows)? { None => Ok(None), Some(_) => { - let old = cloned_self.to_json()?; - let new = self.to_json()?; + let old = cloned_self.revision_json()?; + let new = self.revision_json()?; match cal_diff::(old, new) { None => Ok(None), Some(delta) => { - tracing::trace!("[GridBlockMeta] Composing delta {}", delta.json_str()); - // tracing::debug!( - // "[GridBlockMeta] current delta: {}", - // self.delta.to_str().unwrap_or_else(|_| "".to_string()) - // ); + tracing::trace!("[GridBlockRevision] Composing delta {}", delta.json_str()); self.delta = self.delta.compose(&delta)?; - Ok(Some(GridBlockMetaChange { delta, md5: self.md5() })) + Ok(Some(GridBlockRevisionChangeset { + delta, + md5: md5(&self.delta.json_bytes()), + })) } } } } } - fn modify_row(&mut self, row_id: &str, f: F) -> CollaborateResult> + fn modify_row(&mut self, row_id: &str, f: F) -> CollaborateResult> where F: FnOnce(&mut RowRevision) -> CollaborateResult>, { @@ -225,27 +229,23 @@ impl GridBlockRevisionPad { }) } - pub fn to_json(&self) -> CollaborateResult { - serde_json::to_string(&self.block_revision) - .map_err(|e| CollaborateError::internal().context(format!("serial trash to json failed: {}", e))) + pub fn revision_json(&self) -> CollaborateResult { + serde_json::to_string(&self.block) + .map_err(|e| CollaborateError::internal().context(format!("serial block to json failed: {}", e))) } - pub fn md5(&self) -> String { - md5(&self.delta.json_bytes()) - } - - pub fn delta_str(&self) -> String { + pub fn json_str(&self) -> String { self.delta.json_str() } } -pub struct GridBlockMetaChange { - pub delta: GridBlockRevisionDelta, +pub struct GridBlockRevisionChangeset { + pub delta: TextDelta, /// md5: the md5 of the grid after applying the change. pub md5: String, } -pub fn make_grid_block_delta(block_rev: &GridBlockRevision) -> GridBlockRevisionDelta { +pub fn make_grid_block_delta(block_rev: &GridBlockRevision) -> TextDelta { let json = serde_json::to_string(&block_rev).unwrap(); TextDeltaBuilder::new().insert(&json).build() } @@ -265,14 +265,18 @@ impl std::default::Default for GridBlockRevisionPad { }; let delta = make_grid_block_delta(&block_revision); - GridBlockRevisionPad { block_revision, delta } + GridBlockRevisionPad { + block: block_revision, + delta, + } } } #[cfg(test)] mod tests { - use crate::client_grid::{GridBlockRevisionDelta, GridBlockRevisionPad}; - use flowy_grid_data_model::revision::{RowMetaChangeset, RowRevision}; + use crate::client_grid::GridBlockRevisionPad; + use flowy_grid_data_model::revision::{RowChangeset, RowRevision}; + use lib_ot::core::TextDelta; use std::borrow::Cow; #[test] @@ -369,7 +373,7 @@ mod tests { #[test] fn block_meta_delete_row() { let mut pad = test_pad(); - let pre_delta_str = pad.delta_str(); + let pre_delta_str = pad.json_str(); let row = RowRevision { id: "1".to_string(), block_id: pad.block_id.clone(), @@ -382,7 +386,7 @@ mod tests { let change = pad.delete_rows(vec![Cow::Borrowed(&row.id)]).unwrap().unwrap(); assert_eq!(change.delta.json_str(), r#"[{"retain":24},{"delete":66},{"retain":2}]"#); - assert_eq!(pad.delta_str(), pre_delta_str); + assert_eq!(pad.json_str(), pre_delta_str); } #[test] @@ -396,7 +400,7 @@ mod tests { visibility: false, }; - let changeset = RowMetaChangeset { + let changeset = RowChangeset { row_id: row.id.clone(), height: Some(100), visibility: Some(true), @@ -412,13 +416,13 @@ mod tests { ); assert_eq!( - pad.to_json().unwrap(), + pad.revision_json().unwrap(), r#"{"block_id":"1","rows":[{"id":"1","block_id":"1","cells":[],"height":100,"visibility":true}]}"# ); } fn test_pad() -> GridBlockRevisionPad { - let delta = GridBlockRevisionDelta::from_json(r#"[{"insert":"{\"block_id\":\"1\",\"rows\":[]}"}]"#).unwrap(); + let delta = TextDelta::from_json(r#"[{"insert":"{\"block_id\":\"1\",\"rows\":[]}"}]"#).unwrap(); GridBlockRevisionPad::from_delta(delta).unwrap() } } diff --git a/shared-lib/flowy-sync/src/client_grid/grid_builder.rs b/shared-lib/flowy-sync/src/client_grid/grid_builder.rs index ea5d5a9332..ad3f443505 100644 --- a/shared-lib/flowy-sync/src/client_grid/grid_builder.rs +++ b/shared-lib/flowy-sync/src/client_grid/grid_builder.rs @@ -18,8 +18,8 @@ impl std::default::Default for GridBuilder { rows: vec![], }; - build_context.blocks.push(block_meta); - build_context.blocks_meta_data.push(block_meta_data); + build_context.block_metas.push(block_meta); + build_context.blocks.push(block_meta_data); GridBuilder { build_context } } @@ -34,8 +34,8 @@ impl GridBuilder { } pub fn add_row(&mut self, row_rev: RowRevision) { - let block_meta_rev = self.build_context.blocks.first_mut().unwrap(); - let block_rev = self.build_context.blocks_meta_data.first_mut().unwrap(); + let block_meta_rev = self.build_context.block_metas.first_mut().unwrap(); + let block_rev = self.build_context.blocks.first_mut().unwrap(); block_rev.rows.push(Arc::new(row_rev)); block_meta_rev.row_count += 1; } @@ -50,7 +50,7 @@ impl GridBuilder { } pub fn block_id(&self) -> &str { - &self.build_context.blocks.first().unwrap().block_id + &self.build_context.block_metas.first().unwrap().block_id } pub fn build(self) -> BuildGridContext { diff --git a/shared-lib/flowy-sync/src/client_grid/grid_meta_pad.rs b/shared-lib/flowy-sync/src/client_grid/grid_meta_pad.rs deleted file mode 100644 index 3ef9251e4a..0000000000 --- a/shared-lib/flowy-sync/src/client_grid/grid_meta_pad.rs +++ /dev/null @@ -1,432 +0,0 @@ -use crate::entities::revision::{md5, RepeatedRevision, Revision}; -use crate::errors::{internal_error, CollaborateError, CollaborateResult}; -use crate::util::{cal_diff, make_delta_from_revisions}; -use bytes::Bytes; -use flowy_grid_data_model::entities::{ - gen_block_id, gen_grid_id, FieldChangesetParams, FieldMeta, FieldOrder, FieldType, GridBlockInfoChangeset, - GridBlockMetaSnapshot, GridMeta, -}; -use lib_infra::util::move_vec_element; -use lib_ot::core::{OperationTransformable, PlainTextAttributes, PlainTextDelta, PlainTextDeltaBuilder}; -use std::collections::HashMap; -use std::sync::Arc; - -pub type GridMetaDelta = PlainTextDelta; -pub type GridDeltaBuilder = PlainTextDeltaBuilder; - -pub struct GridMetaPad { - pub(crate) grid_meta: Arc, - pub(crate) delta: GridMetaDelta, -} - -pub trait JsonDeserializer { - fn deserialize(&self, type_option_data: Vec) -> CollaborateResult; -} - -impl GridMetaPad { - pub async fn duplicate_grid_meta(&self) -> (Vec, Vec) { - let fields = self.grid_meta.fields.to_vec(); - - let blocks = self - .grid_meta - .blocks - .iter() - .map(|block| { - let mut duplicated_block = block.clone(); - duplicated_block.block_id = gen_block_id(); - duplicated_block - }) - .collect::>(); - - (fields, blocks) - } - - pub fn from_delta(delta: GridMetaDelta) -> CollaborateResult { - let s = delta.to_str()?; - let grid: GridMeta = serde_json::from_str(&s) - .map_err(|e| CollaborateError::internal().context(format!("Deserialize delta to grid failed: {}", e)))?; - - Ok(Self { - grid_meta: Arc::new(grid), - delta, - }) - } - - pub fn from_revisions(_grid_id: &str, revisions: Vec) -> CollaborateResult { - let grid_delta: GridMetaDelta = make_delta_from_revisions::(revisions)?; - Self::from_delta(grid_delta) - } - - #[tracing::instrument(level = "debug", skip_all, err)] - pub fn create_field_meta( - &mut self, - new_field_meta: FieldMeta, - start_field_id: Option, - ) -> CollaborateResult> { - self.modify_grid(|grid_meta| { - // Check if the field exists or not - if grid_meta - .fields - .iter() - .any(|field_meta| field_meta.id == new_field_meta.id) - { - tracing::error!("Duplicate grid field"); - return Ok(None); - } - - let insert_index = match start_field_id { - None => None, - Some(start_field_id) => grid_meta.fields.iter().position(|field| field.id == start_field_id), - }; - - match insert_index { - None => grid_meta.fields.push(new_field_meta), - Some(index) => grid_meta.fields.insert(index, new_field_meta), - } - Ok(Some(())) - }) - } - - pub fn delete_field_meta(&mut self, field_id: &str) -> CollaborateResult> { - self.modify_grid( - |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_id) { - None => Ok(None), - Some(index) => { - grid_meta.fields.remove(index); - Ok(Some(())) - } - }, - ) - } - - pub fn duplicate_field_meta( - &mut self, - field_id: &str, - duplicated_field_id: &str, - ) -> CollaborateResult> { - self.modify_grid( - |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_id) { - None => Ok(None), - Some(index) => { - let mut duplicate_field_meta = grid_meta.fields[index].clone(); - duplicate_field_meta.id = duplicated_field_id.to_string(); - duplicate_field_meta.name = format!("{} (copy)", duplicate_field_meta.name); - grid_meta.fields.insert(index + 1, duplicate_field_meta); - Ok(Some(())) - } - }, - ) - } - - pub fn switch_to_field( - &mut self, - field_id: &str, - field_type: FieldType, - type_option_json_builder: B, - ) -> CollaborateResult> - where - B: FnOnce(&FieldType) -> String, - { - self.modify_grid(|grid_meta| { - // - match grid_meta.fields.iter_mut().find(|field_meta| field_meta.id == field_id) { - None => { - tracing::warn!("Can not find the field with id: {}", field_id); - Ok(None) - } - Some(field_meta) => { - if field_meta.get_type_option_str(&field_type).is_none() { - let type_option_json = type_option_json_builder(&field_type); - field_meta.insert_type_option_str(&field_type, type_option_json); - } - - field_meta.field_type = field_type; - Ok(Some(())) - } - } - }) - } - - pub fn update_field_meta( - &mut self, - changeset: FieldChangesetParams, - deserializer: T, - ) -> CollaborateResult> { - let field_id = changeset.field_id.clone(); - self.modify_field(&field_id, |field| { - let mut is_changed = None; - if let Some(name) = changeset.name { - field.name = name; - is_changed = Some(()) - } - - if let Some(desc) = changeset.desc { - field.desc = desc; - is_changed = Some(()) - } - - if let Some(field_type) = changeset.field_type { - field.field_type = field_type; - is_changed = Some(()) - } - - if let Some(frozen) = changeset.frozen { - field.frozen = frozen; - is_changed = Some(()) - } - - if let Some(visibility) = changeset.visibility { - field.visibility = visibility; - is_changed = Some(()) - } - - if let Some(width) = changeset.width { - field.width = width; - is_changed = Some(()) - } - - if let Some(type_option_data) = changeset.type_option_data { - match deserializer.deserialize(type_option_data) { - Ok(json_str) => { - let field_type = field.field_type.clone(); - field.insert_type_option_str(&field_type, json_str); - is_changed = Some(()) - } - Err(err) => { - tracing::error!("Deserialize data to type option json failed: {}", err); - } - } - } - - Ok(is_changed) - }) - } - - pub fn get_field_meta(&self, field_id: &str) -> Option<(usize, &FieldMeta)> { - self.grid_meta - .fields - .iter() - .enumerate() - .find(|(_, field)| field.id == field_id) - } - - pub fn replace_field_meta(&mut self, field_meta: FieldMeta) -> CollaborateResult> { - self.modify_grid( - |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_meta.id) { - None => Ok(None), - Some(index) => { - grid_meta.fields.remove(index); - grid_meta.fields.insert(index, field_meta); - Ok(Some(())) - } - }, - ) - } - - pub fn move_field( - &mut self, - field_id: &str, - from_index: usize, - to_index: usize, - ) -> CollaborateResult> { - self.modify_grid(|grid_meta| { - match move_vec_element( - &mut grid_meta.fields, - |field| field.id == field_id, - from_index, - to_index, - ) - .map_err(internal_error)? - { - true => Ok(Some(())), - false => Ok(None), - } - }) - } - - pub fn contain_field(&self, field_id: &str) -> bool { - self.grid_meta.fields.iter().any(|field| field.id == field_id) - } - - pub fn get_field_orders(&self) -> Vec { - self.grid_meta.fields.iter().map(FieldOrder::from).collect() - } - - pub fn get_field_metas(&self, field_orders: Option>) -> CollaborateResult> { - match field_orders { - None => Ok(self.grid_meta.fields.clone()), - Some(field_orders) => { - let field_by_field_id = self - .grid_meta - .fields - .iter() - .map(|field| (&field.id, field)) - .collect::>(); - - let fields = field_orders - .iter() - .flat_map(|field_order| match field_by_field_id.get(&field_order.field_id) { - None => { - tracing::error!("Can't find the field with id: {}", field_order.field_id); - None - } - Some(field) => Some((*field).clone()), - }) - .collect::>(); - Ok(fields) - } - } - } - - pub fn create_block_meta(&mut self, block: GridBlockMetaSnapshot) -> CollaborateResult> { - self.modify_grid(|grid_meta| { - if grid_meta.blocks.iter().any(|b| b.block_id == block.block_id) { - tracing::warn!("Duplicate grid block"); - Ok(None) - } else { - match grid_meta.blocks.last() { - None => grid_meta.blocks.push(block), - Some(last_block) => { - if last_block.start_row_index > block.start_row_index - && last_block.len() > block.start_row_index - { - let msg = "GridBlock's start_row_index should be greater than the last_block's start_row_index and its len".to_string(); - return Err(CollaborateError::internal().context(msg)) - } - grid_meta.blocks.push(block); - } - } - Ok(Some(())) - } - }) - } - - pub fn get_block_metas(&self) -> Vec { - self.grid_meta.blocks.clone() - } - - pub fn update_block_meta(&mut self, changeset: GridBlockInfoChangeset) -> CollaborateResult> { - let block_id = changeset.block_id.clone(); - self.modify_block(&block_id, |block| { - let mut is_changed = None; - - if let Some(row_count) = changeset.row_count { - block.row_count = row_count; - is_changed = Some(()); - } - - if let Some(start_row_index) = changeset.start_row_index { - block.start_row_index = start_row_index; - is_changed = Some(()); - } - - Ok(is_changed) - }) - } - - pub fn md5(&self) -> String { - md5(&self.delta.to_delta_bytes()) - } - - pub fn delta_str(&self) -> String { - self.delta.to_delta_str() - } - - pub fn delta_bytes(&self) -> Bytes { - self.delta.to_delta_bytes() - } - - pub fn fields(&self) -> &[FieldMeta] { - &self.grid_meta.fields - } - - fn modify_grid(&mut self, f: F) -> CollaborateResult> - where - F: FnOnce(&mut GridMeta) -> CollaborateResult>, - { - let cloned_grid = self.grid_meta.clone(); - match f(Arc::make_mut(&mut self.grid_meta))? { - None => Ok(None), - Some(_) => { - let old = json_from_grid(&cloned_grid)?; - let new = json_from_grid(&self.grid_meta)?; - match cal_diff::(old, new) { - None => Ok(None), - Some(delta) => { - self.delta = self.delta.compose(&delta)?; - Ok(Some(GridChangeset { delta, md5: self.md5() })) - } - } - } - } - } - - pub fn modify_block(&mut self, block_id: &str, f: F) -> CollaborateResult> - where - F: FnOnce(&mut GridBlockMetaSnapshot) -> CollaborateResult>, - { - self.modify_grid( - |grid_meta| match grid_meta.blocks.iter().position(|block| block.block_id == block_id) { - None => { - tracing::warn!("[GridMetaPad]: Can't find any block with id: {}", block_id); - Ok(None) - } - Some(index) => f(&mut grid_meta.blocks[index]), - }, - ) - } - - pub fn modify_field(&mut self, field_id: &str, f: F) -> CollaborateResult> - where - F: FnOnce(&mut FieldMeta) -> CollaborateResult>, - { - self.modify_grid( - |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_id) { - None => { - tracing::warn!("[GridMetaPad]: Can't find any field with id: {}", field_id); - Ok(None) - } - Some(index) => f(&mut grid_meta.fields[index]), - }, - ) - } -} - -fn json_from_grid(grid: &Arc) -> CollaborateResult { - let json = serde_json::to_string(grid) - .map_err(|err| internal_error(format!("Serialize grid to json str failed. {:?}", err)))?; - Ok(json) -} - -pub struct GridChangeset { - pub delta: GridMetaDelta, - /// md5: the md5 of the grid after applying the change. - pub md5: String, -} - -pub fn make_grid_delta(grid_meta: &GridMeta) -> GridMetaDelta { - let json = serde_json::to_string(&grid_meta).unwrap(); - PlainTextDeltaBuilder::new().insert(&json).build() -} - -pub fn make_grid_revisions(user_id: &str, grid_meta: &GridMeta) -> RepeatedRevision { - let delta = make_grid_delta(grid_meta); - let bytes = delta.to_delta_bytes(); - let revision = Revision::initial_revision(user_id, &grid_meta.grid_id, bytes); - revision.into() -} - -impl std::default::Default for GridMetaPad { - fn default() -> Self { - let grid = GridMeta { - grid_id: gen_grid_id(), - fields: vec![], - blocks: vec![], - }; - let delta = make_grid_delta(&grid); - GridMetaPad { - grid_meta: Arc::new(grid), - delta, - } - } -} diff --git a/shared-lib/flowy-sync/src/client_grid/grid_revision_pad.rs b/shared-lib/flowy-sync/src/client_grid/grid_revision_pad.rs index b0188066ef..aad9c8b5f3 100644 --- a/shared-lib/flowy-sync/src/client_grid/grid_revision_pad.rs +++ b/shared-lib/flowy-sync/src/client_grid/grid_revision_pad.rs @@ -1,12 +1,11 @@ -use crate::entities::grid::{FieldChangesetParams, GridSettingChangesetParams}; +use crate::entities::grid::FieldChangesetParams; use crate::entities::revision::{md5, RepeatedRevision, Revision}; use crate::errors::{internal_error, CollaborateError, CollaborateResult}; -use crate::util::{cal_diff, make_delta_from_revisions}; +use crate::util::{cal_diff, make_text_delta_from_revisions}; use bytes::Bytes; use flowy_grid_data_model::revision::{ - gen_block_id, gen_grid_filter_id, gen_grid_group_id, gen_grid_id, gen_grid_sort_id, FieldRevision, - FieldTypeRevision, GridBlockMetaRevision, GridBlockMetaRevisionChangeset, GridFilterRevision, GridGroupRevision, - GridLayoutRevision, GridRevision, GridSettingRevision, GridSortRevision, + gen_block_id, gen_grid_id, FieldRevision, FieldTypeRevision, GridBlockMetaRevision, GridBlockMetaRevisionChangeset, + GridRevision, }; use lib_infra::util::move_vec_element; use lib_ot::core::{OperationTransform, PhantomAttributes, TextDelta, TextDeltaBuilder}; @@ -42,7 +41,7 @@ impl GridRevisionPad { .blocks .iter() .map(|block| { - let mut duplicated_block = (&*block.clone()).clone(); + let mut duplicated_block = (&**block).clone(); duplicated_block.block_id = gen_block_id(); duplicated_block }) @@ -53,8 +52,11 @@ impl GridRevisionPad { pub fn from_delta(delta: GridRevisionDelta) -> CollaborateResult { let content = delta.content()?; - let grid: GridRevision = serde_json::from_str(&content) - .map_err(|e| CollaborateError::internal().context(format!("Deserialize delta to grid failed: {}", e)))?; + let grid: GridRevision = serde_json::from_str(&content).map_err(|e| { + let msg = format!("Deserialize delta to grid failed: {}", e); + tracing::error!("{}", msg); + CollaborateError::internal().context(msg) + })?; Ok(Self { grid_rev: Arc::new(grid), @@ -63,7 +65,7 @@ impl GridRevisionPad { } pub fn from_revisions(revisions: Vec) -> CollaborateResult { - let grid_delta: GridRevisionDelta = make_delta_from_revisions::(revisions)?; + let grid_delta: GridRevisionDelta = make_text_delta_from_revisions(revisions)?; Self::from_delta(grid_delta) } @@ -72,7 +74,7 @@ impl GridRevisionPad { &mut self, new_field_rev: FieldRevision, start_field_id: Option, - ) -> CollaborateResult> { + ) -> CollaborateResult> { self.modify_grid(|grid_meta| { // Check if the field exists or not if grid_meta @@ -97,7 +99,7 @@ impl GridRevisionPad { }) } - pub fn delete_field_rev(&mut self, field_id: &str) -> CollaborateResult> { + pub fn delete_field_rev(&mut self, field_id: &str) -> CollaborateResult> { self.modify_grid( |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_id) { None => Ok(None), @@ -113,7 +115,7 @@ impl GridRevisionPad { &mut self, field_id: &str, duplicated_field_id: &str, - ) -> CollaborateResult> { + ) -> CollaborateResult> { self.modify_grid( |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_id) { None => Ok(None), @@ -133,7 +135,7 @@ impl GridRevisionPad { field_id: &str, field_type: T, type_option_json_builder: B, - ) -> CollaborateResult> + ) -> CollaborateResult> where B: FnOnce(&FieldTypeRevision) -> String, T: Into, @@ -153,7 +155,7 @@ impl GridRevisionPad { mut_field_rev.insert_type_option_str(&field_type, type_option_json); } - mut_field_rev.field_type_rev = field_type; + mut_field_rev.ty = field_type; Ok(Some(())) } } @@ -164,7 +166,7 @@ impl GridRevisionPad { &mut self, changeset: FieldChangesetParams, deserializer: T, - ) -> CollaborateResult> { + ) -> CollaborateResult> { let field_id = changeset.field_id.clone(); self.modify_field(&field_id, |field| { let mut is_changed = None; @@ -179,7 +181,7 @@ impl GridRevisionPad { } if let Some(field_type) = changeset.field_type { - field.field_type_rev = field_type; + field.ty = field_type; is_changed = Some(()) } @@ -201,7 +203,7 @@ impl GridRevisionPad { if let Some(type_option_data) = changeset.type_option_data { match deserializer.deserialize(type_option_data) { Ok(json_str) => { - let field_type = field.field_type_rev; + let field_type = field.ty; field.insert_type_option_str(&field_type, json_str); is_changed = Some(()) } @@ -223,7 +225,10 @@ impl GridRevisionPad { .find(|(_, field)| field.id == field_id) } - pub fn replace_field_rev(&mut self, field_rev: Arc) -> CollaborateResult> { + pub fn replace_field_rev( + &mut self, + field_rev: Arc, + ) -> CollaborateResult> { self.modify_grid( |grid_meta| match grid_meta.fields.iter().position(|field| field.id == field_rev.id) { None => Ok(None), @@ -241,7 +246,7 @@ impl GridRevisionPad { field_id: &str, from_index: usize, to_index: usize, - ) -> CollaborateResult> { + ) -> CollaborateResult> { self.modify_grid(|grid_meta| { match move_vec_element( &mut grid_meta.fields, @@ -287,7 +292,10 @@ impl GridRevisionPad { } } - pub fn create_block_meta_rev(&mut self, block: GridBlockMetaRevision) -> CollaborateResult> { + pub fn create_block_meta_rev( + &mut self, + block: GridBlockMetaRevision, + ) -> CollaborateResult> { self.modify_grid(|grid_meta| { if grid_meta.blocks.iter().any(|b| b.block_id == block.block_id) { tracing::warn!("Duplicate grid block"); @@ -317,7 +325,7 @@ impl GridRevisionPad { pub fn update_block_rev( &mut self, changeset: GridBlockMetaRevisionChangeset, - ) -> CollaborateResult> { + ) -> CollaborateResult> { let block_id = changeset.block_id.clone(); self.modify_block(&block_id, |block| { let mut is_changed = None; @@ -336,126 +344,6 @@ impl GridRevisionPad { }) } - pub fn get_grid_setting_rev(&self) -> &GridSettingRevision { - &self.grid_rev.setting - } - - /// If layout is None, then the default layout will be the read from GridSettingRevision - pub fn get_filters( - &self, - layout: Option<&GridLayoutRevision>, - field_ids: Option>, - ) -> Option>> { - let mut filter_revs = vec![]; - let layout_ty = layout.unwrap_or(&self.grid_rev.setting.layout); - let field_revs = self.get_field_revs(None).ok()?; - - field_revs.iter().for_each(|field_rev| { - let mut is_contain = true; - if let Some(field_ids) = &field_ids { - is_contain = field_ids.contains(&field_rev.id); - } - - if is_contain { - // Only return the filters for the current fields' type. - let field_id = &field_rev.id; - let field_type_rev = &field_rev.field_type_rev; - if let Some(mut t_filter_revs) = self.grid_rev.setting.get_filters(layout_ty, field_id, field_type_rev) - { - filter_revs.append(&mut t_filter_revs); - } - } - }); - - Some(filter_revs) - } - - pub fn update_grid_setting_rev( - &mut self, - changeset: GridSettingChangesetParams, - ) -> CollaborateResult> { - self.modify_grid(|grid_rev| { - let mut is_changed = None; - let layout_rev = changeset.layout_type; - - if let Some(params) = changeset.insert_filter { - let filter_rev = GridFilterRevision { - id: gen_grid_filter_id(), - field_id: params.field_id.clone(), - condition: params.condition, - content: params.content, - }; - - grid_rev - .setting - .insert_filter(&layout_rev, ¶ms.field_id, ¶ms.field_type_rev, filter_rev); - - is_changed = Some(()) - } - if let Some(params) = changeset.delete_filter { - match grid_rev - .setting - .get_mut_filters(&layout_rev, ¶ms.field_id, ¶ms.field_type_rev) - { - Some(filters) => { - filters.retain(|filter| filter.id != params.filter_id); - } - None => { - tracing::warn!("Can't find the filter with {:?}", layout_rev); - } - } - } - if let Some(params) = changeset.insert_group { - let rev = GridGroupRevision { - id: gen_grid_group_id(), - field_id: params.field_id, - sub_field_id: params.sub_field_id, - }; - - grid_rev - .setting - .groups - .entry(layout_rev.clone()) - .or_insert_with(std::vec::Vec::new) - .push(rev); - - is_changed = Some(()) - } - if let Some(delete_group_id) = changeset.delete_group { - match grid_rev.setting.groups.get_mut(&layout_rev) { - Some(groups) => groups.retain(|group| group.id != delete_group_id), - None => { - tracing::warn!("Can't find the group with {:?}", layout_rev); - } - } - } - if let Some(sort) = changeset.insert_sort { - let rev = GridSortRevision { - id: gen_grid_sort_id(), - field_id: sort.field_id, - }; - - grid_rev - .setting - .sorts - .entry(layout_rev.clone()) - .or_insert_with(std::vec::Vec::new) - .push(rev); - is_changed = Some(()) - } - - if let Some(delete_sort_id) = changeset.delete_sort { - match grid_rev.setting.sorts.get_mut(&layout_rev) { - Some(sorts) => sorts.retain(|sort| sort.id != delete_sort_id), - None => { - tracing::warn!("Can't find the sort with {:?}", layout_rev); - } - } - } - Ok(is_changed) - }) - } - pub fn md5(&self) -> String { md5(&self.delta.json_bytes()) } @@ -472,7 +360,7 @@ impl GridRevisionPad { &self.grid_rev.fields } - fn modify_grid(&mut self, f: F) -> CollaborateResult> + fn modify_grid(&mut self, f: F) -> CollaborateResult> where F: FnOnce(&mut GridRevision) -> CollaborateResult>, { @@ -486,14 +374,14 @@ impl GridRevisionPad { None => Ok(None), Some(delta) => { self.delta = self.delta.compose(&delta)?; - Ok(Some(GridChangeset { delta, md5: self.md5() })) + Ok(Some(GridRevisionChangeset { delta, md5: self.md5() })) } } } } } - fn modify_block(&mut self, block_id: &str, f: F) -> CollaborateResult> + fn modify_block(&mut self, block_id: &str, f: F) -> CollaborateResult> where F: FnOnce(&mut GridBlockMetaRevision) -> CollaborateResult>, { @@ -511,7 +399,7 @@ impl GridRevisionPad { ) } - fn modify_field(&mut self, field_id: &str, f: F) -> CollaborateResult> + fn modify_field(&mut self, field_id: &str, f: F) -> CollaborateResult> where F: FnOnce(&mut FieldRevision) -> CollaborateResult>, { @@ -534,13 +422,13 @@ impl GridRevisionPad { } } -pub fn make_grid_rev_json_str(grid: &GridRevision) -> CollaborateResult { - let json = serde_json::to_string(grid) +pub fn make_grid_rev_json_str(grid_revision: &GridRevision) -> CollaborateResult { + let json = serde_json::to_string(grid_revision) .map_err(|err| internal_error(format!("Serialize grid to json str failed. {:?}", err)))?; Ok(json) } -pub struct GridChangeset { +pub struct GridRevisionChangeset { pub delta: GridRevisionDelta, /// md5: the md5 of the grid after applying the change. pub md5: String, diff --git a/shared-lib/flowy-sync/src/client_grid/mod.rs b/shared-lib/flowy-sync/src/client_grid/mod.rs index e76ae0cefe..4a9a0374f5 100644 --- a/shared-lib/flowy-sync/src/client_grid/mod.rs +++ b/shared-lib/flowy-sync/src/client_grid/mod.rs @@ -1,7 +1,9 @@ -mod grid_block_revsion_pad; +mod block_revision_pad; mod grid_builder; mod grid_revision_pad; +mod view_revision_pad; -pub use grid_block_revsion_pad::*; +pub use block_revision_pad::*; pub use grid_builder::*; pub use grid_revision_pad::*; +pub use view_revision_pad::*; diff --git a/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs b/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs new file mode 100644 index 0000000000..dd6cc6f977 --- /dev/null +++ b/shared-lib/flowy-sync/src/client_grid/view_revision_pad.rs @@ -0,0 +1,251 @@ +use crate::entities::grid::{CreateGridFilterParams, CreateGridGroupParams, GridSettingChangesetParams}; +use crate::entities::revision::{md5, Revision}; +use crate::errors::{internal_error, CollaborateError, CollaborateResult}; +use crate::util::{cal_diff, make_text_delta_from_revisions}; +use flowy_grid_data_model::revision::{ + gen_grid_filter_id, gen_grid_group_id, FieldRevision, FieldTypeRevision, FilterConfigurationRevision, + FilterConfigurationsByFieldId, GridViewRevision, GroupConfigurationRevision, GroupConfigurationsByFieldId, + SettingRevision, SortConfigurationsByFieldId, +}; +use lib_ot::core::{OperationTransform, PhantomAttributes, TextDelta, TextDeltaBuilder}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct GridViewRevisionPad { + view: Arc, + delta: TextDelta, +} + +impl std::ops::Deref for GridViewRevisionPad { + type Target = GridViewRevision; + + fn deref(&self) -> &Self::Target { + &self.view + } +} + +impl GridViewRevisionPad { + pub fn new(grid_id: String, view_id: String) -> Self { + let view = Arc::new(GridViewRevision::new(grid_id, view_id)); + let json = serde_json::to_string(&view).unwrap(); + let delta = TextDeltaBuilder::new().insert(&json).build(); + Self { view, delta } + } + + pub fn from_delta(delta: TextDelta) -> CollaborateResult { + let s = delta.content()?; + let view: GridViewRevision = serde_json::from_str(&s).map_err(|e| { + let msg = format!("Deserialize delta to GridViewRevision failed: {}", e); + tracing::error!("{}", s); + CollaborateError::internal().context(msg) + })?; + Ok(Self { + view: Arc::new(view), + delta, + }) + } + + pub fn from_revisions(_grid_id: &str, revisions: Vec) -> CollaborateResult { + let delta: TextDelta = make_text_delta_from_revisions(revisions)?; + Self::from_delta(delta) + } + + pub fn get_setting_rev(&self) -> &SettingRevision { + &self.view.setting + } + + pub fn update_setting( + &mut self, + changeset: GridSettingChangesetParams, + ) -> CollaborateResult> { + self.modify(|view| { + let mut is_changed = None; + if let Some(params) = changeset.insert_filter { + view.setting.filters.insert_object( + ¶ms.field_id, + ¶ms.field_type_rev, + make_filter_revision(¶ms), + ); + is_changed = Some(()) + } + if let Some(params) = changeset.delete_filter { + if let Some(filters) = view + .setting + .filters + .get_mut_objects(¶ms.field_id, ¶ms.field_type_rev) + { + filters.retain(|filter| filter.id != params.filter_id); + is_changed = Some(()) + } + } + if let Some(params) = changeset.insert_group { + view.setting.groups.remove_all(); + view.setting.groups.insert_object( + ¶ms.field_id, + ¶ms.field_type_rev, + make_group_revision(¶ms), + ); + + is_changed = Some(()); + } + if let Some(params) = changeset.delete_group { + if let Some(groups) = view + .setting + .groups + .get_mut_objects(¶ms.field_id, ¶ms.field_type_rev) + { + groups.retain(|group| group.id != params.group_id); + is_changed = Some(()); + } + } + + Ok(is_changed) + }) + } + + pub fn get_all_groups(&self, field_revs: &[Arc]) -> Option { + self.setting.groups.get_all_objects(field_revs) + } + + pub fn get_groups( + &self, + field_id: &str, + field_type_rev: &FieldTypeRevision, + ) -> Option>> { + self.setting.groups.get_objects(field_id, field_type_rev) + } + + pub fn insert_group( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + group_rev: GroupConfigurationRevision, + ) -> CollaborateResult> { + self.modify(|view| { + // only one group can be set + view.setting.groups.remove_all(); + view.setting.groups.insert_object(field_id, field_type, group_rev); + Ok(Some(())) + }) + } + + pub fn delete_group( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + group_id: &str, + ) -> CollaborateResult> { + self.modify(|view| { + if let Some(groups) = view.setting.groups.get_mut_objects(field_id, field_type) { + groups.retain(|group| group.id != group_id); + Ok(Some(())) + } else { + Ok(None) + } + }) + } + + pub fn get_all_filters(&self, field_revs: &[Arc]) -> Option { + self.setting.filters.get_all_objects(field_revs) + } + + pub fn get_filters( + &self, + field_id: &str, + field_type_rev: &FieldTypeRevision, + ) -> Option>> { + self.setting.filters.get_objects(field_id, field_type_rev) + } + + pub fn insert_filter( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + filter_rev: FilterConfigurationRevision, + ) -> CollaborateResult> { + self.modify(|view| { + view.setting.filters.insert_object(field_id, field_type, filter_rev); + Ok(Some(())) + }) + } + + pub fn delete_filter( + &mut self, + field_id: &str, + field_type: &FieldTypeRevision, + filter_id: &str, + ) -> CollaborateResult> { + self.modify(|view| { + if let Some(filters) = view.setting.filters.get_mut_objects(field_id, field_type) { + filters.retain(|filter| filter.id != filter_id); + Ok(Some(())) + } else { + Ok(None) + } + }) + } + + pub fn get_all_sort(&self) -> Option { + None + } + + pub fn json_str(&self) -> CollaborateResult { + make_grid_view_rev_json_str(&self.view) + } + + fn modify(&mut self, f: F) -> CollaborateResult> + where + F: FnOnce(&mut GridViewRevision) -> CollaborateResult>, + { + let cloned_view = self.view.clone(); + match f(Arc::make_mut(&mut self.view))? { + None => Ok(None), + Some(_) => { + let old = make_grid_view_rev_json_str(&cloned_view)?; + let new = self.json_str()?; + match cal_diff::(old, new) { + None => Ok(None), + Some(delta) => { + self.delta = self.delta.compose(&delta)?; + let md5 = md5(&self.delta.json_bytes()); + Ok(Some(GridViewRevisionChangeset { delta, md5 })) + } + } + } + } + } +} + +fn make_filter_revision(params: &CreateGridFilterParams) -> FilterConfigurationRevision { + FilterConfigurationRevision { + id: gen_grid_filter_id(), + field_id: params.field_id.clone(), + condition: params.condition, + content: params.content.clone(), + } +} + +fn make_group_revision(params: &CreateGridGroupParams) -> GroupConfigurationRevision { + GroupConfigurationRevision { + id: gen_grid_group_id(), + field_id: params.field_id.clone(), + field_type_rev: params.field_type_rev, + content: params.content.clone(), + } +} + +pub struct GridViewRevisionChangeset { + pub delta: TextDelta, + pub md5: String, +} + +pub fn make_grid_view_rev_json_str(grid_revision: &GridViewRevision) -> CollaborateResult { + let json = serde_json::to_string(grid_revision) + .map_err(|err| internal_error(format!("Serialize grid view to json str failed. {:?}", err)))?; + Ok(json) +} + +pub fn make_grid_view_delta(grid_view: &GridViewRevision) -> TextDelta { + let json = serde_json::to_string(grid_view).unwrap(); + TextDeltaBuilder::new().insert(&json).build() +} diff --git a/shared-lib/flowy-sync/src/entities/grid.rs b/shared-lib/flowy-sync/src/entities/grid.rs index fc3c14b4fb..3be3d98267 100644 --- a/shared-lib/flowy-sync/src/entities/grid.rs +++ b/shared-lib/flowy-sync/src/entities/grid.rs @@ -1,12 +1,12 @@ -use flowy_grid_data_model::revision::{FieldTypeRevision, GridLayoutRevision}; +use flowy_grid_data_model::revision::{FieldTypeRevision, LayoutRevision}; pub struct GridSettingChangesetParams { pub grid_id: String, - pub layout_type: GridLayoutRevision, + pub layout_type: LayoutRevision, pub insert_filter: Option, pub delete_filter: Option, pub insert_group: Option, - pub delete_group: Option, + pub delete_group: Option, pub insert_sort: Option, pub delete_sort: Option, } @@ -28,10 +28,19 @@ pub struct DeleteFilterParams { pub filter_id: String, pub field_type_rev: FieldTypeRevision, } + pub struct CreateGridGroupParams { - pub field_id: Option, - pub sub_field_id: Option, + pub field_id: String, + pub field_type_rev: FieldTypeRevision, + pub content: Option>, } + +pub struct DeleteGroupParams { + pub field_id: String, + pub group_id: String, + pub field_type_rev: FieldTypeRevision, +} + pub struct CreateGridSortParams { pub field_id: Option, } diff --git a/shared-lib/flowy-sync/src/util.rs b/shared-lib/flowy-sync/src/util.rs index 7dd5c4af5c..d968d74796 100644 --- a/shared-lib/flowy-sync/src/util.rs +++ b/shared-lib/flowy-sync/src/util.rs @@ -7,7 +7,7 @@ use crate::{ errors::{CollaborateError, CollaborateResult}, }; use dissimilar::Chunk; -use lib_ot::core::{DeltaBuilder, OTString}; +use lib_ot::core::{DeltaBuilder, OTString, PhantomAttributes, TextDelta}; use lib_ot::{ core::{Attributes, Delta, OperationTransform, NEW_LINE, WHITESPACE}, rich_text::RichTextDelta, @@ -81,6 +81,10 @@ where Ok(delta) } +pub fn make_text_delta_from_revisions(revisions: Vec) -> CollaborateResult { + make_delta_from_revisions::(revisions) +} + pub fn make_delta_from_revision_pb(revisions: Vec) -> CollaborateResult> where T: Attributes + DeserializeOwned, diff --git a/shared-lib/lib-infra/src/future.rs b/shared-lib/lib-infra/src/future.rs index 9077dd18b7..a6bad3b298 100644 --- a/shared-lib/lib-infra/src/future.rs +++ b/shared-lib/lib-infra/src/future.rs @@ -8,20 +8,20 @@ use std::{ task::{Context, Poll}, }; -pub fn wrap_future(f: T) -> FnFuture +pub fn wrap_future(f: T) -> AFFuture where T: Future + Send + Sync + 'static, { - FnFuture { fut: Box::pin(f) } + AFFuture { fut: Box::pin(f) } } #[pin_project] -pub struct FnFuture { +pub struct AFFuture { #[pin] pub fut: Pin + Sync + Send>>, } -impl Future for FnFuture +impl Future for AFFuture where T: Send + Sync, { diff --git a/shared-lib/lib-ot/src/core/delta/delta.rs b/shared-lib/lib-ot/src/core/delta/delta.rs index b422205a90..a95fd7c236 100644 --- a/shared-lib/lib-ot/src/core/delta/delta.rs +++ b/shared-lib/lib-ot/src/core/delta/delta.rs @@ -604,7 +604,7 @@ where serde_json::to_string(self).unwrap_or_else(|_| "".to_owned()) } - /// Get the content the [Delta] represents. + /// Get the content that the [Delta] represents. pub fn content(&self) -> Result { self.apply("") } diff --git a/shared-lib/lib-ot/src/core/operation/builder.rs b/shared-lib/lib-ot/src/core/operation/builder.rs index 9483d4cae7..c4f9cc8277 100644 --- a/shared-lib/lib-ot/src/core/operation/builder.rs +++ b/shared-lib/lib-ot/src/core/operation/builder.rs @@ -4,6 +4,7 @@ use crate::rich_text::RichTextAttributes; pub type RichTextOpBuilder = OperationsBuilder; pub type PlainTextOpBuilder = OperationsBuilder; +#[derive(Default)] pub struct OperationsBuilder { operations: Vec>, } @@ -13,17 +14,17 @@ where T: Attributes, { pub fn new() -> OperationsBuilder { - OperationsBuilder { operations: vec![] } + OperationsBuilder::default() } pub fn retain_with_attributes(mut self, n: usize, attributes: T) -> OperationsBuilder { - let retain = Operation::retain_with_attributes(n.into(), attributes); + let retain = Operation::retain_with_attributes(n, attributes); self.operations.push(retain); self } pub fn retain(mut self, n: usize) -> OperationsBuilder { - let retain = Operation::retain(n.into()); + let retain = Operation::retain(n); self.operations.push(retain); self } @@ -34,13 +35,13 @@ where } pub fn insert_with_attributes(mut self, s: &str, attributes: T) -> OperationsBuilder { - let insert = Operation::insert_with_attributes(s.into(), attributes); + let insert = Operation::insert_with_attributes(s, attributes); self.operations.push(insert); self } pub fn insert(mut self, s: &str) -> OperationsBuilder { - let insert = Operation::insert(s.into()); + let insert = Operation::insert(s); self.operations.push(insert); self }