diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 515b3af226..7b7c889a72 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,7 @@ jobs: sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list sudo apt-get update sudo apt-get install -y dart curl build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev + sudo apt-get install keybinder-3.0 elif [ "$RUNNER_OS" == "macOS" ]; then echo 'do nothing' fi diff --git a/.github/workflows/flowy_editor_test.yml b/.github/workflows/flowy_editor_test.yml index 1bff599ace..1459a47518 100644 --- a/.github/workflows/flowy_editor_test.yml +++ b/.github/workflows/flowy_editor_test.yml @@ -4,10 +4,14 @@ on: push: branches: - "main" + paths: + - "frontend/app_flowy/packages/appflowy_editor" pull_request: branches: - "main" + paths: + - "frontend/app_flowy/packages/appflowy_editor" env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b608276f8..e8a826f031 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,7 @@ jobs: sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub sudo apt-get update sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev + sudo apt-get install keybinder-3.0 source $HOME/.cargo/env cargo install --force cargo-make cargo install --force duckscript_cli diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ee49d36f3..b485eb1211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ # Release Notes -## Version 0.0.4 - 2022-06-06 +## Version 0.0.5 - beta.2 - beta.1 - 09/01/2022 + +New features +- Board-view database + - Support start editing after creating a new card + - Support editing the card directly by clicking the edit button + - Add the `No Status` column to display the cards while their status is empty + +### Bug Fixes +- Optimize insert card animation +- Fix some UI bugs + +## Version 0.0.5 - beta.1 - 08/25/2022 + +New features +- Board-view database + - Group by single select + - drag and drop cards + - insert / delete cards + +![Aug-25-2022 16-22-38](https://user-images.githubusercontent.com/86001920/186614248-23186dfe-410e-427a-8cc6-865b1f79e074.gif) + + +## Version 0.0.4 - 06/06/2022 - Drag to adjust the width of a column - Upgrade to Flutter 3.0 - Native support for M1 chip @@ -12,12 +35,12 @@ - Fixed some bugs -## Version 0.0.4 - beta.3 - 2022-05-02 +## Version 0.0.4 - beta.3 - 05/02/2022 - Drag to reorder app/ view/ field - Row record open as a page - Auto resize the height of the row in the grid - Support more number formats -- Search column options, supporting Single select, Multi-select, and number format +- Search column options, supporting Single-select, Multi-select, and number format ![May-03-2022 10-03-00](https://user-images.githubusercontent.com/86001920/166394640-a8f1f3bc-5f20-4033-93e9-16bc308d7005.gif) @@ -27,7 +50,7 @@ - Fixed some bugs -## Version 0.0.4 - beta.2 - 2022-04-11 +## Version 0.0.4 - beta.2 - 04/11/2022 - Support properties: Text, Number, Date, Checkbox, Select, Multi-select - Insert / delete rows @@ -35,16 +58,16 @@ - Edit property ![](https://user-images.githubusercontent.com/12026239/162753644-bf2f4e7a-2367-4d48-87e6-35e244e83a5b.png) -## Version 0.0.4 - beta.1 - 2022-04-08 +## Version 0.0.4 - beta.1 - 04/08/2022 v0.0.4 - beta.1 is pre-release New features - Table-view database - - supported column types: Text, Checbox, Single-select, Multi-select, Numbers + - supported column types: Text, Checkbox, Single-select, Multi-select, Numbers - hide / delete columns - insert rows -## Version 0.0.3 - 2022-02-23 +## Version 0.0.3 - 02/23/2022 v0.0.3 is production ready, available on Linux, macOS, and Windows New features diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json index 0efc79b00e..f40b4c713e 100644 --- a/frontend/.vscode/launch.json +++ b/frontend/.vscode/launch.json @@ -16,6 +16,18 @@ }, "cwd": "${workspaceRoot}/app_flowy" }, + { + // This task builds the Rust and Dart code of AppFlowy for android. + "name": "AF: Run Android", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: build_mobile_sdk", + "env": { + "RUST_LOG": "info" + }, + "cwd": "${workspaceRoot}/app_flowy" + }, { "name": "AF: Debug Rust", "request": "attach", @@ -48,6 +60,21 @@ }, "cwd": "${workspaceRoot}/app_flowy" }, + { + // This task builds will: + // - call the clean task, + // - rebuild all the generated Files (including freeze and language files) + // - rebuild the the Rust and Dart code of AppFlowy. + "name": "AF: Clean + Rebuild All (Android)", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: Clean + Rebuild All (Android)", + "env": { + "RUST_LOG": "info" + }, + "cwd": "${workspaceRoot}/app_flowy" + }, { "name": "AF: Build All (rustlog: trace)", "request": "launch", @@ -59,6 +86,17 @@ }, "cwd": "${workspaceRoot}/app_flowy" }, + { + "name": "AF: Build All Android (rustlog: trace)", + "request": "launch", + "program": "./lib/main.dart", + "type": "dart", + "preLaunchTask": "AF: build_mobile_sdk", + "env": { + "RUST_LOG": "trace" + }, + "cwd": "${workspaceRoot}/app_flowy" + }, { "name": "AF: app_flowy (profile mode)", "request": "launch", diff --git a/frontend/.vscode/tasks.json b/frontend/.vscode/tasks.json index 769ecd9b28..30fc1e134d 100644 --- a/frontend/.vscode/tasks.json +++ b/frontend/.vscode/tasks.json @@ -27,6 +27,33 @@ "panel": "new" } }, + { + "label": "AF: Clean + Rebuild All (Android)", + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "AF: Rust Clean", + "AF: Flutter Clean", + "AF: build_flowy_sdk_for_android", + "AF: Flutter Pub Get", + "AF: Flutter Package Get", + "AF: Generate Language Files", + "AF: Generate Freezed Files", + ], + "presentation": { + "reveal": "always", + "panel": "new", + }, + }, + { + "label": "AF: build_flowy_sdk_for_android", + "type": "shell", + "command": "cargo make --profile development-android flowy-sdk-dev-android", + "group": "build", + "options": { + "cwd": "${workspaceFolder}" + } + }, { "label": "AF: build_flowy_sdk", "type": "shell", diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index 337b9efd76..185dac15ad 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -22,7 +22,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -CURRENT_APP_VERSION = "0.0.4" +CURRENT_APP_VERSION = "0.0.5" FEATURES = "flutter" PRODUCT_NAME = "AppFlowy" # CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html @@ -161,6 +161,11 @@ TARGET_OS = "ios" FLUTTER_OUTPUT_DIR = "Release" PRODUCT_EXT = "ipa" +[env.development-android] +BUILD_FLAG = "debug" +TARGET_OS = "android" +CRATE_TYPE = "cdylib" +FLUTTER_OUTPUT_DIR = "Debug" [tasks.setup-crate-type] private = true diff --git a/frontend/app_flowy/android/README.md b/frontend/app_flowy/android/README.md new file mode 100644 index 0000000000..a073fde807 --- /dev/null +++ b/frontend/app_flowy/android/README.md @@ -0,0 +1,64 @@ +# Description + +This is a guide on how to build the rust SDK for AppFlowy on android. +Compiling the sdk is easy it just needs a few tweaks. +When compiling for android we need the following pre-requisites: + +- Android NDK Tools. (v24 has been tested). +- Cargo NDK. (@latest version). + +**Getting the tools** +- Install cargo-ndk ```bash cargo install cargo-ndk```. +- [Download](https://developer.android.com/ndk/downloads/) Android NDK version 24. +- When downloading Android NDK you can get the compressed version as a standalone from the site. + Or you can download it through [Android Studio](https://developer.android.com/studio). +- After downloading the two you need to set the environment variables. For Windows that's a seperate process. + On MacOs and Linux the process is similar. +- The variables needed are '$ANDROID_NDK_HOME', this will point to where the NDK is located. +--- + +**Cargo Config File** +This code needs to be written in ~/.cargo/config, this helps cargo know where to locate the android tools(linker and archiver). +**NB** Keep in mind just replace 'user' with your own user name. Or just point it to the location of where you put the NDK. + +```toml +[target.aarch64-linux-android] +ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" +linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang" + +[target.armv7-linux-androideabi] +ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" +linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi29-clang" + +[target.i686-linux-android] +ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" +linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android29-clang" + +[target.x86_64-linux-android] +ar = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar" +linker = "/home/user/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android29-clang" +``` + +**Clang Fix** + In order to get clang to work properly with version 24 you need to create this file. + libgcc.a, then add this one line. + ``` + INPUT(-lunwind) + ``` + +**Folder path: 'Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/lib64/clang/14.0.1/lib/linux'.** +After that you have to copy this file into three different folders namely aarch64, arm, i386 and x86_64. +We have to do this so we Android NDK can find clang on our system, if we used NDK 22 we wouldnt have to do this process. +Though using NDK v22 will not give us alot of features to work with. +This github [issue](https://github.com/fzyzcjy/flutter_rust_bridge/issues/419) explains the reason why we are doing this. + + --- + + **Android NDK** + + After installing the NDK tools for android you should export the PATH to your config file + (.vimrc, .zshrc, .profile, .bashrc file), That way it can be found. + + ```vim + export PATH=/home/sean/Android/Sdk/ndk/24.0.8215888 + ``` \ No newline at end of file diff --git a/frontend/app_flowy/android/app/build.gradle b/frontend/app_flowy/android/app/build.gradle index e2f9734817..2fc26c2fb5 100644 --- a/frontend/app_flowy/android/app/build.gradle +++ b/frontend/app_flowy/android/app/build.gradle @@ -26,7 +26,8 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 30 + compileSdkVersion 31 + ndkVersion "24.0.8215888" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -39,21 +40,26 @@ android { sourceSets { main.java.srcDirs += 'src/main/kotlin' + main.jniLibs.srcDirs += 'jniLibs/' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.app_flowy" - minSdkVersion 16 - targetSdkVersion 30 + minSdkVersion 19 + targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName + multiDexEnabled true } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. + minifyEnabled true + shrinkResources true + signingConfig signingConfigs.debug } } @@ -65,4 +71,5 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "com.android.support:multidex:2.0.1" } diff --git a/frontend/app_flowy/android/app/src/main/AndroidManifest.xml b/frontend/app_flowy/android/app/src/main/AndroidManifest.xml index a9fd47bd31..a34dc98587 100644 --- a/frontend/app_flowy/android/app/src/main/AndroidManifest.xml +++ b/frontend/app_flowy/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ package="com.example.app_flowy"> + android:icon="@mipmap/ic_launcher" + android:name="${applicationName}"> properties.load(reader) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" + + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') + +if(pluginsFile.exists()){ + pluginsFile.withReader('UTF-8'){reader -> plugins.load(reader)} +} + +plugins.each{name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/ca-ES.json b/frontend/app_flowy/assets/translations/ca-ES.json index d08639f5cf..23ae51bd69 100644 --- a/frontend/app_flowy/assets/translations/ca-ES.json +++ b/frontend/app_flowy/assets/translations/ca-ES.json @@ -141,5 +141,9 @@ "lightLabel": "Mode Clar", "darkLabel": "Mode Fosc" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/de-DE.json b/frontend/app_flowy/assets/translations/de-DE.json index 5e1e5cd901..6ce661e7b9 100644 --- a/frontend/app_flowy/assets/translations/de-DE.json +++ b/frontend/app_flowy/assets/translations/de-DE.json @@ -141,6 +141,10 @@ "lightLabel": "Heller Modus", "darkLabel": "Dunkler Modus" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/en.json b/frontend/app_flowy/assets/translations/en.json index a60ff02dc1..953e5b02bb 100644 --- a/frontend/app_flowy/assets/translations/en.json +++ b/frontend/app_flowy/assets/translations/en.json @@ -95,7 +95,13 @@ "tooltip": { "lightMode": "Switch to Light mode", "darkMode": "Switch to Dark mode", - "openAsPage": "Open as a Page" + "openAsPage": "Open as a Page", + "addNewRow": "Add a new row", + "openMenu": "Click to open menu" + }, + "sideBar": { + "closeSidebar": "Close side bar", + "openSidebar": "Open side bar" }, "notifications": { "export": { @@ -183,7 +189,8 @@ "addSelectOption": "Add an option", "optionTitle": "Options", "addOption": "Add option", - "editProperty": "Edit property" + "editProperty": "Edit property", + "newColumn": "New column" }, "row": { "duplicate": "Duplicate", @@ -215,5 +222,10 @@ "timeHintTextInTwelveHour": "12:00 AM", "timeHintTextInTwentyFourHour": "12:00" } + }, + "board": { + "column": { + "create_new_card": "New" + } } -} +} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/es-VE.json b/frontend/app_flowy/assets/translations/es-VE.json index d3740db8ec..cf78b8083d 100644 --- a/frontend/app_flowy/assets/translations/es-VE.json +++ b/frontend/app_flowy/assets/translations/es-VE.json @@ -213,5 +213,9 @@ "timeHintTextInTwelveHour": "12:00 AM", "timeHintTextInTwentyFourHour": "12:00" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/fr-CA.json b/frontend/app_flowy/assets/translations/fr-CA.json index 329576028b..ff7f5ca251 100644 --- a/frontend/app_flowy/assets/translations/fr-CA.json +++ b/frontend/app_flowy/assets/translations/fr-CA.json @@ -141,5 +141,9 @@ "lightLabel": "Mode clair", "darkLabel": "Mode sombre" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/fr-FR.json b/frontend/app_flowy/assets/translations/fr-FR.json index ffb96ad863..e6ccdb3b78 100644 --- a/frontend/app_flowy/assets/translations/fr-FR.json +++ b/frontend/app_flowy/assets/translations/fr-FR.json @@ -142,6 +142,10 @@ "darkLabel": "Mode sombre" } }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" + }, "grid": { "settings": { "filter": "Filtrer", diff --git a/frontend/app_flowy/assets/translations/hu-HU.json b/frontend/app_flowy/assets/translations/hu-HU.json index a2f99c55e1..7428f48520 100644 --- a/frontend/app_flowy/assets/translations/hu-HU.json +++ b/frontend/app_flowy/assets/translations/hu-HU.json @@ -141,5 +141,9 @@ "lightLabel": "Világos mód", "darkLabel": "Éjjeli mód" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/id-ID.json b/frontend/app_flowy/assets/translations/id-ID.json index cbe721c2b8..d1c708721d 100644 --- a/frontend/app_flowy/assets/translations/id-ID.json +++ b/frontend/app_flowy/assets/translations/id-ID.json @@ -214,5 +214,9 @@ "timeHintTextInTwelveHour": "12:00 AM", "timeHintTextInTwentyFourHour": "12:00" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/it-IT.json b/frontend/app_flowy/assets/translations/it-IT.json index 9a546076c5..3433eb4d8e 100644 --- a/frontend/app_flowy/assets/translations/it-IT.json +++ b/frontend/app_flowy/assets/translations/it-IT.json @@ -147,5 +147,9 @@ }, "document":{ "menuName":"Documento" + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/ja-JP.json b/frontend/app_flowy/assets/translations/ja-JP.json index aa726a0410..04409a82a2 100644 --- a/frontend/app_flowy/assets/translations/ja-JP.json +++ b/frontend/app_flowy/assets/translations/ja-JP.json @@ -195,5 +195,9 @@ "pannelTitle": "選択候補を検索 または 作成する", "searchOption": "選択候補を検索" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/pl-PL.json b/frontend/app_flowy/assets/translations/pl-PL.json index 0105e7aec7..ba6c4e1861 100644 --- a/frontend/app_flowy/assets/translations/pl-PL.json +++ b/frontend/app_flowy/assets/translations/pl-PL.json @@ -141,5 +141,9 @@ "lightLabel": "Tryb Jasny", "darkLabel": "Tryb Ciemny" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/pt-BR.json b/frontend/app_flowy/assets/translations/pt-BR.json index 8ae5818b60..8e3fba0362 100644 --- a/frontend/app_flowy/assets/translations/pt-BR.json +++ b/frontend/app_flowy/assets/translations/pt-BR.json @@ -141,6 +141,10 @@ "lightLabel": "Modo Claro", "darkLabel": "Modo Escuro" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/pt-PT.json b/frontend/app_flowy/assets/translations/pt-PT.json index 3aa37ee230..a9291949bd 100644 --- a/frontend/app_flowy/assets/translations/pt-PT.json +++ b/frontend/app_flowy/assets/translations/pt-PT.json @@ -141,6 +141,10 @@ "lightLabel": "Modo Claro", "darkLabel": "Modo Escuro" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/ru-RU.json b/frontend/app_flowy/assets/translations/ru-RU.json index 65e61347e6..8be0e0a07f 100644 --- a/frontend/app_flowy/assets/translations/ru-RU.json +++ b/frontend/app_flowy/assets/translations/ru-RU.json @@ -203,6 +203,10 @@ "timeHintTextInTwelveHour": "12:00 AM", "timeHintTextInTwentyFourHour": "12:00" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/tr-TR.json b/frontend/app_flowy/assets/translations/tr-TR.json index c83cace0a7..aa2b1a3a39 100644 --- a/frontend/app_flowy/assets/translations/tr-TR.json +++ b/frontend/app_flowy/assets/translations/tr-TR.json @@ -141,5 +141,9 @@ "lightLabel": "Aydınlık Mod", "darkLabel": "Karanlık Mod" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } diff --git a/frontend/app_flowy/assets/translations/zh-CN.json b/frontend/app_flowy/assets/translations/zh-CN.json index 81b0ad13b1..514a1cd338 100644 --- a/frontend/app_flowy/assets/translations/zh-CN.json +++ b/frontend/app_flowy/assets/translations/zh-CN.json @@ -93,8 +93,14 @@ "highlight": "高亮" }, "tooltip": { - "lightMode": "切换到灯光模式", - "darkMode": "切换到暗模式" + "lightMode": "切换到亮色模式", + "darkMode": "切换到暗色模式" + }, + "notifications": { + "export": { + "markdown": "导出笔记为Markdown文档", + "path": "Documents/flowy" + } }, "contactsPage": { "title": "联系人", @@ -135,11 +141,82 @@ "menu": { "appearance": "外观", "language": "语言", + "user": "用户", "open": "打开设置" }, "appearance": { "lightLabel": "日间模式", "darkLabel": "夜间模式" } + }, + "sideBar": { + "openSidebar": "打开侧边栏", + "closeSidebar": "关闭侧边栏" + }, + "grid": { + "settings": { + "filter": "过滤器", + "sortBy": "排序", + "Properties": "属性" + }, + "field": { + "hide": "隐藏", + "insertLeft": "左侧插入", + "insertRight": "右侧插入", + "duplicate": "拷贝", + "delete": "删除", + "textFieldName": "文本", + "checkboxFieldName": "勾选框", + "dateFieldName": "日期", + "numberFieldName": "数字", + "singleSelectFieldName": "单项选择器", + "multiSelectFieldName": "多项选择器", + "urlFieldName": "链接", + "numberFormat": " 数字格式", + "dateFormat": " 日期格式", + "includeTime": " 包含时间", + "dateFormatFriendly": "月 日,年", + "dateFormatISO": "年-月-日", + "dateFormatLocal": "年/月/日", + "dateFormatUS": "年/月/日", + "timeFormat": " 时间格式", + "invalidTimeFormat": "时间格式错误", + "timeFormatTwelveHour": "12小时制", + "timeFormatTwentyFourHour": "24小时制", + "addSelectOption": "添加一个标签", + "optionTitle": "标签", + "addOption": "添加标签", + "editProperty": "编辑列属性" + }, + "row": { + "duplicate": "复制", + "delete": "删除", + "textPlaceholder": "空", + "copyProperty": "复制列" + }, + "selectOption": { + "create": "新建", + "purpleColor": "紫色", + "pinkColor": "粉色", + "lightPinkColor": "浅粉色", + "orangeColor": "橙色", + "yellowColor": "黄色", + "limeColor": "鲜绿色", + "greenColor": "绿色", + "aquaColor": "水蓝色", + "blueColor": "蓝色", + "deleteTag": "删除标签", + "colorPannelTitle": "颜色", + "pannelTitle": "选择或新建一个标签", + "searchOption": "搜索标签" + }, + "menuName": "网格" + }, + "document": { + "menuName": "文档", + "date": { + "timeHintTextInTwelveHour": "12:00 AM", + "timeHintTextInTwentyFourHour": "12:00" + } } -} +} \ No newline at end of file diff --git a/frontend/app_flowy/assets/translations/zh-TW.json b/frontend/app_flowy/assets/translations/zh-TW.json index 9f72b90512..53a9347126 100644 --- a/frontend/app_flowy/assets/translations/zh-TW.json +++ b/frontend/app_flowy/assets/translations/zh-TW.json @@ -214,5 +214,9 @@ "timeHintTextInTwelveHour": "12:00 AM", "timeHintTextInTwentyFourHour": "12:00" } + }, + "sideBar": { + "openSidebar": "Open sidebar", + "closeSidebar": "Close sidebar" } } \ No newline at end of file diff --git a/frontend/app_flowy/lib/core/frameless_window.dart b/frontend/app_flowy/lib/core/frameless_window.dart index a7d6417cd3..3641aeef4d 100644 --- a/frontend/app_flowy/lib/core/frameless_window.dart +++ b/frontend/app_flowy/lib/core/frameless_window.dart @@ -31,10 +31,10 @@ class MoveWindowDetector extends StatefulWidget { final Widget? child; @override - _MoveWindowDetectorState createState() => _MoveWindowDetectorState(); + MoveWindowDetectorState createState() => MoveWindowDetectorState(); } -class _MoveWindowDetectorState extends State { +class MoveWindowDetectorState extends State { double winX = 0; double winY = 0; @@ -59,7 +59,8 @@ class _MoveWindowDetectorState extends State { final double dy = windowPos[1]; final deltaX = details.globalPosition.dx - winX; final deltaY = details.globalPosition.dy - winY; - await CocoaWindowChannel.instance.setWindowPosition(Offset(dx + deltaX, dy - deltaY)); + await CocoaWindowChannel.instance + .setWindowPosition(Offset(dx + deltaX, dy - deltaY)); }, child: widget.child, ); diff --git a/frontend/app_flowy/lib/main.dart b/frontend/app_flowy/lib/main.dart index 8b1da85f75..58b784da26 100644 --- a/frontend/app_flowy/lib/main.dart +++ b/frontend/app_flowy/lib/main.dart @@ -1,6 +1,7 @@ import 'package:app_flowy/startup/startup.dart'; import 'package:app_flowy/user/presentation/splash_screen.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:flutter/material.dart'; class FlowyApp implements EntryPoint { @@ -14,5 +15,8 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + await hotKeyManager.unregisterAll(); + await FlowyRunner.run(FlowyApp()); } 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 f84c2a2bd1..6913b07023 100644 --- a/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart +++ b/frontend/app_flowy/lib/plugins/board/application/board_bloc.dart @@ -20,19 +20,19 @@ import 'group_controller.dart'; part 'board_bloc.freezed.dart'; class BoardBloc extends Bloc { - final BoardDataController _dataController; - late final AFBoardDataController afBoardDataController; + final BoardDataController _gridDataController; + late final AFBoardDataController boardController; final MoveRowFFIService _rowService; - LinkedHashMap groupControllers = LinkedHashMap.new(); + LinkedHashMap groupControllers = LinkedHashMap(); - GridFieldCache get fieldCache => _dataController.fieldCache; - String get gridId => _dataController.gridId; + GridFieldCache get fieldCache => _gridDataController.fieldCache; + String get gridId => _gridDataController.gridId; BoardBloc({required ViewPB view}) : _rowService = MoveRowFFIService(gridId: view.id), - _dataController = BoardDataController(view: view), + _gridDataController = BoardDataController(view: view), super(BoardState.initial(view.id)) { - afBoardDataController = AFBoardDataController( + boardController = AFBoardDataController( onMoveColumn: ( fromColumnId, fromIndex, @@ -69,31 +69,51 @@ class BoardBloc extends Bloc { _startListening(); await _loadGrid(emit); }, - createRow: (groupId) async { - final result = await _dataController.createBoardCard(groupId); + createBottomRow: (groupId) async { + final startRowId = groupControllers[groupId]?.lastRow()?.id; + final result = await _gridDataController.createBoardCard( + groupId, + startRowId: startRowId, + ); result.fold( - (rowPB) { - emit(state.copyWith(editingRow: some(rowPB))); - }, + (_) {}, (err) => Log.error(err), ); }, + createHeaderRow: (String groupId) async { + final result = await _gridDataController.createBoardCard(groupId); + result.fold( + (_) {}, + (err) => Log.error(err), + ); + }, + didCreateRow: (String groupId, RowPB row, int? index) { + emit(state.copyWith( + editingRow: Some(BoardEditingRow( + columnId: groupId, + row: row, + index: index, + )), + )); + }, endEditRow: (rowId) { assert(state.editingRow.isSome()); - state.editingRow.fold(() => null, (row) { - assert(row.id == rowId); + state.editingRow.fold(() => null, (editingRow) { + assert(editingRow.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))); }, + didReceiveGroups: (List groups) { + emit(state.copyWith( + groupIds: groups.map((group) => group.groupId).toList(), + )); + }, ); }, ); @@ -126,7 +146,7 @@ class BoardBloc extends Bloc { @override Future close() async { - await _dataController.dispose(); + await _gridDataController.dispose(); for (final controller in groupControllers.values) { controller.dispose(); } @@ -135,7 +155,12 @@ class BoardBloc extends Bloc { void initializeGroups(List groups) { for (final group in groups) { - final delegate = GroupControllerDelegateImpl(afBoardDataController); + final delegate = GroupControllerDelegateImpl( + controller: boardController, + onNewColumnItem: (groupId, row, index) { + add(BoardEvent.didCreateRow(groupId, row, index)); + }, + ); final controller = GroupController( gridId: state.gridId, group: group, @@ -147,12 +172,12 @@ class BoardBloc extends Bloc { } GridRowCache? getRowCache(String blockId) { - final GridBlockCache? blockCache = _dataController.blocks[blockId]; + final GridBlockCache? blockCache = _gridDataController.blocks[blockId]; return blockCache?.rowCache; } void _startListening() { - _dataController.addListener( + _gridDataController.addListener( onGridChanged: (grid) { if (!isClosed) { add(BoardEvent.didReceiveGridUpdate(grid)); @@ -162,17 +187,31 @@ class BoardBloc extends Bloc { List columns = groups.map((group) { return AFBoardColumnData( id: group.groupId, - desc: group.desc, - items: _buildRows(group.rows), + name: group.desc, + items: _buildRows(group), customData: group, ); }).toList(); - afBoardDataController.addColumns(columns); + boardController.addColumns(columns); initializeGroups(groups); + add(BoardEvent.didReceiveGroups(groups)); }, - onRowsChanged: (List rowInfos, RowsChangedReason reason) { - add(BoardEvent.didReceiveRows(rowInfos)); + onDeletedGroup: (groupIds) { + // + }, + onInsertedGroup: (insertedGroups) { + // + }, + onUpdatedGroup: (updatedGroups) { + // + for (final group in updatedGroups) { + final columnController = + boardController.getColumnController(group.groupId); + if (columnController != null) { + columnController.updateColumnName(group.desc); + } + } }, onError: (err) { Log.error(err); @@ -180,16 +219,19 @@ class BoardBloc extends Bloc { ); } - List _buildRows(List rows) { - final items = rows.map((row) { - return BoardColumnItem(row: row); + List _buildRows(GroupPB group) { + final items = group.rows.map((row) { + return BoardColumnItem( + row: row, + fieldId: group.fieldId, + ); }).toList(); return [...items]; } Future _loadGrid(Emitter emit) async { - final result = await _dataController.loadData(); + final result = await _gridDataController.loadData(); result.fold( (grid) => emit( state.copyWith(loadingState: GridLoadingState.finish(left(unit))), @@ -203,15 +245,21 @@ class BoardBloc extends Bloc { @freezed class BoardEvent with _$BoardEvent { - const factory BoardEvent.initial() = InitialGrid; - const factory BoardEvent.createRow(String groupId) = _CreateRow; + const factory BoardEvent.initial() = _InitialBoard; + const factory BoardEvent.createBottomRow(String groupId) = _CreateBottomRow; + const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow; + const factory BoardEvent.didCreateRow( + String groupId, + RowPB row, + int? index, + ) = _DidCreateRow; 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; + const factory BoardEvent.didReceiveGroups(List groups) = + _DidReceiveGroups; } @freezed @@ -219,16 +267,16 @@ class BoardState with _$BoardState { const factory BoardState({ required String gridId, required Option grid, - required Option editingRow, - required List rowInfos, + required List groupIds, + required Option editingRow, required GridLoadingState loadingState, required Option noneOrError, }) = _BoardState; factory BoardState.initial(String gridId) => BoardState( - rowInfos: [], grid: none(), gridId: gridId, + groupIds: [], editingRow: none(), noneOrError: none(), loadingState: const _Loading(), @@ -268,39 +316,84 @@ class GridFieldEquatable extends Equatable { class BoardColumnItem extends AFColumnItem { final RowPB row; - BoardColumnItem({required this.row}); + final String fieldId; + + final bool requestFocus; + + BoardColumnItem({ + required this.row, + required this.fieldId, + this.requestFocus = false, + }); @override String get id => row.id; } -class CreateCardItem extends AFColumnItem { - @override - String get id => '$CreateCardItem'; -} - class GroupControllerDelegateImpl extends GroupControllerDelegate { final AFBoardDataController controller; + final void Function(String, RowPB, int?) onNewColumnItem; - GroupControllerDelegateImpl(this.controller); + GroupControllerDelegateImpl({ + required this.controller, + required this.onNewColumnItem, + }); @override - void insertRow(String groupId, RowPB row, int? index) { - final item = BoardColumnItem(row: row); + void insertRow(GroupPB group, RowPB row, int? index) { if (index != null) { - controller.insertColumnItem(groupId, index, item); + final item = BoardColumnItem(row: row, fieldId: group.fieldId); + controller.insertColumnItem(group.groupId, index, item); } else { - controller.addColumnItem(groupId, item); + final item = BoardColumnItem( + row: row, + fieldId: group.fieldId, + ); + controller.addColumnItem(group.groupId, item); } } @override - void removeRow(String groupId, String rowId) { - controller.removeColumnItem(groupId, rowId); + void removeRow(GroupPB group, String rowId) { + controller.removeColumnItem(group.groupId, rowId); } @override - void updateRow(String groupId, RowPB row) { - // + void updateRow(GroupPB group, RowPB row) { + controller.updateColumnItem( + group.groupId, + BoardColumnItem( + row: row, + fieldId: group.fieldId, + ), + ); + } + + @override + void addNewRow(GroupPB group, RowPB row, int? index) { + final item = BoardColumnItem( + row: row, + fieldId: group.fieldId, + requestFocus: true, + ); + + if (index != null) { + controller.insertColumnItem(group.groupId, index, item); + } else { + controller.addColumnItem(group.groupId, item); + } + onNewColumnItem(group.groupId, row, index); } } + +class BoardEditingRow { + String columnId; + RowPB row; + int? index; + + BoardEditingRow({ + required this.columnId, + required this.row, + required this.index, + }); +} 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 index 1d17431713..4ab45c64b7 100644 --- a/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart +++ b/frontend/app_flowy/lib/plugins/board/application/board_data_controller.dart @@ -10,9 +10,15 @@ import 'dart:async'; import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/protobuf/flowy-grid/protobuf.dart'; +import 'board_listener.dart'; + typedef OnFieldsChanged = void Function(UnmodifiableListView); typedef OnGridChanged = void Function(GridPB); typedef DidLoadGroups = void Function(List); +typedef OnUpdatedGroup = void Function(List); +typedef OnDeletedGroup = void Function(List); +typedef OnInsertedGroup = void Function(List); + typedef OnRowsChanged = void Function( List, RowsChangedReason, @@ -23,6 +29,7 @@ class BoardDataController { final String gridId; final GridFFIService _gridFFIService; final GridFieldCache fieldCache; + final BoardListener _listener; // key: the block id final LinkedHashMap _blocks; @@ -44,16 +51,21 @@ class BoardDataController { BoardDataController({required ViewPB view}) : gridId = view.id, - _blocks = LinkedHashMap.new(), + _listener = BoardListener(view.id), + // ignore: prefer_collection_literals + _blocks = LinkedHashMap(), _gridFFIService = GridFFIService(gridId: view.id), fieldCache = GridFieldCache(gridId: view.id); void addListener({ - OnGridChanged? onGridChanged, + required OnGridChanged onGridChanged, OnFieldsChanged? onFieldsChanged, - DidLoadGroups? didLoadGroups, + required DidLoadGroups didLoadGroups, OnRowsChanged? onRowsChanged, - OnError? onError, + required OnUpdatedGroup onUpdatedGroup, + required OnDeletedGroup onDeletedGroup, + required OnInsertedGroup onInsertedGroup, + required OnError? onError, }) { _onGridChanged = onGridChanged; _onFieldsChanged = onFieldsChanged; @@ -64,6 +76,25 @@ class BoardDataController { fieldCache.addListener(onFields: (fields) { _onFieldsChanged?.call(UnmodifiableListView(fields)); }); + + _listener.start(onBoardChanged: (result) { + result.fold( + (changeset) { + if (changeset.updateGroups.isNotEmpty) { + onUpdatedGroup.call(changeset.updateGroups); + } + + if (changeset.insertedGroups.isNotEmpty) { + onInsertedGroup.call(changeset.insertedGroups); + } + + if (changeset.deletedGroups.isNotEmpty) { + onDeletedGroup.call(changeset.deletedGroups); + } + }, + (e) => _onError?.call(e), + ); + }); } Future> loadData() async { @@ -88,8 +119,9 @@ class BoardDataController { ); } - Future> createBoardCard(String groupId) { - return _gridFFIService.createBoardCard(groupId); + Future> createBoardCard(String groupId, + {String? startRowId}) { + return _gridFFIService.createBoardCard(groupId, startRowId); } Future dispose() async { diff --git a/frontend/app_flowy/lib/plugins/board/application/board_listener.dart b/frontend/app_flowy/lib/plugins/board/application/board_listener.dart new file mode 100644 index 0000000000..a953a993cc --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/board_listener.dart @@ -0,0 +1,50 @@ +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:dartz/dartz.dart'; +import 'package:flowy_sdk/protobuf/flowy-grid/group_changeset.pb.dart'; + +typedef UpdateBoardNotifiedValue = Either; + +class BoardListener { + final String viewId; + PublishNotifier? _groupNotifier = PublishNotifier(); + GridNotificationListener? _listener; + BoardListener(this.viewId); + + void start({ + required void Function(UpdateBoardNotifiedValue) onBoardChanged, + }) { + _groupNotifier?.addPublishListener(onBoardChanged); + _listener = GridNotificationListener( + objectId: viewId, + handler: _handler, + ); + } + + void _handler( + GridNotification ty, + Either result, + ) { + switch (ty) { + case GridNotification.DidUpdateGroupView: + result.fold( + (payload) => _groupNotifier?.value = + left(GroupViewChangesetPB.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/application/card/board_date_cell_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/card/board_date_cell_bloc.dart index 76267ededb..b1110f45cc 100644 --- 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 @@ -79,7 +79,7 @@ class BoardDateCellState with _$BoardDateCellState { String _dateStrFromCellData(DateCellDataPB? cellData) { String dateStr = ""; if (cellData != null) { - dateStr = cellData.date + " " + cellData.time; + dateStr = "${cellData.date} ${cellData.time}"; } return dateStr; } 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 index df36033cfa..1b70710a35 100644 --- 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 @@ -68,7 +68,6 @@ class BoardSelectOptionCellState with _$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 index e11d7b5ac6..9d1b14c605 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; @@ -20,6 +21,15 @@ class BoardTextCellBloc extends Bloc { didReceiveCellUpdate: (content) { emit(state.copyWith(content: content)); }, + updateText: (text) { + if (text != state.content) { + cellController.saveCellData(text); + emit(state.copyWith(content: text)); + } + }, + enableEdit: (bool enabled) { + emit(state.copyWith(enableEdit: enabled)); + }, ); }, ); @@ -49,6 +59,8 @@ class BoardTextCellBloc extends Bloc { @freezed class BoardTextCellEvent with _$BoardTextCellEvent { const factory BoardTextCellEvent.initial() = _InitialCell; + const factory BoardTextCellEvent.updateText(String text) = _UpdateContent; + const factory BoardTextCellEvent.enableEdit(bool enabled) = _EnableEdit; const factory BoardTextCellEvent.didReceiveCellUpdate(String cellContent) = _DidReceiveCellUpdate; } @@ -57,10 +69,12 @@ class BoardTextCellEvent with _$BoardTextCellEvent { class BoardTextCellState with _$BoardTextCellState { const factory BoardTextCellState({ required String content, + required bool enableEdit, }) = _BoardTextCellState; factory BoardTextCellState.initial(GridCellController context) => BoardTextCellState( content: context.getCellData() ?? "", + enableEdit: false, ); } 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 index ab6aeacfcc..ad30d2b250 100644 --- a/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart +++ b/frontend/app_flowy/lib/plugins/board/application/card/card_bloc.dart @@ -4,7 +4,6 @@ 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'; @@ -14,10 +13,12 @@ import 'card_data_controller.dart'; part 'card_bloc.freezed.dart'; class BoardCardBloc extends Bloc { + final String fieldId; final RowFFIService _rowService; final CardDataController _dataController; BoardCardBloc({ + required this.fieldId, required String gridId, required CardDataController dataController, }) : _rowService = RowFFIService( @@ -25,22 +26,22 @@ class BoardCardBloc extends Bloc { blockId: dataController.rowPB.blockId, ), _dataController = dataController, - super(BoardCardState.initial( - dataController.rowPB, dataController.loadData())) { + super( + BoardCardState.initial( + dataController.rowPB, + _makeCells(fieldId, dataController.loadData()), + ), + ) { on( (event, emit) async { - await event.map( - initial: (_InitialRow value) async { + await event.when( + initial: () async { await _startListening(); }, - didReceiveCells: (_DidReceiveCells value) async { - final cells = value.gridCellMap.values - .map((e) => GridCellEquatable(e.field)) - .toList(); + didReceiveCells: (cells, reason) async { emit(state.copyWith( - gridCellMap: value.gridCellMap, - cells: UnmodifiableListView(cells), - changeReason: value.reason, + cells: cells, + changeReason: reason, )); }, ); @@ -58,7 +59,7 @@ class BoardCardBloc extends Bloc { return RowInfo( gridId: _rowService.gridId, fields: UnmodifiableListView( - state.cells.map((cell) => cell._field).toList(), + state.cells.map((cell) => cell.identifier.field).toList(), ), rowPB: state.rowPB, ); @@ -66,8 +67,9 @@ class BoardCardBloc extends Bloc { Future _startListening() async { _dataController.addListener( - onRowChanged: (cells, reason) { + onRowChanged: (cellMap, reason) { if (!isClosed) { + final cells = _makeCells(fieldId, cellMap); add(BoardCardEvent.didReceiveCells(cells, reason)); } }, @@ -75,42 +77,52 @@ class BoardCardBloc extends Bloc { } } +UnmodifiableListView _makeCells( + String fieldId, GridCellMap originalCellMap) { + List cells = []; + for (final entry in originalCellMap.entries) { + if (entry.value.fieldId != fieldId) { + cells.add(BoardCellEquatable(entry.value)); + } + } + return UnmodifiableListView(cells); +} + @freezed class BoardCardEvent with _$BoardCardEvent { const factory BoardCardEvent.initial() = _InitialRow; const factory BoardCardEvent.didReceiveCells( - GridCellMap gridCellMap, RowsChangedReason reason) = _DidReceiveCells; + UnmodifiableListView cells, + RowsChangedReason reason, + ) = _DidReceiveCells; } @freezed class BoardCardState with _$BoardCardState { const factory BoardCardState({ required RowPB rowPB, - required GridCellMap gridCellMap, - required UnmodifiableListView cells, + required UnmodifiableListView cells, RowsChangedReason? changeReason, }) = _BoardCardState; - factory BoardCardState.initial(RowPB rowPB, GridCellMap cellDataMap) => + factory BoardCardState.initial( + RowPB rowPB, UnmodifiableListView cells) => BoardCardState( rowPB: rowPB, - gridCellMap: cellDataMap, - cells: UnmodifiableListView( - cellDataMap.values.map((e) => GridCellEquatable(e.field)).toList(), - ), + cells: cells, ); } -class GridCellEquatable extends Equatable { - final FieldPB _field; +class BoardCellEquatable extends Equatable { + final GridCellIdentifier identifier; - const GridCellEquatable(FieldPB field) : _field = field; + const BoardCellEquatable(this.identifier); @override List get props => [ - _field.id, - _field.fieldType, - _field.visibility, - _field.width, + identifier.field.id, + identifier.field.fieldType, + identifier.field.visibility, + identifier.field.width, ]; } diff --git a/frontend/app_flowy/lib/plugins/board/application/group_controller.dart b/frontend/app_flowy/lib/plugins/board/application/group_controller.dart index 6fd68b1df8..6a148c0312 100644 --- a/frontend/app_flowy/lib/plugins/board/application/group_controller.dart +++ b/frontend/app_flowy/lib/plugins/board/application/group_controller.dart @@ -1,15 +1,15 @@ 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); + void removeRow(GroupPB group, String rowId); + void insertRow(GroupPB group, RowPB row, int? index); + void updateRow(GroupPB group, RowPB row); + void addNewRow(GroupPB group, RowPB row, int? index); } class GroupController { @@ -31,13 +31,22 @@ class GroupController { } } + RowPB? lastRow() { + if (group.rows.isEmpty) return null; + return group.rows.last; + } + void startListening() { _listener.start(onGroupChanged: (result) { result.fold( - (GroupRowsChangesetPB changeset) { + (GroupChangesetPB changeset) { + for (final deletedRow in changeset.deletedRows) { + group.rows.removeWhere((rowPB) => rowPB.id == deletedRow); + delegate.removeRow(group, deletedRow); + } + for (final insertedRow in changeset.insertedRows) { final index = insertedRow.hasIndex() ? insertedRow.index : null; - if (insertedRow.hasIndex() && group.rows.length > insertedRow.index) { group.rows.insert(insertedRow.index, insertedRow.row); @@ -45,16 +54,11 @@ class GroupController { group.rows.add(insertedRow.row); } - delegate.insertRow( - group.groupId, - insertedRow.row, - index, - ); - } - - for (final deletedRow in changeset.deletedRows) { - group.rows.removeWhere((rowPB) => rowPB.id == deletedRow); - delegate.removeRow(group.groupId, deletedRow); + if (insertedRow.isNew) { + delegate.addNewRow(group, insertedRow.row, index); + } else { + delegate.insertRow(group, insertedRow.row, index); + } } for (final updatedRow in changeset.updatedRows) { @@ -66,7 +70,7 @@ class GroupController { group.rows[index] = updatedRow; } - delegate.updateRow(group.groupId, updatedRow); + delegate.updateRow(group, updatedRow); } }, (err) => Log.error(err), @@ -74,6 +78,29 @@ class GroupController { }); } + // GroupChangesetPB _transformChangeset(GroupChangesetPB changeset) { + // final insertedRows = changeset.insertedRows + // .where( + // (delete) => !changeset.deletedRows.contains(delete.row.id), + // ) + // .toList(); + + // final deletedRows = changeset.deletedRows + // .where((deletedRowId) => + // changeset.insertedRows + // .indexWhere((insert) => insert.row.id == deletedRowId) == + // -1) + // .toList(); + + // return changeset.rebuild((rebuildChangeset) { + // rebuildChangeset.insertedRows.clear(); + // rebuildChangeset.insertedRows.addAll(insertedRows); + + // rebuildChangeset.deletedRows.clear(); + // rebuildChangeset.deletedRows.addAll(deletedRows); + // }); + // } + 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 index 797177deca..e3b626af07 100644 --- a/frontend/app_flowy/lib/plugins/board/application/group_listener.dart +++ b/frontend/app_flowy/lib/plugins/board/application/group_listener.dart @@ -8,7 +8,7 @@ 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; +typedef UpdateGroupNotifiedValue = Either; class GroupListener { final GroupPB group; @@ -34,7 +34,7 @@ class GroupListener { case GridNotification.DidUpdateGroup: result.fold( (payload) => _groupNotifier?.value = - left(GroupRowsChangesetPB.fromBuffer(payload)), + left(GroupChangesetPB.fromBuffer(payload)), (error) => _groupNotifier?.value = right(error), ); break; diff --git a/frontend/app_flowy/lib/plugins/board/application/toolbar/board_setting_bloc.dart b/frontend/app_flowy/lib/plugins/board/application/toolbar/board_setting_bloc.dart new file mode 100644 index 0000000000..480b3a4768 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/application/toolbar/board_setting_bloc.dart @@ -0,0 +1,46 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'dart:async'; +import 'package:dartz/dartz.dart'; + +part 'board_setting_bloc.freezed.dart'; + +class BoardSettingBloc extends Bloc { + final String gridId; + BoardSettingBloc({required this.gridId}) + : super(BoardSettingState.initial()) { + on( + (event, emit) async { + event.when(performAction: (action) { + emit(state.copyWith(selectedAction: Some(action))); + }); + }, + ); + } + + @override + Future close() async { + return super.close(); + } +} + +@freezed +class BoardSettingEvent with _$BoardSettingEvent { + const factory BoardSettingEvent.performAction(BoardSettingAction action) = + _PerformAction; +} + +@freezed +class BoardSettingState with _$BoardSettingState { + const factory BoardSettingState({ + required Option selectedAction, + }) = _BoardSettingState; + + factory BoardSettingState.initial() => BoardSettingState( + selectedAction: none(), + ); +} + +enum BoardSettingAction { + properties, +} diff --git a/frontend/app_flowy/lib/plugins/board/board.dart b/frontend/app_flowy/lib/plugins/board/board.dart index c55d7f2e17..213cc8bc3c 100644 --- a/frontend/app_flowy/lib/plugins/board/board.dart +++ b/frontend/app_flowy/lib/plugins/board/board.dart @@ -31,7 +31,7 @@ class BoardPluginBuilder implements PluginBuilder { class BoardPluginConfig implements PluginConfig { @override - bool get creatable => false; + bool get creatable => true; } 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 423a075867..21e38511b6 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/board_page.dart @@ -2,6 +2,7 @@ import 'dart:collection'; +import 'package:app_flowy/generated/locale_keys.g.dart'; 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'; @@ -9,16 +10,22 @@ 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:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/flowy_infra_ui_web.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:flowy_sdk/protobuf/flowy-grid/group.pbserver.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'; +import 'toolbar/board_toolbar.dart'; class BoardPage extends StatelessWidget { final ViewPB view; @@ -30,13 +37,15 @@ class BoardPage extends StatelessWidget { create: (context) => BoardBloc(view: view)..add(const BoardEvent.initial()), child: BlocBuilder( + buildWhen: (previous, current) => + previous.loadingState != current.loadingState, builder: (context, state) { return state.loadingState.map( loading: (_) => const Center(child: CircularProgressIndicator.adaptive()), finish: (result) { return result.successOrFail.fold( - (_) => BoardContent(), + (_) => const BoardContent(), (err) => FlowyErrorPage(err.toString()), ); }, @@ -47,67 +56,172 @@ class BoardPage extends StatelessWidget { } } -class BoardContent extends StatelessWidget { +class BoardContent extends StatefulWidget { + const BoardContent({Key? key}) : super(key: key); + + @override + State createState() => _BoardContentState(); +} + +class _BoardContentState extends State { + late ScrollController scrollController; + late AFBoardScrollManager scrollManager; + final config = AFBoardConfig( columnBackgroundColor: HexColor.fromHex('#F7F8FC'), ); - BoardContent({Key? key}) : super(key: key); + @override + void initState() { + scrollController = ScrollController(); + scrollManager = AFBoardScrollManager(); + super.initState(); + } @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'), + return BlocListener( + listener: (context, state) => _handleEditState(state, context), + child: BlocBuilder( + buildWhen: (previous, current) => + previous.groupIds.length != current.groupIds.length, + builder: (context, state) { + final theme = context.read(); + return Container( + color: theme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const _ToolbarBlocAdaptor(), + Expanded( + child: AFBoard( + scrollManager: scrollManager, + scrollController: scrollController, + dataController: context.read().boardController, + headerBuilder: _buildHeader, + footBuilder: _buildFooter, + cardBuilder: (_, column, columnItem) => _buildCard( + context, + column, + columnItem, + ), + columnConstraints: + const BoxConstraints.tightFor(width: 300), + config: AFBoardConfig( + columnBackgroundColor: HexColor.fromHex('#F7F8FC'), + ), + ), + ), + ], ), ), - ), - ); + ); + }, + ), + ); + } + + void _handleEditState(BoardState state, BuildContext context) { + state.editingRow.fold( + () => null, + (editingRow) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (editingRow.index != null) { + context + .read() + .add(BoardEvent.endEditRow(editingRow.row.id)); + } else { + scrollManager.scrollToBottom(editingRow.columnId, () { + context + .read() + .add(BoardEvent.endEditRow(editingRow.row.id)); + }); + } + }); }, ); } - Widget _buildHeader(BuildContext context, AFBoardColumnData columnData) { + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + 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), + title: Flexible( + fit: FlexFit.tight, + child: FlowyText.medium( + columnData.headerData.columnName, + fontSize: 14, + overflow: TextOverflow.clip, + color: context.read().textColor, + ), + ), + addIcon: SizedBox( + height: 20, + width: 20, + child: svgWidget( + "home/add", + color: context.read().iconColor, + ), + ), + onAddButtonClick: () { + context.read().add( + BoardEvent.createHeaderRow(columnData.id), + ); + }, height: 50, - margin: config.columnItemPadding, + margin: config.headerPadding, ); } Widget _buildFooter(BuildContext context, AFBoardColumnData columnData) { - return AppFlowyColumnFooter( - icon: const Icon(Icons.add, size: 20), - title: const Text('New'), + final group = columnData.customData as GroupPB; + if (group.isDefault) { + return const SizedBox(); + } else { + return AppFlowyColumnFooter( + icon: SizedBox( + height: 20, + width: 20, + child: svgWidget( + "home/add", + color: context.read().iconColor, + ), + ), + title: FlowyText.medium( + LocaleKeys.board_column_create_new_card.tr(), + fontSize: 14, + color: context.read().textColor, + ), height: 50, - margin: config.columnItemPadding, + margin: config.footerPadding, onAddButtonClick: () { - context.read().add(BoardEvent.createRow(columnData.id)); - }); + context.read().add( + BoardEvent.createBottomRow(columnData.id), + ); + }, + ); + } } - Widget _buildCard(BuildContext context, AFColumnItem item) { - final rowPB = (item as BoardColumnItem).row; + Widget _buildCard( + BuildContext context, + AFBoardColumnData column, + AFColumnItem columnItem, + ) { + final boardColumnItem = columnItem as BoardColumnItem; + final rowPB = 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)); + if (rowCache == null) return SizedBox(key: ObjectKey(columnItem)); final fieldCache = context.read().fieldCache; final gridId = context.read().gridId; @@ -118,21 +232,25 @@ class BoardContent extends StatelessWidget { ); final cellBuilder = BoardCellBuilder(cardController); - final isEditing = context.read().state.editingRow.fold( - () => false, - (editingRow) => editingRow.id == rowPB.id, - ); + bool isEditing = false; + context.read().state.editingRow.fold( + () => null, + (editingRow) { + isEditing = editingRow.row.id == columnItem.row.id; + }, + ); return AppFlowyColumnItemCard( - key: ObjectKey(item), + key: ValueKey(columnItem.id), + margin: config.cardPadding, + decoration: _makeBoxDecoration(context), child: BoardCard( gridId: gridId, + groupId: column.id, + fieldId: boardColumnItem.fieldId, isEditing: isEditing, cellBuilder: cellBuilder, dataController: cardController, - onEditEditing: (rowId) { - context.read().add(BoardEvent.endEditRow(rowId)); - }, openCard: (context) => _openCard( gridId, fieldCache, @@ -144,6 +262,16 @@ class BoardContent extends StatelessWidget { ); } + BoxDecoration _makeBoxDecoration(BuildContext context) { + final theme = context.read(); + final borderSide = BorderSide(color: theme.shader6, width: 1.0); + return BoxDecoration( + color: theme.surface, + border: Border.fromBorderSide(borderSide), + borderRadius: const BorderRadius.all(Radius.circular(6)), + ); + } + void _openCard(String gridId, GridFieldCache fieldCache, RowPB rowPB, GridRowCache rowCache, BuildContext context) { final rowInfo = RowInfo( @@ -159,13 +287,33 @@ class BoardContent extends StatelessWidget { ); FlowyOverlay.show( - context: context, - builder: (BuildContext context) { - return RowDetailPage( - cellBuilder: GridCellBuilder(delegate: dataController), - dataController: dataController, - ); - }); + context: context, + builder: (BuildContext context) { + return RowDetailPage( + cellBuilder: GridCellBuilder(delegate: dataController), + dataController: dataController, + ); + }, + ); + } +} + +class _ToolbarBlocAdaptor extends StatelessWidget { + const _ToolbarBlocAdaptor({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final bloc = context.read(); + final toolbarContext = BoardToolbarContext( + viewId: bloc.gridId, + fieldCache: bloc.fieldCache, + ); + + return BoardToolbar(toolbarContext: toolbarContext); + }, + ); } } diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/board_cell.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/board_cell.dart new file mode 100644 index 0000000000..580ebe6c5e --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/board_cell.dart @@ -0,0 +1,62 @@ +import 'package:app_flowy/plugins/grid/application/prelude.dart'; +import 'package:flowy_infra/notifier.dart'; + +abstract class FocusableBoardCell { + set becomeFocus(bool isFocus); +} + +class EditableCellNotifier { + final Notifier becomeFirstResponder = Notifier(); + + final Notifier resignFirstResponder = Notifier(); + + EditableCellNotifier(); +} + +class EditableRowNotifier { + Map cells = {}; + + void insertCell( + GridCellIdentifier cellIdentifier, + EditableCellNotifier notifier, + ) { + cells[EditableCellId.from(cellIdentifier)] = notifier; + } + + void becomeFirstResponder() { + for (final notifier in cells.values) { + notifier.becomeFirstResponder.notify(); + } + } + + void resignFirstResponder() { + for (final notifier in cells.values) { + notifier.resignFirstResponder.notify(); + } + } + + void dispose() { + for (final notifier in cells.values) { + notifier.resignFirstResponder.notify(); + } + + cells.clear(); + } +} + +abstract class EditableCell { + EditableCellNotifier? get editableNotifier; +} + +class EditableCellId { + String fieldId; + String rowId; + + EditableCellId(this.rowId, this.fieldId); + + factory EditableCellId.from(GridCellIdentifier cellIdentifier) => + EditableCellId( + cellIdentifier.rowId, + cellIdentifier.fieldId, + ); +} 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 index c816964d3c..f832d3749d 100644 --- 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 @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class BoardCheckboxCell extends StatefulWidget { + final String groupId; final GridCellControllerBuilder cellControllerBuilder; const BoardCheckboxCell({ + required this.groupId, required this.cellControllerBuilder, Key? key, }) : super(key: key); @@ -34,6 +36,8 @@ class _BoardCheckboxCellState extends State { return BlocProvider.value( value: _cellBloc, child: BlocBuilder( + buildWhen: (previous, current) => + previous.isSelected != current.isSelected, builder: (context, state) { final icon = state.isSelected ? svgWidget('editor/editor_check') 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 index 4a52d82116..47472a0f9f 100644 --- 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 @@ -1,13 +1,16 @@ 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/theme.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 String groupId; final GridCellControllerBuilder cellControllerBuilder; const BoardDateCell({ + required this.groupId, required this.cellControllerBuilder, Key? key, }) : super(key: key); @@ -34,6 +37,7 @@ class _BoardDateCellState extends State { return BlocProvider.value( value: _cellBloc, child: BlocBuilder( + buildWhen: (previous, current) => previous.dateStr != current.dateStr, builder: (context, state) { if (state.dateStr.isEmpty) { return const SizedBox(); @@ -42,7 +46,8 @@ class _BoardDateCellState extends State { alignment: Alignment.centerLeft, child: FlowyText.regular( state.dateStr, - fontSize: 14, + fontSize: 13, + color: context.read().shader3, ), ); } 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 index 096592583e..0f4aca6b61 100644 --- 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 @@ -5,9 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class BoardNumberCell extends StatefulWidget { + final String groupId; final GridCellControllerBuilder cellControllerBuilder; const BoardNumberCell({ + required this.groupId, required this.cellControllerBuilder, Key? key, }) : super(key: key); @@ -34,13 +36,14 @@ class _BoardNumberCellState extends State { return BlocProvider.value( value: _cellBloc, child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox(); } else { return Align( alignment: Alignment.centerLeft, - child: FlowyText.regular( + child: FlowyText.medium( state.content, fontSize: 14, ), 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 index 373bb3c850..8814d83a5e 100644 --- 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 @@ -1,14 +1,22 @@ 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:app_flowy/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class BoardSelectOptionCell extends StatefulWidget { +import 'board_cell.dart'; + +class BoardSelectOptionCell extends StatefulWidget with EditableCell { + final String groupId; final GridCellControllerBuilder cellControllerBuilder; + @override + final EditableCellNotifier? editableNotifier; const BoardSelectOptionCell({ + required this.groupId, required this.cellControllerBuilder, + this.editableNotifier, Key? key, }) : super(key: key); @@ -33,23 +41,41 @@ class _BoardSelectOptionCellState extends State { return BlocProvider.value( value: _cellBloc, child: BlocBuilder( + buildWhen: (previous, current) { + return previous.selectedOptions != current.selectedOptions; + }, builder: (context, state) { - final children = state.selectedOptions - .map((option) => SelectOptionTag.fromOption( + if (state.selectedOptions + .where((element) => element.id == widget.groupId) + .isNotEmpty || + state.selectedOptions.isEmpty) { + return const SizedBox(); + } else { + 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, + ), + ) + .toList(); + + return IntrinsicHeight( + child: Stack( + alignment: AlignmentDirectional.center, + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Wrap(spacing: 4, runSpacing: 2, children: children), + ), + _SelectOptionDialog( + controller: widget.cellControllerBuilder.build(), + ), + ], ), - ), - ); + ); + } }, ), ); @@ -61,3 +87,23 @@ class _BoardSelectOptionCellState extends State { super.dispose(); } } + +class _SelectOptionDialog extends StatelessWidget { + final GridSelectOptionCellController _controller; + const _SelectOptionDialog({ + Key? key, + required IGridCellController controller, + }) : _controller = controller as GridSelectOptionCellController, + super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell(onTap: () { + SelectOptionCellEditor.show( + context, + _controller, + () {}, + ); + }); + } +} 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 index 2da156ded8..02ab521222 100644 --- 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 @@ -1,13 +1,27 @@ 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:app_flowy/plugins/grid/presentation/widgets/cell/cell_builder.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 { +import 'board_cell.dart'; +import 'define.dart'; + +class BoardTextCell extends StatefulWidget with EditableCell { + final String groupId; + final bool isFocus; + @override + final EditableCellNotifier? editableNotifier; final GridCellControllerBuilder cellControllerBuilder; - const BoardTextCell({required this.cellControllerBuilder, Key? key}) - : super(key: key); + + const BoardTextCell({ + required this.groupId, + required this.cellControllerBuilder, + this.editableNotifier, + this.isFocus = false, + Key? key, + }) : super(key: key); @override State createState() => _BoardTextCellState(); @@ -15,14 +29,48 @@ class BoardTextCell extends StatefulWidget { class _BoardTextCellState extends State { late BoardTextCellBloc _cellBloc; + late TextEditingController _controller; + bool focusWhenInit = false; + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); @override void initState() { final cellController = widget.cellControllerBuilder.build() as GridCellController; - _cellBloc = BoardTextCellBloc(cellController: cellController) ..add(const BoardTextCellEvent.initial()); + _controller = TextEditingController(text: _cellBloc.state.content); + focusWhenInit = widget.isFocus; + + if (widget.isFocus) { + focusNode.requestFocus(); + } + + focusNode.addListener(() { + if (!focusNode.hasFocus) { + _cellBloc.add(const BoardTextCellEvent.enableEdit(false)); + + if (focusWhenInit) { + setState(() { + focusWhenInit = false; + }); + } + } + }); + + widget.editableNotifier?.becomeFirstResponder.addListener(() { + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + _cellBloc.add(const BoardTextCellEvent.enableEdit(true)); + }); + + widget.editableNotifier?.resignFirstResponder.addListener(() { + if (!mounted) return; + _cellBloc.add(const BoardTextCellEvent.enableEdit(false)); + }); + super.initState(); } @@ -30,32 +78,75 @@ class _BoardTextCellState extends State { 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, - ), - ), - ); + child: BlocListener( + listener: (context, state) { + if (_controller.text != state.content) { + _controller.text = state.content; } }, + child: BlocBuilder( + builder: (context, state) { + if (state.content.isEmpty && + state.enableEdit == false && + focusWhenInit == false) { + return const SizedBox(); + } + + // + Widget child; + if (state.enableEdit || focusWhenInit) { + child = _buildTextField(); + } else { + child = _buildText(state); + } + return Align(alignment: Alignment.centerLeft, child: child); + }, + ), ), ); } + Future focusChanged() async { + _cellBloc.add(BoardTextCellEvent.updateText(_controller.text)); + } + @override Future dispose() async { _cellBloc.close(); + _controller.dispose(); + focusNode.dispose(); super.dispose(); } + + Widget _buildText(BoardTextCellState state) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: BoardSizes.cardCellVPadding, + ), + child: FlowyText.medium(state.content, fontSize: 14), + ); + } + + Widget _buildTextField() { + return TextField( + controller: _controller, + focusNode: focusNode, + onChanged: (value) => focusChanged(), + onEditingComplete: () => focusNode.unfocus(), + maxLines: 1, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + fontFamily: 'Mulish', + ), + decoration: InputDecoration( + // Magic number 4 makes the textField take up the same space as FlowyText + contentPadding: EdgeInsets.symmetric( + vertical: BoardSizes.cardCellVPadding + 4, + ), + border: InputBorder.none, + isDense: true, + ), + ); + } } 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 index 31cca41e6a..40cdec7c2f 100644 --- 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 @@ -5,9 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class BoardUrlCell extends StatefulWidget { + final String groupId; final GridCellControllerBuilder cellControllerBuilder; const BoardUrlCell({ + required this.groupId, required this.cellControllerBuilder, Key? key, }) : super(key: key); @@ -34,6 +36,7 @@ class _BoardUrlCellState extends State { return BlocProvider.value( value: _cellBloc, child: BlocBuilder( + buildWhen: (previous, current) => previous.content != current.content, builder: (context, state) { if (state.content.isEmpty) { return const SizedBox(); diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart index a5c7b7ba2c..924faef0a5 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/card.dart @@ -7,25 +7,26 @@ 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 'board_cell.dart'; import 'card_cell_builder.dart'; import 'card_container.dart'; -typedef OnEndEditing = void Function(String rowId); - class BoardCard extends StatefulWidget { final String gridId; + final String groupId; + final String fieldId; final bool isEditing; final CardDataController dataController; final BoardCellBuilder cellBuilder; - final OnEndEditing onEditEditing; final void Function(BuildContext) openCard; const BoardCard({ required this.gridId, + required this.groupId, + required this.fieldId, required this.isEditing, required this.dataController, required this.cellBuilder, - required this.onEditEditing, required this.openCard, Key? key, }) : super(key: key); @@ -36,13 +37,16 @@ class BoardCard extends StatefulWidget { class _BoardCardState extends State { late BoardCardBloc _cardBloc; + late EditableRowNotifier rowNotifier; @override void initState() { + rowNotifier = EditableRowNotifier(); _cardBloc = BoardCardBloc( gridId: widget.gridId, + fieldId: widget.fieldId, dataController: widget.dataController, - ); + )..add(const BoardCardEvent.initial()); super.initState(); } @@ -51,16 +55,28 @@ class _BoardCardState extends State { return BlocProvider.value( value: _cardBloc, child: BlocBuilder( + buildWhen: (previous, current) { + return previous.cells.length != current.cells.length; + }, builder: (context, state) { return BoardCardContainer( accessoryBuilder: (context) { - return [const _CardMoreOption()]; + return [ + _CardEditOption( + startEditing: () => rowNotifier.becomeFirstResponder(), + ), + const _CardMoreOption(), + ]; }, onTap: (context) { widget.openCard(context); }, child: Column( - children: _makeCells(context, state.gridCellMap), + mainAxisSize: MainAxisSize.min, + children: _makeCells( + context, + state.cells.map((cell) => cell.identifier).toList(), + ), ), ); }, @@ -68,16 +84,42 @@ class _BoardCardState extends State { ); } - 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), + List _makeCells( + BuildContext context, + List cells, + ) { + final List children = []; + cells.asMap().forEach( + (int index, GridCellIdentifier cellId) { + final cellNotifier = EditableCellNotifier(); + Widget child = widget.cellBuilder.buildCell( + widget.groupId, + cellId, + widget.isEditing, + cellNotifier, + ); + + if (index == 0) { + rowNotifier.insertCell(cellId, cellNotifier); + } + + child = Padding( + key: cellId.key(), + padding: const EdgeInsets.only(left: 4, right: 4), child: child, ); + + children.add(child); }, - ).toList(); + ); + return children; + } + + @override + Future dispose() async { + rowNotifier.dispose(); + _cardBloc.close(); + super.dispose(); } } @@ -86,7 +128,11 @@ class _CardMoreOption extends StatelessWidget with CardAccessory { @override Widget build(BuildContext context) { - return svgWidget('home/details', color: context.read().iconColor); + return Padding( + padding: const EdgeInsets.all(3.0), + child: + svgWidget('grid/details', color: context.read().iconColor), + ); } @override @@ -96,3 +142,27 @@ class _CardMoreOption extends StatelessWidget with CardAccessory { ).show(context, direction: AnchorDirection.bottomWithCenterAligned); } } + +class _CardEditOption extends StatelessWidget with CardAccessory { + final VoidCallback startEditing; + const _CardEditOption({ + required this.startEditing, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(3.0), + child: svgWidget( + 'editor/edit', + color: context.read().iconColor, + ), + ); + } + + @override + void onTap(BuildContext context) { + startEditing(); + } +} 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 index 10ae0db680..99bd3f3b3a 100644 --- 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 @@ -2,6 +2,7 @@ import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_servic import 'package:flowy_sdk/protobuf/flowy-grid/field_entities.pb.dart'; import 'package:flutter/material.dart'; +import 'board_cell.dart'; import 'board_checkbox_cell.dart'; import 'board_date_cell.dart'; import 'board_number_cell.dart'; @@ -19,7 +20,12 @@ class BoardCellBuilder { BoardCellBuilder(this.delegate); - Widget buildCell(GridCellIdentifier cellId) { + Widget buildCell( + String groupId, + GridCellIdentifier cellId, + bool isEditing, + EditableCellNotifier cellNotifier, + ) { final cellControllerBuilder = GridCellControllerBuilder( delegate: delegate, cellId: cellId, @@ -30,36 +36,46 @@ class BoardCellBuilder { switch (cellId.fieldType) { case FieldType.Checkbox: return BoardCheckboxCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, key: key, ); case FieldType.DateTime: return BoardDateCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, key: key, ); case FieldType.SingleSelect: return BoardSelectOptionCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, key: key, ); case FieldType.MultiSelect: return BoardSelectOptionCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, + editableNotifier: cellNotifier, key: key, ); case FieldType.Number: return BoardNumberCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, key: key, ); case FieldType.RichText: return BoardTextCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, + isFocus: isEditing, + editableNotifier: cellNotifier, key: key, ); case FieldType.URL: return BoardUrlCell( + groupId: groupId, cellControllerBuilder: cellControllerBuilder, key: key, ); 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 index abca27e5c5..d28e6712c9 100644 --- a/frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/card_container.dart @@ -26,8 +26,8 @@ class BoardCardContainer extends StatelessWidget { final accessories = accessoryBuilder!(context); if (accessories.isNotEmpty) { container = _CardEnterRegion( - child: container, accessories: accessories, + child: container, ); } } @@ -69,25 +69,48 @@ class CardAccessoryContainer extends StatelessWidget { style: HoverStyle( hoverColor: theme.hover, backgroundColor: theme.surface, + borderRadius: BorderRadius.zero, ), - builder: (_, onHover) => Container( - width: 26, - height: 26, - padding: const EdgeInsets.all(3), + builder: (_, onHover) => SizedBox( + width: 24, + height: 24, child: accessory, ), ); return GestureDetector( - child: hover, behavior: HitTestBehavior.opaque, onTap: () => accessory.onTap(context), + child: hover, ); }).toList(); - return Wrap(children: children, spacing: 6); + return Container( + clipBehavior: Clip.hardEdge, + decoration: _makeBoxDecoration(context), + child: Row(children: children), + ); } } +BoxDecoration _makeBoxDecoration(BuildContext context) { + final theme = context.read(); + final borderSide = BorderSide(color: theme.shader6, width: 1.0); + return BoxDecoration( + color: Colors.transparent, + border: Border.fromBorderSide(borderSide), + // boxShadow: const [ + // BoxShadow( + // color: Colors.transparent, + // spreadRadius: 0, + // blurRadius: 5, + // offset: Offset.zero, + // ) + // ], + + borderRadius: const BorderRadius.all(Radius.circular(4)), + ); +} + class _CardEnterRegion extends StatelessWidget { final Widget child; final List accessories; @@ -102,8 +125,9 @@ class _CardEnterRegion extends StatelessWidget { builder: (context, onEnter, _) { List children = [child]; if (onEnter) { - children.add(CardAccessoryContainer(accessories: accessories) - .positioned(right: 0)); + children.add(CardAccessoryContainer( + accessories: accessories, + ).positioned(right: 0)); } return MouseRegion( @@ -116,7 +140,7 @@ class _CardEnterRegion extends StatelessWidget { .onEnter = false, child: IntrinsicHeight( child: Stack( - alignment: AlignmentDirectional.center, + alignment: AlignmentDirectional.topEnd, fit: StackFit.expand, children: children, )), diff --git a/frontend/app_flowy/lib/plugins/board/presentation/card/define.dart b/frontend/app_flowy/lib/plugins/board/presentation/card/define.dart new file mode 100644 index 0000000000..5fc55743db --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/card/define.dart @@ -0,0 +1,3 @@ +class BoardSizes { + static double get cardCellVPadding => 6; +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart new file mode 100644 index 0000000000..76ab265a90 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_setting.dart @@ -0,0 +1,168 @@ +import 'package:app_flowy/generated/locale_keys.g.dart'; +import 'package:app_flowy/plugins/board/application/toolbar/board_setting_bloc.dart'; +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:app_flowy/plugins/grid/presentation/layout/sizes.dart'; +import 'package:app_flowy/plugins/grid/presentation/widgets/toolbar/grid_property.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'board_toolbar.dart'; + +class BoardSettingContext { + final String viewId; + final GridFieldCache fieldCache; + BoardSettingContext({ + required this.viewId, + required this.fieldCache, + }); + + factory BoardSettingContext.from(BoardToolbarContext toolbarContext) => + BoardSettingContext( + viewId: toolbarContext.viewId, + fieldCache: toolbarContext.fieldCache, + ); +} + +class BoardSettingList extends StatelessWidget { + final BoardSettingContext settingContext; + final Function(BoardSettingAction, BoardSettingContext) onAction; + const BoardSettingList({ + required this.settingContext, + required this.onAction, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => BoardSettingBloc(gridId: settingContext.viewId), + child: BlocListener( + listenWhen: (previous, current) => + previous.selectedAction != current.selectedAction, + listener: (context, state) { + state.selectedAction.foldLeft(null, (_, action) { + FlowyOverlay.of(context).remove(identifier()); + onAction(action, settingContext); + }); + }, + child: BlocBuilder( + builder: (context, state) { + return _renderList(); + }, + ), + ), + ); + } + + Widget _renderList() { + final cells = BoardSettingAction.values.map((action) { + return _SettingItem(action: action); + }).toList(); + + return SizedBox( + width: 140, + child: ListView.separated( + shrinkWrap: true, + controller: ScrollController(), + itemCount: cells.length, + separatorBuilder: (context, index) { + return VSpace(GridSize.typeOptionSeparatorHeight); + }, + physics: StyledScrollPhysics(), + itemBuilder: (BuildContext context, int index) { + return cells[index]; + }, + ), + ); + } + + static void show(BuildContext context, BoardSettingContext settingContext) { + final list = BoardSettingList( + settingContext: settingContext, + onAction: (action, settingContext) { + switch (action) { + case BoardSettingAction.properties: + GridPropertyList( + gridId: settingContext.viewId, + fieldCache: settingContext.fieldCache) + .show(context); + break; + } + }, + ); + + FlowyOverlay.of(context).insertWithAnchor( + widget: OverlayContainer( + constraints: BoxConstraints.loose(const Size(140, 400)), + child: list, + ), + identifier: identifier(), + anchorContext: context, + anchorDirection: AnchorDirection.bottomRight, + style: FlowyOverlayStyle(blur: false), + ); + } + + static String identifier() { + return (BoardSettingList).toString(); + } +} + +class _SettingItem extends StatelessWidget { + final BoardSettingAction action; + + const _SettingItem({ + required this.action, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = context.read(); + final isSelected = context + .read() + .state + .selectedAction + .foldLeft(false, (_, selectedAction) => selectedAction == action); + + return SizedBox( + height: 30, + child: FlowyButton( + isSelected: isSelected, + text: FlowyText.medium(action.title(), + fontSize: 12, color: theme.textColor), + hoverColor: theme.hover, + onTap: () { + context + .read() + .add(BoardSettingEvent.performAction(action)); + }, + leftIcon: svgWidget(action.iconName(), color: theme.iconColor), + ), + ); + } +} + +extension _GridSettingExtension on BoardSettingAction { + String iconName() { + switch (this) { + case BoardSettingAction.properties: + return 'grid/setting/properties'; + } + } + + String title() { + switch (this) { + case BoardSettingAction.properties: + return LocaleKeys.grid_settings_Properties.tr(); + } + } +} diff --git a/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart new file mode 100644 index 0000000000..fae27851a9 --- /dev/null +++ b/frontend/app_flowy/lib/plugins/board/presentation/toolbar/board_toolbar.dart @@ -0,0 +1,60 @@ +import 'package:app_flowy/plugins/grid/application/field/field_cache.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import 'board_setting.dart'; + +class BoardToolbarContext { + final String viewId; + final GridFieldCache fieldCache; + + BoardToolbarContext({ + required this.viewId, + required this.fieldCache, + }); +} + +class BoardToolbar extends StatelessWidget { + final BoardToolbarContext toolbarContext; + const BoardToolbar({ + required this.toolbarContext, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 40, + child: Row( + children: [ + _SettingButton( + settingContext: BoardSettingContext.from(toolbarContext), + ), + ], + ), + ); + } +} + +class _SettingButton extends StatelessWidget { + final BoardSettingContext settingContext; + const _SettingButton({required this.settingContext, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = context.read(); + return FlowyIconButton( + hoverColor: theme.hover, + width: 22, + onPressed: () => BoardSettingList.show(context, settingContext), + icon: Padding( + padding: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 3.0), + child: svgWidget("grid/setting/setting"), + ), + ); + } +} diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart b/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart index bd4b651da8..66c1f6dfba 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/banner.dart @@ -40,11 +40,11 @@ class DocumentBanner extends StatelessWidget { downColor: theme.main1, outlineColor: Colors.white, borderRadius: Corners.s8Border, + onPressed: onRestore, child: FlowyText.medium( LocaleKeys.deletePagePrompt_restore.tr(), color: Colors.white, - fontSize: 14), - onPressed: onRestore), + fontSize: 14)), const HSpace(20), BaseStyledButton( minWidth: 220, @@ -55,11 +55,11 @@ class DocumentBanner extends StatelessWidget { downColor: theme.main1, outlineColor: Colors.white, borderRadius: Corners.s8Border, + onPressed: onDelete, child: FlowyText.medium( LocaleKeys.deletePagePrompt_deletePermanent.tr(), color: Colors.white, - fontSize: 14), - onPressed: onDelete), + fontSize: 14)), ], ), ), diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart b/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart index 4f09b64053..8bbd5bc0d0 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/style_widgets.dart @@ -16,7 +16,10 @@ class EditorCheckboxBuilder extends QuillCheckboxBuilder { EditorCheckboxBuilder(this.theme); @override - Widget build({required BuildContext context, required bool isChecked, required ValueChanged onChanged}) { + Widget build( + {required BuildContext context, + required bool isChecked, + required ValueChanged onChanged}) { return FlowyEditorCheckbox( theme: theme, isChecked: isChecked, @@ -37,10 +40,10 @@ class FlowyEditorCheckbox extends StatefulWidget { }) : super(key: key); @override - _FlowyEditorCheckboxState createState() => _FlowyEditorCheckboxState(); + FlowyEditorCheckboxState createState() => FlowyEditorCheckboxState(); } -class _FlowyEditorCheckboxState extends State { +class FlowyEditorCheckboxState extends State { late bool isChecked; @override @@ -51,7 +54,9 @@ class _FlowyEditorCheckboxState extends State { @override Widget build(BuildContext context) { - final icon = isChecked ? svgWidget('editor/editor_check') : svgWidget('editor/editor_uncheck'); + final icon = isChecked + ? svgWidget('editor/editor_check') + : svgWidget('editor/editor_uncheck'); return Align( alignment: Alignment.centerLeft, child: FlowyIconButton( diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart index 55f17ac558..280209c64a 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/check_button.dart @@ -28,10 +28,10 @@ class FlowyCheckListButton extends StatefulWidget { final String tooltipText; @override - _FlowyCheckListButtonState createState() => _FlowyCheckListButtonState(); + FlowyCheckListButtonState createState() => FlowyCheckListButtonState(); } -class _FlowyCheckListButtonState extends State { +class FlowyCheckListButtonState extends State { bool? _isToggled; Style get _selectionStyle => widget.controller.getSelectionStyle(); diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart index 57299bd6cc..1f262483a8 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/color_picker.dart @@ -24,10 +24,10 @@ class FlowyColorButton extends StatefulWidget { final QuillIconTheme? iconTheme; @override - _FlowyColorButtonState createState() => _FlowyColorButtonState(); + FlowyColorButtonState createState() => FlowyColorButtonState(); } -class _FlowyColorButtonState extends State { +class FlowyColorButtonState extends State { late bool _isToggledColor; late bool _isToggledBackground; late bool _isWhite; @@ -37,10 +37,14 @@ class _FlowyColorButtonState extends State { void _didChangeEditingValue() { setState(() { - _isToggledColor = _getIsToggledColor(widget.controller.getSelectionStyle().attributes); - _isToggledBackground = _getIsToggledBackground(widget.controller.getSelectionStyle().attributes); - _isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff'; + _isToggledColor = + _getIsToggledColor(widget.controller.getSelectionStyle().attributes); + _isToggledBackground = _getIsToggledBackground( + widget.controller.getSelectionStyle().attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; }); } @@ -49,8 +53,10 @@ class _FlowyColorButtonState extends State { super.initState(); _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff'; + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; widget.controller.addListener(_didChangeEditingValue); } @@ -69,9 +75,12 @@ class _FlowyColorButtonState extends State { oldWidget.controller.removeListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue); _isToggledColor = _getIsToggledColor(_selectionStyle.attributes); - _isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); - _isWhite = _isToggledColor && _selectionStyle.attributes['color']!.value == '#ffffff'; - _isWhitebackground = _isToggledBackground && _selectionStyle.attributes['background']!.value == '#ffffff'; + _isToggledBackground = + _getIsToggledBackground(_selectionStyle.attributes); + _isWhite = _isToggledColor && + _selectionStyle.attributes['color']!.value == '#ffffff'; + _isWhitebackground = _isToggledBackground && + _selectionStyle.attributes['background']!.value == '#ffffff'; } } @@ -88,9 +97,10 @@ class _FlowyColorButtonState extends State { final fillColor = _isToggledColor && !widget.background && _isWhite ? stringToColor('#ffffff') : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); - final fillColorBackground = _isToggledBackground && widget.background && _isWhitebackground - ? stringToColor('#ffffff') - : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); + final fillColorBackground = + _isToggledBackground && widget.background && _isWhitebackground + ? stringToColor('#ffffff') + : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); return Tooltip( message: LocaleKeys.toolbar_highlight.tr(), @@ -99,7 +109,8 @@ class _FlowyColorButtonState extends State { highlightElevation: 0, hoverElevation: 0, size: widget.iconSize * kIconButtonFactor, - icon: Icon(widget.icon, size: widget.iconSize, color: theme.iconTheme.color), + icon: Icon(widget.icon, + size: widget.iconSize, color: theme.iconTheme.color), fillColor: widget.background ? fillColorBackground : fillColor, onPressed: _showColorPicker, ), @@ -112,13 +123,16 @@ class _FlowyColorButtonState extends State { hex = hex.substring(2); } hex = '#$hex'; - widget.controller.formatSelection(widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); + widget.controller.formatSelection( + widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); Navigator.of(context).pop(); } void _showColorPicker() { final style = widget.controller.getSelectionStyle(); - final values = style.values.where((v) => v.key == Attribute.background.key).map((v) => v.value); + final values = style.values + .where((v) => v.key == Attribute.background.key) + .map((v) => v.value); int initialColor = 0; if (values.isNotEmpty) { assert(values.length == 1); @@ -160,7 +174,9 @@ class FlowyColorPicker extends StatefulWidget { ]; final Function(Color?) onColorChanged; final int initialColor; - FlowyColorPicker({Key? key, required this.onColorChanged, this.initialColor = 0}) : super(key: key); + FlowyColorPicker( + {Key? key, required this.onColorChanged, this.initialColor = 0}) + : super(key: key); @override State createState() => _FlowyColorPickerState(); @@ -178,8 +194,10 @@ class _FlowyColorPickerState extends State { const double crossAxisSpacing = 10; final numberOfRows = (widget.colors.length / crossAxisCount).ceil(); - const perRowHeight = ((width - ((crossAxisCount - 1) * mainAxisSpacing)) / crossAxisCount); - final totalHeight = numberOfRows * perRowHeight + numberOfRows * crossAxisSpacing; + const perRowHeight = + ((width - ((crossAxisCount - 1) * mainAxisSpacing)) / crossAxisCount); + final totalHeight = + numberOfRows * perRowHeight + numberOfRows * crossAxisSpacing; return Container( constraints: BoxConstraints.tightFor(width: width, height: totalHeight), @@ -198,7 +216,8 @@ class _FlowyColorPickerState extends State { delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { if (widget.colors.length > index) { - final isSelected = widget.colors[index] == widget.initialColor; + final isSelected = + widget.colors[index] == widget.initialColor; return ColorItem( color: Color(widget.colors[index]), onPressed: widget.onColorChanged, @@ -242,7 +261,8 @@ class ColorItem extends StatelessWidget { ); } else { return RawMaterialButton( - shape: const CircleBorder(side: BorderSide(color: Colors.white, width: 8)) + + shape: const CircleBorder( + side: BorderSide(color: Colors.white, width: 8)) + CircleBorder(side: BorderSide(color: color, width: 4)), onPressed: () { if (isSelected) { diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart index 427c5cf559..2db98b5af0 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/header_button.dart @@ -16,10 +16,10 @@ class FlowyHeaderStyleButton extends StatefulWidget { final double iconSize; @override - _FlowyHeaderStyleButtonState createState() => _FlowyHeaderStyleButtonState(); + FlowyHeaderStyleButtonState createState() => FlowyHeaderStyleButtonState(); } -class _FlowyHeaderStyleButtonState extends State { +class FlowyHeaderStyleButtonState extends State { Attribute? _value; Style get _selectionStyle => widget.controller.getSelectionStyle(); @@ -28,22 +28,27 @@ class _FlowyHeaderStyleButtonState extends State { void initState() { super.initState(); setState(() { - _value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; }); widget.controller.addListener(_didChangeEditingValue); } @override Widget build(BuildContext context) { - final _valueToText = { + final valueToText = { Attribute.h1: 'H1', Attribute.h2: 'H2', Attribute.h3: 'H3', }; - final _valueAttribute = [Attribute.h1, Attribute.h2, Attribute.h3]; - final _valueString = ['H1', 'H2', 'H3']; - final _attributeImageName = ['editor/H1', 'editor/H2', 'editor/H3']; + final valueAttribute = [ + Attribute.h1, + Attribute.h2, + Attribute.h3 + ]; + final valueString = ['H1', 'H2', 'H3']; + final attributeImageName = ['editor/H1', 'editor/H2', 'editor/H3']; return Row( mainAxisSize: MainAxisSize.min, @@ -52,18 +57,18 @@ class _FlowyHeaderStyleButtonState extends State { // _valueToText[_value] == _valueString[index] ? svg('editor/H1', color: Colors.white) : svg('editor/H1'); final headerTitle = "${LocaleKeys.toolbar_header.tr()} ${index + 1}"; - final _isToggled = _valueToText[_value] == _valueString[index]; + final isToggled = valueToText[_value] == valueString[index]; return ToolbarIconButton( onPressed: () { - if (_isToggled) { + if (isToggled) { widget.controller.formatSelection(Attribute.header); } else { - widget.controller.formatSelection(_valueAttribute[index]); + widget.controller.formatSelection(valueAttribute[index]); } }, width: widget.iconSize * kIconButtonFactor, - iconName: _attributeImageName[index], - isToggled: _isToggled, + iconName: attributeImageName[index], + isToggled: isToggled, tooltipText: headerTitle, ); }), @@ -72,7 +77,8 @@ class _FlowyHeaderStyleButtonState extends State { void _didChangeEditingValue() { setState(() { - _value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; }); } @@ -82,7 +88,8 @@ class _FlowyHeaderStyleButtonState extends State { if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue); - _value = _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; + _value = + _selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; } } diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart index 60b654302f..428c45e400 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/link_button.dart @@ -19,10 +19,10 @@ class FlowyLinkStyleButton extends StatefulWidget { final double iconSize; @override - _FlowyLinkStyleButtonState createState() => _FlowyLinkStyleButtonState(); + FlowyLinkStyleButtonState createState() => FlowyLinkStyleButtonState(); } -class _FlowyLinkStyleButtonState extends State { +class FlowyLinkStyleButtonState extends State { void _didChangeSelection() { setState(() {}); } @@ -75,7 +75,9 @@ class _FlowyLinkStyleButtonState extends State { void _openLinkDialog(BuildContext context) { final style = widget.controller.getSelectionStyle(); - final values = style.values.where((v) => v.key == Attribute.link.key).map((v) => v.value); + final values = style.values + .where((v) => v.key == Attribute.link.key) + .map((v) => v.value); String value = ""; if (values.isNotEmpty) { assert(values.length == 1); diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart index 89fd72981d..2ecb98c3ea 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/toggle_button.dart @@ -21,10 +21,10 @@ class FlowyToggleStyleButton extends StatefulWidget { }) : super(key: key); @override - _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); + ToggleStyleButtonState createState() => ToggleStyleButtonState(); } -class _ToggleStyleButtonState extends State { +class ToggleStyleButtonState extends State { bool? _isToggled; Style get _selectionStyle => widget.controller.getSelectionStyle(); @override @@ -77,6 +77,8 @@ class _ToggleStyleButtonState extends State { } void _toggleAttribute() { - widget.controller.formatSelection(_isToggled! ? Attribute.clone(widget.attribute, null) : widget.attribute); + widget.controller.formatSelection(_isToggled! + ? Attribute.clone(widget.attribute, null) + : widget.attribute); } } diff --git a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart index 8dae41f986..d649340066 100644 --- a/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart +++ b/frontend/app_flowy/lib/plugins/doc/presentation/toolbar/tool_bar.dart @@ -32,7 +32,8 @@ class EditorToolbar extends StatelessWidget implements PreferredSizeWidget { return Container( color: Theme.of(context).canvasColor, constraints: BoxConstraints.tightFor(height: preferredSize.height), - child: ToolbarButtonList(buttons: children).padding(horizontal: 4, vertical: 4), + child: ToolbarButtonList(buttons: children) + .padding(horizontal: 4, vertical: 4), ); } @@ -168,10 +169,11 @@ class ToolbarButtonList extends StatefulWidget { final List buttons; @override - _ToolbarButtonListState createState() => _ToolbarButtonListState(); + ToolbarButtonListState createState() => ToolbarButtonListState(); } -class _ToolbarButtonListState extends State with WidgetsBindingObserver { +class ToolbarButtonListState extends State + with WidgetsBindingObserver { final ScrollController _controller = ScrollController(); bool _showLeftArrow = false; bool _showRightArrow = false; @@ -196,7 +198,8 @@ class _ToolbarButtonListState extends State with WidgetsBindi return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { List children = []; - double width = (widget.buttons.length + 2) * defaultIconSize * kIconButtonFactor; + double width = + (widget.buttons.length + 2) * defaultIconSize * kIconButtonFactor; final isFit = constraints.maxWidth > width; if (!isFit) { children.add(_buildLeftArrow()); @@ -233,8 +236,10 @@ class _ToolbarButtonListState extends State with WidgetsBindi void _handleScroll() { if (!mounted) return; setState(() { - _showLeftArrow = _controller.position.minScrollExtent != _controller.position.pixels; - _showRightArrow = _controller.position.maxScrollExtent != _controller.position.pixels; + _showLeftArrow = + _controller.position.minScrollExtent != _controller.position.pixels; + _showRightArrow = + _controller.position.maxScrollExtent != _controller.position.pixels; }); } diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart index 5da5fce86d..4805ad8b7a 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_listener.dart @@ -11,13 +11,15 @@ typedef UpdateFieldNotifiedValue = Either; class CellListener { final String rowId; final String fieldId; - PublishNotifier? _updateCellNotifier = PublishNotifier(); + PublishNotifier? _updateCellNotifier = + PublishNotifier(); GridNotificationListener? _listener; CellListener({required this.rowId, required this.fieldId}); void start({required void Function(UpdateFieldNotifiedValue) onCellChanged}) { _updateCellNotifier?.addPublishListener(onCellChanged); - _listener = GridNotificationListener(objectId: "$rowId:$fieldId", handler: _handler); + _listener = GridNotificationListener( + objectId: "$rowId:$fieldId", handler: _handler); } void _handler(GridNotification ty, Either result) { diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart index 1f14c7c54a..3d816b21d1 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_cache.dart @@ -33,10 +33,17 @@ class GridCellCache { required this.gridId, }); - void remove(String fieldId) { + void removeCellWithFieldId(String fieldId) { _cellDataByFieldId.remove(fieldId); } + void remove(GridCellCacheKey key) { + var map = _cellDataByFieldId[key.fieldId]; + if (map != null) { + map.remove(key.rowId); + } + } + void insert(GridCellCacheKey key, T value) { var map = _cellDataByFieldId[key.fieldId]; if (map == null) { diff --git a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart index c4b3430199..a6a1ba43a9 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/cell/cell_service/cell_data_loader.dart @@ -24,18 +24,21 @@ class GridCellDataLoader { Future loadData() { final fut = service.getCell(cellId: cellId); return fut.then( - (result) => result.fold((GridCellPB cell) { - try { - return parser.parserData(cell.data); - } catch (e, s) { - Log.error('$parser parser cellData failed, $e'); - Log.error('Stack trace \n $s'); + (result) => result.fold( + (GridCellPB cell) { + try { + return parser.parserData(cell.data); + } catch (e, s) { + Log.error('$parser parser cellData failed, $e'); + Log.error('Stack trace \n $s'); + return null; + } + }, + (err) { + Log.error(err); return null; - } - }, (err) { - Log.error(err); - return null; - }), + }, + ), ); } } @@ -58,7 +61,8 @@ class DateCellDataParser implements IGridCellDataParser { } } -class SelectOptionCellDataParser implements IGridCellDataParser { +class SelectOptionCellDataParser + implements IGridCellDataParser { @override SelectOptionCellDataPB? parserData(List data) { if (data.isEmpty) { 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 bb750b4b89..48e82cc906 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 @@ -71,6 +71,6 @@ class GridCellIdentifier with _$GridCellIdentifier { FieldType get fieldType => field.fieldType; ValueKey key() { - return ValueKey(rowId + fieldId + "${field.fieldType}"); + return ValueKey("$rowId$fieldId${field.fieldType}"); } } 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 1068cbf36b..d716133d05 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 @@ -190,7 +190,10 @@ class IGridCellController extends Equatable { /// cell display: $12 _cellListener?.start(onCellChanged: (result) { result.fold( - (_) => _loadData(), + (_) { + _cellsCache.remove(_cacheKey); + _loadData(); + }, (err) => Log.error(err), ); }); @@ -279,8 +282,8 @@ class IGridCellController extends Equatable { _loadDataOperation?.cancel(); _loadDataOperation = Timer(const Duration(milliseconds: 10), () { _cellDataLoader.loadData().then((data) { - _cellDataNotifier?.value = data; _cellsCache.insert(_cacheKey, GridCell(object: data)); + _cellDataNotifier?.value = data; }); }); } 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 c0584a084b..a7124b7a3d 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 @@ -119,13 +119,13 @@ class DateCalBloc extends Bloc { } String timeFormatPrompt(FlowyError error) { - String msg = LocaleKeys.grid_field_invalidTimeFormat.tr() + ". "; + String msg = "${LocaleKeys.grid_field_invalidTimeFormat.tr()}. "; switch (state.dateTypeOptionPB.timeFormat) { case TimeFormat.TwelveHour: - msg = msg + "e.g. 01: 00 AM"; + msg = "${msg}e.g. 01: 00 AM"; break; case TimeFormat.TwentyFourHour: - msg = msg + "e.g. 13: 00"; + msg = "${msg}e.g. 13: 00"; break; default: break; 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 4150093275..4d453eca25 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 @@ -79,7 +79,7 @@ class DateCellState with _$DateCellState { String _dateStrFromCellData(DateCellDataPB? cellData) { String dateStr = ""; if (cellData != null) { - dateStr = cellData.date + " " + cellData.time; + dateStr = "${cellData.date} ${cellData.time}"; } return dateStr; } 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 8d52252e2a..349d95d13f 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 @@ -1,12 +1,14 @@ import 'dart:async'; + +import 'package:app_flowy/plugins/grid/application/cell/cell_service/cell_service.dart'; +import 'package:collection/collection.dart'; import 'package:dartz/dartz.dart'; import 'package:flowy_sdk/log.dart'; 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'; + import 'select_option_service.dart'; -import 'package:collection/collection.dart'; part 'select_option_editor_bloc.freezed.dart'; 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 index f11db25167..55733b9b7b 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/grid_data_controller.dart @@ -46,7 +46,8 @@ class GridDataController { GridDataController({required ViewPB view}) : gridId = view.id, - _blocks = LinkedHashMap.new(), + // ignore: prefer_collection_literals + _blocks = LinkedHashMap(), _gridFFIService = GridFFIService(gridId: view.id), fieldCache = GridFieldCache(gridId: view.id); 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 c8b6873d91..40dd5eeda1 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/grid_service.dart @@ -27,10 +27,18 @@ class GridFFIService { return GridEventCreateTableRow(payload).send(); } - Future> createBoardCard(String groupId) { + Future> createBoardCard( + String groupId, + String? startRowId, + ) { CreateBoardCardPayloadPB payload = CreateBoardCardPayloadPB.create() ..gridId = gridId ..groupId = groupId; + + if (startRowId != null) { + payload.startRowId = startRowId; + } + return GridEventCreateBoardCard(payload).send(); } 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 index 68c8b6f519..618d73cbc1 100644 --- a/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart +++ b/frontend/app_flowy/lib/plugins/grid/application/row/row_cache.dart @@ -52,7 +52,8 @@ class GridRowCache { // notifier.onRowFieldsChanged(() => _rowChangeReasonNotifier .receive(const RowsChangedReason.fieldDidChange())); - notifier.onRowFieldChanged((field) => _cellCache.remove(field.id)); + notifier.onRowFieldChanged( + (field) => _cellCache.removeCellWithFieldId(field.id)); _rowInfos = block.rows.map((rowPB) => buildGridRow(rowPB)).toList(); } @@ -209,7 +210,8 @@ class GridRowCache { } GridCellMap _makeGridCells(String rowId, RowPB? row) { - var cellDataMap = GridCellMap.new(); + // ignore: prefer_collection_literals + var cellDataMap = GridCellMap(); for (final field in _fieldNotifier.fields) { if (field.visibility) { cellDataMap[field.id] = GridCellIdentifier( 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 9b3f281130..8a88316473 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 @@ -190,12 +190,12 @@ class CellAccessoryContainer extends StatelessWidget { ), ); return GestureDetector( - child: hover, behavior: HitTestBehavior.opaque, onTap: () => accessory.onTap(), + child: hover, ); }).toList(); - return Wrap(children: children, spacing: 6); + return Wrap(spacing: 6, children: children); } } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart index ed09ec3f36..eea58775dd 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/cell_container.dart @@ -44,8 +44,8 @@ class CellContainer extends StatelessWidget { if (accessories.isNotEmpty) { container = _GridCellEnterRegion( - child: container, accessories: accessories, + child: container, ); } } 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 25ecf613a6..92b0a3233d 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 @@ -297,9 +297,8 @@ class _DateTypeOptionButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = context.watch(); - final title = LocaleKeys.grid_field_dateFormat.tr() + - " &" + - LocaleKeys.grid_field_timeFormat.tr(); + final title = + "${LocaleKeys.grid_field_dateFormat.tr()} &${LocaleKeys.grid_field_timeFormat.tr()}"; return BlocSelector( selector: (state) => state.dateTypeOptionPB, builder: (context, dateTypeOptionPB) { @@ -406,8 +405,8 @@ class _CalDateTimeSettingState extends State<_CalDateTimeSetting> { overlayIdentifier = child.toString(); FlowyOverlay.of(context).insertWithAnchor( widget: OverlayContainer( - child: child, constraints: BoxConstraints.loose(const Size(460, 440)), + child: child, ), identifier: overlayIdentifier!, anchorContext: context, 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 6fdd8bf6f8..f045984e66 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 @@ -91,8 +91,11 @@ 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.clip, + ), selectedColor: color, backgroundColor: color, labelPadding: const EdgeInsets.symmetric(horizontal: 6), 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 72794adee3..c09c5e93db 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 @@ -178,14 +178,14 @@ class _SelectOptionWrapState extends State { child = Align( alignment: Alignment.centerLeft, child: Wrap( + spacing: 4, + runSpacing: 2, children: widget.selectOptions .map((option) => SelectOptionTag.fromOption( context: context, option: option, )) .toList(), - spacing: 4, - runSpacing: 2, ), ); } diff --git a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart index ef074ed7bb..04252ed987 100644 --- a/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart +++ b/frontend/app_flowy/lib/plugins/grid/presentation/widgets/cell/select_option_cell/select_option_editor.dart @@ -75,8 +75,8 @@ class SelectOptionCellEditor extends StatelessWidget with FlowyOverlayDelegate { // FlowyOverlay.of(context).insertWithAnchor( widget: OverlayContainer( - child: SizedBox(width: _editorPannelWidth, child: editor), constraints: BoxConstraints.loose(const Size(_editorPannelWidth, 300)), + child: SizedBox(width: _editorPannelWidth, child: editor), ), identifier: SelectOptionCellEditor.identifier(), anchorContext: context, 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 5482a403cc..022d411f2b 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 @@ -108,7 +108,7 @@ class SelectOptionTextField extends StatelessWidget { child: SingleChildScrollView( controller: sc, scrollDirection: Axis.horizontal, - child: Wrap(children: children, spacing: 4), + child: Wrap(spacing: 4, children: children), ), ); } 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 e68ac720a3..b9e0f1ef48 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 @@ -30,11 +30,11 @@ class URLCellEditor extends StatefulWidget with FlowyOverlayDelegate { // FlowyOverlay.of(context).insertWithAnchor( widget: OverlayContainer( + constraints: BoxConstraints.loose(const Size(300, 160)), child: SizedBox( width: 200, child: Padding(padding: const EdgeInsets.all(6), child: editor), ), - constraints: BoxConstraints.loose(const Size(300, 160)), ), identifier: URLCellEditor.identifier(), anchorContext: context, 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 175e7272ef..f07eba0843 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,7 +1,9 @@ +import 'package:app_flowy/generated/locale_keys.g.dart'; 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:easy_localization/easy_localization.dart'; import 'package:appflowy_popover/popover.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; @@ -178,7 +180,10 @@ class CreateFieldButton extends StatelessWidget { triggerActions: PopoverTriggerActionFlags.click, direction: PopoverDirection.bottomWithRightAligned, child: FlowyButton( - text: const FlowyText.medium('New column', fontSize: 12), + text: FlowyText.medium( + LocaleKeys.grid_field_newColumn.tr(), + fontSize: 12, + ), hoverColor: theme.shader6, onTap: () {}, leftIcon: svgWidget("home/add"), 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 c61a2e417f..0420ea42c3 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 @@ -101,10 +101,10 @@ class NumberTypeOptionWidget extends TypeOptionWidget { } } -typedef _SelectNumberFormatCallback = Function(NumberFormat format); +typedef SelectNumberFormatCallback = Function(NumberFormat format); class NumberFormatList extends StatelessWidget { - final _SelectNumberFormatCallback onSelected; + final SelectNumberFormatCallback onSelected; final NumberFormat selectedFormat; const NumberFormatList( {required this.selectedFormat, required this.onSelected, Key? key}) 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 a4bf813fe5..55ec7b9832 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 @@ -14,6 +14,8 @@ import '../cell/cell_accessory.dart'; import '../cell/cell_container.dart'; import '../cell/prelude.dart'; import 'row_action_sheet.dart'; +import "package:app_flowy/generated/locale_keys.g.dart"; +import 'package:easy_localization/easy_localization.dart'; class GridRowWidget extends StatefulWidget { final RowInfo rowInfo; @@ -122,10 +124,13 @@ class _InsertRowButton extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); return FlowyIconButton( + tooltipText: LocaleKeys.tooltip_addNewRow.tr(), hoverColor: theme.hover, width: 20, height: 30, - onPressed: () => context.read().add(const RowEvent.createRow()), + onPressed: () => context.read().add( + const RowEvent.createRow(), + ), iconPadding: const EdgeInsets.all(3), icon: svgWidget("home/add"), ); @@ -139,6 +144,7 @@ class _DeleteRowButton extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); return FlowyIconButton( + tooltipText: LocaleKeys.tooltip_openMenu.tr(), hoverColor: theme.hover, width: 20, height: 30, @@ -184,7 +190,6 @@ class RowContent extends StatelessWidget { return CellContainer( width: cellId.field.width.toDouble(), - child: child, rowStateNotifier: Provider.of(context, listen: false), accessoryBuilder: (buildContext) { @@ -202,6 +207,7 @@ class RowContent extends StatelessWidget { } return accessories; }, + child: child, ); }, ).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 b4390d098f..8c828ec627 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 @@ -59,8 +59,8 @@ class GridRowActionSheet extends StatelessWidget { }) { FlowyOverlay.of(overlayContext).insertWithAnchor( widget: OverlayContainer( - child: this, constraints: BoxConstraints.loose(const Size(140, 200)), + child: this, ), identifier: GridRowActionSheet.identifier(), anchorContext: overlayContext, 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 7d22c51fab..3caedd652b 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 @@ -5,8 +5,10 @@ import 'package:app_flowy/plugins/grid/application/row/row_detail_bloc.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:app_flowy/generated/locale_keys.g.dart'; @@ -61,7 +63,12 @@ class _RowDetailPageState extends State { children: const [Spacer(), _CloseButton()], ), ), - Expanded(child: _PropertyList(cellBuilder: widget.cellBuilder)), + Expanded( + child: _PropertyList( + cellBuilder: widget.cellBuilder, + viewId: widget.dataController.rowInfo.gridId, + ), + ), ], ), ), @@ -88,9 +95,11 @@ class _CloseButton extends StatelessWidget { } class _PropertyList extends StatelessWidget { + final String viewId; final GridCellBuilder cellBuilder; final ScrollController _scrollController; _PropertyList({ + required this.viewId, required this.cellBuilder, Key? key, }) : _scrollController = ScrollController(), @@ -101,22 +110,65 @@ class _PropertyList extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.gridCells != current.gridCells, builder: (context, state) { - return ScrollbarListStack( - axis: Axis.vertical, - controller: _scrollController, - barSize: GridSize.scrollBarSize, - child: ListView.separated( - controller: _scrollController, - itemCount: state.gridCells.length, - itemBuilder: (BuildContext context, int index) { - return _RowDetailCell( - cellId: state.gridCells[index], - cellBuilder: cellBuilder, - ); - }, - separatorBuilder: (BuildContext context, int index) { - return const VSpace(2); - }, + return Column( + children: [ + Expanded( + child: ScrollbarListStack( + axis: Axis.vertical, + controller: _scrollController, + barSize: GridSize.scrollBarSize, + child: ListView.separated( + controller: _scrollController, + itemCount: state.gridCells.length, + itemBuilder: (BuildContext context, int index) { + return _RowDetailCell( + cellId: state.gridCells[index], + cellBuilder: cellBuilder, + ); + }, + separatorBuilder: (BuildContext context, int index) { + return const VSpace(2); + }, + ), + ), + ), + _CreateFieldButton(viewId: viewId), + ], + ); + }, + ); + } +} + +class _CreateFieldButton extends StatelessWidget { + final String viewId; + const _CreateFieldButton({required this.viewId, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = context.read(); + + return Popover( + triggerActions: PopoverTriggerActionFlags.click, + child: SizedBox( + height: 40, + child: FlowyButton( + text: FlowyText.medium( + LocaleKeys.grid_field_newColumn.tr(), + fontSize: 12, + ), + hoverColor: theme.shader6, + onTap: () {}, + leftIcon: svgWidget("home/add"), + ), + ), + popupBuilder: (BuildContext context) { + return OverlayContainer( + constraints: BoxConstraints.loose(const Size(240, 200)), + child: FieldEditor( + gridId: viewId, + fieldName: "", + typeOptionLoader: NewFieldTypeOptionLoader(gridId: viewId), ), ); }, @@ -150,9 +202,9 @@ class _RowDetailCellState extends State<_RowDetailCell> { behavior: HitTestBehavior.translucent, onTap: () => cell.beginFocus.notify(), child: AccessoryHover( - child: cell, contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: cell, ), ); 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 276b66cae4..d9a5daa041 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,5 +1,4 @@ import 'package:app_flowy/plugins/grid/application/setting/setting_bloc.dart'; -import 'package:appflowy_popover/popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra/theme.dart'; diff --git a/frontend/app_flowy/lib/plugins/trash/trash.dart b/frontend/app_flowy/lib/plugins/trash/trash.dart index 0d17091581..c1622d5b20 100644 --- a/frontend/app_flowy/lib/plugins/trash/trash.dart +++ b/frontend/app_flowy/lib/plugins/trash/trash.dart @@ -91,12 +91,12 @@ class _TrashPageState extends State { builder: (context, state) { return SizedBox.expand( child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ _renderTopBar(context, theme, state), const VSpace(32), _renderTrashList(context, state), ], - mainAxisAlignment: MainAxisAlignment.start, ).padding(horizontal: horizontalPadding, vertical: 48), ); }, diff --git a/frontend/app_flowy/lib/startup/tasks/app_widget.dart b/frontend/app_flowy/lib/startup/tasks/app_widget.dart index 6dae04a1c0..dd9e1f0850 100644 --- a/frontend/app_flowy/lib/startup/tasks/app_widget.dart +++ b/frontend/app_flowy/lib/startup/tasks/app_widget.dart @@ -20,39 +20,35 @@ class InitAppWidgetTask extends LaunchTask { final setting = await UserSettingsService().getAppearanceSettings(); final settingModel = AppearanceSettingModel(setting); final app = ApplicationWidget( - child: widget, settingModel: settingModel, + child: widget, ); - BlocOverrides.runZoned( - () { - runApp( - EasyLocalization( - supportedLocales: const [ - // In alphabetical order - Locale('ca', 'ES'), - Locale('de', 'DE'), - Locale('en'), - Locale('es', 'VE'), - Locale('fr', 'FR'), - Locale('fr', 'CA'), - Locale('hu', 'HU'), - Locale('id', 'ID'), - Locale('it', 'IT'), - Locale('ja', 'JP'), - Locale('pl', 'PL'), - Locale('pt', 'BR'), - Locale('ru', 'RU'), - Locale('tr', 'TR'), - Locale('zh', 'CN'), - ], - path: 'assets/translations', - fallbackLocale: const Locale('en'), - saveLocale: false, - child: app, - ), - ); - }, - blocObserver: ApplicationBlocObserver(), + Bloc.observer = ApplicationBlocObserver(); + runApp( + EasyLocalization( + supportedLocales: const [ + // In alphabetical order + Locale('ca', 'ES'), + Locale('de', 'DE'), + Locale('en'), + Locale('es', 'VE'), + Locale('fr', 'FR'), + Locale('fr', 'CA'), + Locale('hu', 'HU'), + Locale('id', 'ID'), + Locale('it', 'IT'), + Locale('ja', 'JP'), + Locale('pl', 'PL'), + Locale('pt', 'BR'), + Locale('ru', 'RU'), + Locale('tr', 'TR'), + Locale('zh', 'CN'), + ], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + saveLocale: false, + child: app, + ), ); return Future(() => {}); diff --git a/frontend/app_flowy/lib/user/presentation/router.dart b/frontend/app_flowy/lib/user/presentation/router.dart index 2928154ebe..82ff46ada9 100644 --- a/frontend/app_flowy/lib/user/presentation/router.dart +++ b/frontend/app_flowy/lib/user/presentation/router.dart @@ -28,16 +28,19 @@ class AuthRouter { ); } - void pushHomeScreen(BuildContext context, UserProfilePB profile, CurrentWorkspaceSettingPB workspaceSetting) { + void pushHomeScreen(BuildContext context, UserProfilePB profile, + CurrentWorkspaceSettingPB workspaceSetting) { Navigator.push( context, - PageRoutes.fade(() => HomeScreen(profile, workspaceSetting), RouteDurations.slow.inMilliseconds * .001), + PageRoutes.fade(() => HomeScreen(profile, workspaceSetting), + RouteDurations.slow.inMilliseconds * .001), ); } } class SplashRoute { - Future pushWelcomeScreen(BuildContext context, UserProfilePB userProfile) async { + Future pushWelcomeScreen( + BuildContext context, UserProfilePB userProfile) async { final screen = WelcomeScreen(userProfile: userProfile); final workspaceId = await Navigator.of(context).push( PageRoutes.fade( @@ -46,20 +49,24 @@ class SplashRoute { ), ); + // ignore: use_build_context_synchronously pushHomeScreen(context, userProfile, workspaceId); } - void pushHomeScreen(BuildContext context, UserProfilePB userProfile, CurrentWorkspaceSettingPB workspaceSetting) { + void pushHomeScreen(BuildContext context, UserProfilePB userProfile, + CurrentWorkspaceSettingPB workspaceSetting) { Navigator.push( context, - PageRoutes.fade(() => HomeScreen(userProfile, workspaceSetting), RouteDurations.slow.inMilliseconds * .001), + PageRoutes.fade(() => HomeScreen(userProfile, workspaceSetting), + RouteDurations.slow.inMilliseconds * .001), ); } void pushSignInScreen(BuildContext context) { Navigator.push( context, - PageRoutes.fade(() => SignInScreen(router: getIt()), RouteDurations.slow.inMilliseconds * .001), + PageRoutes.fade(() => SignInScreen(router: getIt()), + RouteDurations.slow.inMilliseconds * .001), ); } diff --git a/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart b/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart index ee3600e782..6f17404474 100644 --- a/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart +++ b/frontend/app_flowy/lib/user/presentation/sign_in_screen.dart @@ -94,6 +94,7 @@ class SignUpPrompt extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); return Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ Text(LocaleKeys.signIn_dontHaveAnAccount.tr(), style: TextStyle(color: theme.shader3, fontSize: 12)), TextButton( @@ -107,7 +108,6 @@ class SignUpPrompt extends StatelessWidget { ), ), ], - mainAxisAlignment: MainAxisAlignment.center, ); } } diff --git a/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart b/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart index d0cb7f8b90..75834f3836 100644 --- a/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart +++ b/frontend/app_flowy/lib/user/presentation/sign_up_screen.dart @@ -86,6 +86,7 @@ class SignUpPrompt extends StatelessWidget { Widget build(BuildContext context) { final theme = context.watch(); return Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ Text( LocaleKeys.signUp_alreadyHaveAnAccount.tr(), @@ -97,7 +98,6 @@ class SignUpPrompt extends StatelessWidget { child: Text(LocaleKeys.signIn_buttonText.tr(), style: TextStyle(color: theme.main1)), ), ], - mainAxisAlignment: MainAxisAlignment.center, ); } } diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart b/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart index 8b47a97433..ce0f11302e 100644 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart +++ b/frontend/app_flowy/lib/workspace/application/markdown/src/inline_parser.dart @@ -399,8 +399,8 @@ class AutolinkExtensionSyntax extends InlineSyntax { } } -class _DelimiterRun { - _DelimiterRun._( +class DelimiterRun { + DelimiterRun._( {this.char, this.length, this.isLeftFlanking, @@ -420,8 +420,7 @@ class _DelimiterRun { final bool? isFollowedByPunctuation; // ignore: prefer_constructors_over_static_methods - static _DelimiterRun? tryParse( - InlineParser parser, int runStart, int runEnd) { + static DelimiterRun? tryParse(InlineParser parser, int runStart, int runEnd) { bool leftFlanking, rightFlanking, precededByPunctuation, @@ -466,7 +465,7 @@ class _DelimiterRun { return null; } - return _DelimiterRun._( + return DelimiterRun._( char: parser.charAt(runStart), length: runEnd - runStart + 1, isLeftFlanking: leftFlanking, @@ -516,7 +515,7 @@ class TagSyntax extends InlineSyntax { return true; } - final delimiterRun = _DelimiterRun.tryParse(parser, matchStart, matchEnd); + final delimiterRun = DelimiterRun.tryParse(parser, matchStart, matchEnd); if (delimiterRun != null && delimiterRun.canOpen) { parser.openTag(TagState(parser.pos, matchEnd + 1, this, delimiterRun)); return true; @@ -531,7 +530,7 @@ class TagSyntax extends InlineSyntax { final matchStart = parser.pos; final matchEnd = parser.pos + runLength - 1; final openingRunLength = state.endPos - state.startPos; - final delimiterRun = _DelimiterRun.tryParse(parser, matchStart, matchEnd); + final delimiterRun = DelimiterRun.tryParse(parser, matchStart, matchEnd); if (openingRunLength == 1 && runLength == 1) { parser.addNode(Element('em', state.children)); @@ -579,7 +578,7 @@ class StrikethroughSyntax extends TagSyntax { final runLength = match.group(0)!.length; final matchStart = parser.pos; final matchEnd = parser.pos + runLength - 1; - final delimiterRun = _DelimiterRun.tryParse(parser, matchStart, matchEnd)!; + final delimiterRun = DelimiterRun.tryParse(parser, matchStart, matchEnd)!; if (!delimiterRun.isRightFlanking!) { return false; } @@ -1170,7 +1169,7 @@ class TagState { /// The children of this node. Will be `null` for text nodes. final List children; - final _DelimiterRun? openingDelimiterRun; + final DelimiterRun? openingDelimiterRun; /// Attempts to close this tag by matching the current text against its end /// pattern. @@ -1193,7 +1192,7 @@ class TagState { final closingMatchStart = parser.pos; final closingMatchEnd = parser.pos + runLength - 1; final closingDelimiterRun = - _DelimiterRun.tryParse(parser, closingMatchStart, closingMatchEnd); + DelimiterRun.tryParse(parser, closingMatchStart, closingMatchEnd); if (closingDelimiterRun != null && closingDelimiterRun.canClose) { // Emphasis rules #9 and #10: final oneRunOpensAndCloses = 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 dce1c71f29..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,5 +1,7 @@ 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'; @@ -54,7 +56,8 @@ class _HomeScreenState extends State { }, ), ], - child: Scaffold( + child: HomeHotKeys( + child: Scaffold( body: BlocListener( listenWhen: (p, c) => p.unauthorized != c.unauthorized, listener: (context, state) { @@ -80,7 +83,7 @@ class _HomeScreenState extends State { }, ), ), - ), + )), ); } @@ -145,6 +148,7 @@ class _HomeScreenState extends State { return FocusTraversalGroup(child: RepaintBoundary(child: homeMenu)); } + Widget _buildEditPanel( {required HomeState homeState, required BuildContext 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 5099dcddfc..eed9eb6f4a 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart @@ -58,10 +58,10 @@ class FadingIndexedStack extends StatefulWidget { }) : super(key: key); @override - _FadingIndexedStackState createState() => _FadingIndexedStackState(); + FadingIndexedStackState createState() => FadingIndexedStackState(); } -class _FadingIndexedStackState extends State { +class FadingIndexedStackState extends State { double _targetOpacity = 1; @override diff --git a/frontend/app_flowy/lib/workspace/presentation/home/hotkeys.dart b/frontend/app_flowy/lib/workspace/presentation/home/hotkeys.dart new file mode 100644 index 0000000000..0ac9cbc704 --- /dev/null +++ b/frontend/app_flowy/lib/workspace/presentation/home/hotkeys.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:app_flowy/startup/startup.dart'; +import 'package:app_flowy/workspace/application/home/home_bloc.dart'; +import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; +import 'package:flutter/material.dart'; +import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:provider/provider.dart'; + +class HomeHotKeys extends StatelessWidget { + final Widget child; + const HomeHotKeys({required this.child, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + HotKey hotKey = HotKey( + KeyCode.backslash, + modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control], + // Set hotkey scope (default is HotKeyScope.system) + scope: HotKeyScope.inapp, // Set as inapp-wide hotkey. + ); + hotKeyManager.register( + hotKey, + keyDownHandler: (hotKey) { + context.read().add(const HomeEvent.collapseMenu()); + getIt().collapsedNotifier.value = + !getIt().collapsedNotifier.currentValue!; + }, + ); + return child; + } +} diff --git a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart index 6732d88925..1796640d00 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart @@ -2,6 +2,7 @@ export './app/header/header.dart'; export './app/menu_app.dart'; import 'dart:io' show Platform; +import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:app_flowy/plugins/trash/menu.dart'; import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; @@ -25,6 +26,7 @@ import 'package:app_flowy/core/frameless_window.dart'; // import 'package:app_flowy/workspace/presentation/home/home_sizes.dart'; import 'package:flowy_infra/image.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'app/menu_app.dart'; import 'app/create_button.dart'; @@ -217,14 +219,23 @@ class MenuTopBar extends StatelessWidget { children: [ renderIcon(context), const Spacer(), - FlowyIconButton( - width: 28, - onPressed: () => context - .read() - .add(const HomeEvent.collapseMenu()), - iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), - icon: svgWidget("home/hide_menu", color: theme.iconColor), - ) + Tooltip( + richMessage: TextSpan(children: [ + TextSpan( + text: "${LocaleKeys.sideBar_closeSidebar.tr()}\n"), + TextSpan( + text: Platform.isMacOS ? "⌘+\\" : "Ctrl+\\", + style: const TextStyle(color: Colors.white60), + ), + ]), + child: FlowyIconButton( + width: 28, + onPressed: () => context + .read() + .add(const HomeEvent.collapseMenu()), + iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + icon: svgWidget("home/hide_menu", color: theme.iconColor), + )) ], )), ); 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 3d9d76fe29..399bbd1f89 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 @@ -24,6 +24,7 @@ class MenuUser extends StatelessWidget { getIt(param1: user)..add(const MenuUserEvent.initial()), child: BlocBuilder( builder: (context, state) => Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ _renderAvatar(context), const HSpace(10), @@ -34,7 +35,6 @@ class MenuUser extends StatelessWidget { //we get the below block back //_renderDropButton(context), ], - crossAxisAlignment: CrossAxisAlignment.center, ), ), ); diff --git a/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart b/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart index f52b7224f6..bfdb708013 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/navigation.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:app_flowy/generated/locale_keys.g.dart'; import 'package:app_flowy/workspace/application/home/home_bloc.dart'; import 'package:app_flowy/workspace/presentation/home/home_stack.dart'; import 'package:flowy_infra/image.dart'; @@ -8,13 +11,15 @@ import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:easy_localization/easy_localization.dart'; typedef NaviAction = void Function(); class NavigationNotifier with ChangeNotifier { List navigationItems; PublishNotifier collapasedNotifier; - NavigationNotifier({required this.navigationItems, required this.collapasedNotifier}); + NavigationNotifier( + {required this.navigationItems, required this.collapasedNotifier}); void update(HomeStackNotifier notifier) { bool shouldNotify = false; @@ -69,7 +74,8 @@ class FlowyNavigation extends StatelessWidget { child: Row(children: [ Selector>( selector: (context, notifier) => notifier.collapasedNotifier, - builder: (ctx, collapsedNotifier, child) => _renderCollapse(ctx, collapsedNotifier, theme)), + builder: (ctx, collapsedNotifier, child) => + _renderCollapse(ctx, collapsedNotifier, theme)), Selector>( selector: (context, notifier) => notifier.navigationItems, builder: (ctx, items, child) => Expanded( @@ -84,7 +90,8 @@ class FlowyNavigation extends StatelessWidget { ); } - Widget _renderCollapse(BuildContext context, PublishNotifier collapsedNotifier, AppTheme theme) { + Widget _renderCollapse(BuildContext context, + PublishNotifier collapsedNotifier, AppTheme theme) { return ChangeNotifierProvider.value( value: collapsedNotifier, child: Consumer( @@ -92,15 +99,23 @@ class FlowyNavigation extends StatelessWidget { if (notifier.currentValue ?? false) { return RotationTransition( turns: const AlwaysStoppedAnimation(180 / 360), - child: FlowyIconButton( - width: 24, - onPressed: () { - notifier.value = false; - ctx.read().add(const HomeEvent.collapseMenu()); - }, - iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2), - icon: svgWidget("home/hide_menu", color: theme.iconColor), - ), + child: Tooltip( + richMessage: TextSpan(children: [ + TextSpan(text: "${LocaleKeys.sideBar_openSidebar.tr()}\n"), + TextSpan( + text: Platform.isMacOS ? "⌘+\\" : "Ctrl+\\", + style: const TextStyle(color: Colors.white60), + ), + ]), + child: FlowyIconButton( + width: 24, + onPressed: () { + notifier.value = false; + ctx.read().add(const HomeEvent.collapseMenu()); + }, + iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2), + icon: svgWidget("home/hide_menu", color: theme.iconColor), + )), ); } else { return Container(); @@ -154,7 +169,8 @@ class NaviItemWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Expanded(child: item.leftBarItem.padding(horizontal: 2, vertical: 2)); + return Expanded( + child: item.leftBarItem.padding(horizontal: 2, vertical: 2)); } } diff --git a/frontend/app_flowy/lib/workspace/presentation/home/toast.dart b/frontend/app_flowy/lib/workspace/presentation/home/toast.dart index 28241c0ec4..d3473adaa9 100644 --- a/frontend/app_flowy/lib/workspace/presentation/home/toast.dart +++ b/frontend/app_flowy/lib/workspace/presentation/home/toast.dart @@ -10,14 +10,14 @@ class FlowyMessageToast extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: FlowyText.medium(message, color: Colors.white), - ), decoration: const BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(4)), color: Colors.black, ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: FlowyText.medium(message, color: Colors.white), + ), ); } } diff --git a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart index e95e6e83ab..bb1b419da0 100644 --- a/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart +++ b/frontend/app_flowy/lib/workspace/presentation/settings/widgets/settings_language_view.dart @@ -84,8 +84,8 @@ class _LanguageSelectorDropdownState extends State { }); }, icon: const Visibility( - child: (Icon(Icons.arrow_downward)), visible: false, + child: (Icon(Icons.arrow_downward)), ), borderRadius: BorderRadius.circular(8), items: EasyLocalization.of(context)!.supportedLocales.map((locale) { diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart index 082cb5dabf..614ff356cb 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/edit_panel/panel_animation.dart @@ -19,10 +19,10 @@ class AnimatedPanel extends StatefulWidget { : super(key: key); @override - _AnimatedPanelState createState() => _AnimatedPanelState(); + AnimatedPanelState createState() => AnimatedPanelState(); } -class _AnimatedPanelState extends State { +class AnimatedPanelState extends State { bool _isHidden = true; @override @@ -79,9 +79,9 @@ extension AnimatedPanelExtensions on Widget { return AnimatedPanel( closedX: closePos.dx, closedY: closePos.dy, - child: this, isClosed: isClosed ?? false, duration: duration ?? .35, - curve: curve); + curve: curve, + child: this); } } diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart index afdfa5e6eb..271a3b6dd2 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart @@ -10,28 +10,34 @@ import 'emoji_picker_builder.dart'; import 'emoji_view_state.dart'; class DefaultEmojiPickerView extends EmojiPickerBuilder { - const DefaultEmojiPickerView(Config config, EmojiViewState state, {Key? key}) : super(config, state, key: key); + const DefaultEmojiPickerView(Config config, EmojiViewState state, {Key? key}) + : super(config, state, key: key); @override - _DefaultEmojiPickerViewState createState() => _DefaultEmojiPickerViewState(); + DefaultEmojiPickerViewState createState() => DefaultEmojiPickerViewState(); } -class _DefaultEmojiPickerViewState extends State with TickerProviderStateMixin { +class DefaultEmojiPickerViewState extends State + with TickerProviderStateMixin { PageController? _pageController; TabController? _tabController; final TextEditingController _emojiController = TextEditingController(); final FocusNode _emojiFocusNode = FocusNode(); - final CategoryEmoji _categoryEmoji = CategoryEmoji(Category.SEARCH, List.empty(growable: true)); + final CategoryEmoji _categoryEmoji = + CategoryEmoji(Category.SEARCH, List.empty(growable: true)); CategoryEmoji searchEmojiList = CategoryEmoji(Category.SEARCH, []); @override void initState() { - var initCategory = - widget.state.categoryEmoji.indexWhere((element) => element.category == widget.config.initCategory); + var initCategory = widget.state.categoryEmoji.indexWhere( + (element) => element.category == widget.config.initCategory); if (initCategory == -1) { initCategory = 0; } - _tabController = TabController(initialIndex: initCategory, length: widget.state.categoryEmoji.length, vsync: this); + _tabController = TabController( + initialIndex: initCategory, + length: widget.state.categoryEmoji.length, + vsync: this); _pageController = PageController(initialPage: initCategory); _emojiFocusNode.requestFocus(); @@ -83,7 +89,8 @@ class _DefaultEmojiPickerViewState extends State with Ti } bool isEmojiSearching() { - bool result = searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; + bool result = + searchEmojiList.emoji.isNotEmpty || _emojiController.text.isNotEmpty; return result; } @@ -133,7 +140,9 @@ class _DefaultEmojiPickerViewState extends State with Ti child: TabBar( labelColor: widget.config.iconColorSelected, unselectedLabelColor: widget.config.iconColor, - controller: isEmojiSearching() ? TabController(length: 1, vsync: this) : _tabController, + controller: isEmojiSearching() + ? TabController(length: 1, vsync: this) + : _tabController, labelPadding: EdgeInsets.zero, indicatorColor: widget.config.indicatorColor, padding: const EdgeInsets.symmetric(vertical: 5.0), @@ -154,7 +163,8 @@ class _DefaultEmojiPickerViewState extends State with Ti : widget.state.categoryEmoji .asMap() .entries - .map((item) => _buildCategory(item.value.category, emojiSize)) + .map((item) => _buildCategory( + item.value.category, emojiSize)) .toList(), ), ), @@ -163,7 +173,9 @@ class _DefaultEmojiPickerViewState extends State with Ti ), Flexible( child: PageView.builder( - itemCount: searchEmojiList.emoji.isNotEmpty ? 1 : widget.state.categoryEmoji.length, + itemCount: searchEmojiList.emoji.isNotEmpty + ? 1 + : widget.state.categoryEmoji.length, controller: _pageController, physics: const NeverScrollableScrollPhysics(), // onPageChanged: (index) { @@ -173,7 +185,9 @@ class _DefaultEmojiPickerViewState extends State with Ti // ); // }, itemBuilder: (context, index) { - CategoryEmoji catEmoji = isEmojiSearching() ? searchEmojiList : widget.state.categoryEmoji[index]; + CategoryEmoji catEmoji = isEmojiSearching() + ? searchEmojiList + : widget.state.categoryEmoji[index]; return _buildPage(emojiSize, catEmoji); }, ), @@ -195,24 +209,28 @@ class _DefaultEmojiPickerViewState extends State with Ti ); } - Widget _buildButtonWidget({required VoidCallback onPressed, required Widget child}) { + Widget _buildButtonWidget( + {required VoidCallback onPressed, required Widget child}) { if (widget.config.buttonMode == ButtonMode.MATERIAL) { return TextButton( onPressed: onPressed, - child: child, style: ButtonStyle(padding: MaterialStateProperty.all(EdgeInsets.zero)), + child: child, ); } - return CupertinoButton(padding: EdgeInsets.zero, onPressed: onPressed, child: child); + return CupertinoButton( + padding: EdgeInsets.zero, onPressed: onPressed, child: child); } Widget _buildPage(double emojiSize, CategoryEmoji categoryEmoji) { // Display notice if recent has no entries yet final scrollController = ScrollController(); - if (categoryEmoji.category == Category.RECENT && categoryEmoji.emoji.isEmpty) { + if (categoryEmoji.category == Category.RECENT && + categoryEmoji.emoji.isEmpty) { return _buildNoRecent(); - } else if (categoryEmoji.category == Category.SEARCH && categoryEmoji.emoji.isEmpty) { + } else if (categoryEmoji.category == Category.SEARCH && + categoryEmoji.emoji.isEmpty) { return const Center(child: Text("No Emoji Found")); } // Build page normally @@ -236,8 +254,13 @@ class _DefaultEmojiPickerViewState extends State with Ti mainAxisSpacing: widget.config.verticalSpacing, crossAxisSpacing: widget.config.horizontalSpacing, children: _categoryEmoji.emoji.isNotEmpty - ? _categoryEmoji.emoji.map((e) => _buildEmoji(emojiSize, categoryEmoji, e)).toList() - : categoryEmoji.emoji.map((item) => _buildEmoji(emojiSize, categoryEmoji, item)).toList(), + ? _categoryEmoji.emoji + .map((e) => _buildEmoji(emojiSize, categoryEmoji, e)) + .toList() + : categoryEmoji.emoji + .map( + (item) => _buildEmoji(emojiSize, categoryEmoji, item)) + .toList(), ), ), ); diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart index 37ce933618..28cf268a46 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_button.dart @@ -20,10 +20,10 @@ class FlowyEmojiStyleButton extends StatefulWidget { }) : super(key: key); @override - _EmojiStyleButtonState createState() => _EmojiStyleButtonState(); + EmojiStyleButtonState createState() => EmojiStyleButtonState(); } -class _EmojiStyleButtonState extends State { +class EmojiStyleButtonState extends State { bool _isToggled = false; // Style get _selectionStyle => widget.controller.getSelectionStyle(); final GlobalKey emojiButtonKey = GlobalKey(); diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart index 650cd185f1..8852b12799 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/emoji_picker/src/emoji_picker.dart @@ -101,10 +101,10 @@ class EmojiPicker extends StatefulWidget { final Config config; @override - _EmojiPickerState createState() => _EmojiPickerState(); + EmojiPickerState createState() => EmojiPickerState(); } -class _EmojiPickerState extends State { +class EmojiPickerState extends State { static const platform = MethodChannel('emoji_picker_flutter'); List categoryEmoji = List.empty(growable: true); @@ -147,7 +147,8 @@ class _EmojiPickerState extends State { return const Center(child: CircularProgressIndicator()); } if (widget.config.showRecentsTab) { - categoryEmoji[0].emoji = recentEmoji.map((e) => e.emoji).toList().cast(); + categoryEmoji[0].emoji = + recentEmoji.map((e) => e.emoji).toList().cast(); } var state = EmojiViewState( @@ -184,23 +185,35 @@ class _EmojiPickerState extends State { categoryEmoji.clear(); if (widget.config.showRecentsTab) { recentEmoji = await _getRecentEmojis(); - final List recentEmojiMap = recentEmoji.map((e) => e.emoji).toList().cast(); + final List recentEmojiMap = + recentEmoji.map((e) => e.emoji).toList().cast(); categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap)); } categoryEmoji.addAll([ - CategoryEmoji(Category.SMILEYS, await _getAvailableEmojis(emoji_list.smileys, title: 'smileys')), - CategoryEmoji(Category.ANIMALS, await _getAvailableEmojis(emoji_list.animals, title: 'animals')), - CategoryEmoji(Category.FOODS, await _getAvailableEmojis(emoji_list.foods, title: 'foods')), - CategoryEmoji(Category.ACTIVITIES, await _getAvailableEmojis(emoji_list.activities, title: 'activities')), - CategoryEmoji(Category.TRAVEL, await _getAvailableEmojis(emoji_list.travel, title: 'travel')), - CategoryEmoji(Category.OBJECTS, await _getAvailableEmojis(emoji_list.objects, title: 'objects')), - CategoryEmoji(Category.SYMBOLS, await _getAvailableEmojis(emoji_list.symbols, title: 'symbols')), - CategoryEmoji(Category.FLAGS, await _getAvailableEmojis(emoji_list.flags, title: 'flags')) + CategoryEmoji(Category.SMILEYS, + await _getAvailableEmojis(emoji_list.smileys, title: 'smileys')), + CategoryEmoji(Category.ANIMALS, + await _getAvailableEmojis(emoji_list.animals, title: 'animals')), + CategoryEmoji(Category.FOODS, + await _getAvailableEmojis(emoji_list.foods, title: 'foods')), + CategoryEmoji( + Category.ACTIVITIES, + await _getAvailableEmojis(emoji_list.activities, + title: 'activities')), + CategoryEmoji(Category.TRAVEL, + await _getAvailableEmojis(emoji_list.travel, title: 'travel')), + CategoryEmoji(Category.OBJECTS, + await _getAvailableEmojis(emoji_list.objects, title: 'objects')), + CategoryEmoji(Category.SYMBOLS, + await _getAvailableEmojis(emoji_list.symbols, title: 'symbols')), + CategoryEmoji(Category.FLAGS, + await _getAvailableEmojis(emoji_list.flags, title: 'flags')) ]); } // Get available emoji for given category title - Future> _getAvailableEmojis(Map map, {required String title}) async { + Future> _getAvailableEmojis(Map map, + {required String title}) async { Map? newMap; // Get Emojis cached locally if available @@ -216,19 +229,22 @@ class _EmojiPickerState extends State { } // Map to Emoji Object - return newMap!.entries.map((entry) => Emoji(entry.key, entry.value)).toList(); + return newMap!.entries + .map((entry) => Emoji(entry.key, entry.value)) + .toList(); } // Check if emoji is available on current platform - Future?> _getPlatformAvailableEmoji(Map emoji) async { + Future?> _getPlatformAvailableEmoji( + Map emoji) async { if (Platform.isAndroid) { Map? filtered = {}; var delimiter = '|'; try { var entries = emoji.values.join(delimiter); var keys = emoji.keys.join(delimiter); - var result = (await platform - .invokeMethod('checkAvailability', {'emojiKeys': keys, 'emojiEntries': entries})) as String; + var result = (await platform.invokeMethod('checkAvailability', + {'emojiKeys': keys, 'emojiEntries': entries})) as String; var resultKeys = result.split(delimiter); for (var i = 0; i < resultKeys.length; i++) { filtered[resultKeys[i]] = emoji[resultKeys[i]]!; @@ -249,12 +265,14 @@ class _EmojiPickerState extends State { if (emojiJson == null) { return null; } - var emojis = Map.from(jsonDecode(emojiJson) as Map); + var emojis = + Map.from(jsonDecode(emojiJson) as Map); return emojis; } // Stores filtered emoji locally for faster access next time - Future _cacheFilteredEmojis(String title, Map emojis) async { + Future _cacheFilteredEmojis( + String title, Map emojis) async { final prefs = await SharedPreferences.getInstance(); var emojiJson = jsonEncode(emojis); prefs.setString(title, emojiJson); @@ -274,7 +292,8 @@ class _EmojiPickerState extends State { // Add an emoji to recently used list or increase its counter Future _addEmojiToRecentlyUsed(Emoji emoji) async { final prefs = await SharedPreferences.getInstance(); - var recentEmojiIndex = recentEmoji.indexWhere((element) => element.emoji.emoji == emoji.emoji); + var recentEmojiIndex = + recentEmoji.indexWhere((element) => element.emoji.emoji == emoji.emoji); if (recentEmojiIndex != -1) { // Already exist in recent list // Just update counter @@ -285,7 +304,8 @@ class _EmojiPickerState extends State { // Sort by counter desc recentEmoji.sort((a, b) => b.counter - a.counter); // Limit entries to recentsLimit - recentEmoji = recentEmoji.sublist(0, min(widget.config.recentsLimit, recentEmoji.length)); + recentEmoji = recentEmoji.sublist( + 0, min(widget.config.recentsLimit, recentEmoji.length)); // save locally prefs.setString('recent', jsonEncode(recentEmoji)); } diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart index 6cc150489c..dbcafe2e44 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/float_bubble/question_bubble.dart @@ -87,7 +87,7 @@ class _DebugToast { return deviceInfo.then((info) { var debugText = ""; info.toMap().forEach((key, value) { - debugText = debugText + "$key: $value\n"; + debugText = "$debugText$key: $value\n"; }); return debugText; }); diff --git a/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_window.dart b/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_window.dart index 1803257672..9ad25dc70c 100644 --- a/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_window.dart +++ b/frontend/app_flowy/lib/workspace/presentation/widgets/pop_up_window.dart @@ -10,8 +10,8 @@ class FlowyPoppuWindow extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - child: child, type: MaterialType.transparency, + child: child, ); } @@ -21,6 +21,7 @@ class FlowyPoppuWindow extends StatelessWidget { required Size size, }) async { final window = await getWindowInfo(); + // ignore: use_build_context_synchronously FlowyOverlay.of(context).insertWithRect( widget: FlowyPoppuWindow(child: child), identifier: 'FlowyPoppuWindow', @@ -49,7 +50,10 @@ class PopupTextField extends StatelessWidget { ); } - static void show({required BuildContext context, required Size size, required void Function(String) textDidChange}) { + static void show( + {required BuildContext context, + required Size size, + required void Function(String) textDidChange}) { FlowyPoppuWindow.show( context, size: size, diff --git a/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/linux/flutter/generated_plugin_registrant.cc index ae6ec7ed89..f05fb593f4 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 #include @@ -15,6 +16,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) hotkey_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "HotkeyManagerPlugin"); + hotkey_manager_plugin_register_with_registrar(hotkey_manager_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); diff --git a/frontend/app_flowy/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/linux/flutter/generated_plugins.cmake index 3e0d068b6a..ce38abcac0 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 + hotkey_manager 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 60d2b8c792..2f24aad58b 100644 --- a/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/app_flowy/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import connectivity_plus_macos import device_info_plus_macos import flowy_infra_ui import flowy_sdk +import hotkey_manager import package_info_plus_macos import path_provider_macos import rich_clipboard_macos @@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlowyInfraUIPlugin.register(with: registry.registrar(forPlugin: "FlowyInfraUIPlugin")) FlowySdkPlugin.register(with: registry.registrar(forPlugin: "FlowySdkPlugin")) + HotkeyManagerPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) diff --git a/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md index c4c6495533..d8eceeefee 100644 --- a/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md +++ b/frontend/app_flowy/packages/appflowy_board/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.0.6 +* Support scroll to bottom +* Fix some bugs + # 0.0.5 * Optimize insert card animation * Enable insert card at the end of the column diff --git a/frontend/app_flowy/packages/appflowy_board/README.md b/frontend/app_flowy/packages/appflowy_board/README.md index b33c4d7883..38561d13e3 100644 --- a/frontend/app_flowy/packages/appflowy_board/README.md +++ b/frontend/app_flowy/packages/appflowy_board/README.md @@ -7,6 +7,7 @@ The **appflowy_board** is a package that is used in [AppFlowy](https://github.co ## Getting Started

+

diff --git a/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif b/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif new file mode 100644 index 0000000000..d2a29020ed Binary files /dev/null and b/frontend/app_flowy/packages/appflowy_board/example/gifs/appflowy_board_video_2.gif differ 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 218331d198..1decf21063 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 @@ -11,13 +11,13 @@ class MultiBoardListExample extends StatefulWidget { class _MultiBoardListExampleState extends State { final AFBoardDataController boardDataController = AFBoardDataController( onMoveColumn: (fromColumnId, fromIndex, toColumnId, toIndex) { - debugPrint('Move column from $fromIndex to $toIndex'); + // debugPrint('Move column from $fromIndex to $toIndex'); }, onMoveColumnItem: (columnId, fromIndex, toIndex) { - debugPrint('Move $columnId:$fromIndex to $columnId:$toIndex'); + // debugPrint('Move $columnId:$fromIndex to $columnId:$toIndex'); }, onMoveColumnItemToColumn: (fromColumnId, fromIndex, toColumnId, toIndex) { - debugPrint('Move $fromColumnId:$fromIndex to $toColumnId:$toIndex'); + // debugPrint('Move $fromColumnId:$fromIndex to $toColumnId:$toIndex'); }, ); @@ -34,13 +34,19 @@ class _MultiBoardListExampleState extends State { RichTextItem(title: "Card 8", subtitle: 'Aug 1, 2020 4:05 PM'), TextItem("Card 9"), ]; - final column1 = AFBoardColumnData(id: "To Do", items: a); - final column2 = AFBoardColumnData(id: "In Progress", items: [ - RichTextItem(title: "Card 10", subtitle: 'Aug 1, 2020 4:05 PM'), - TextItem("Card 11"), - ]); - final column3 = AFBoardColumnData(id: "Done", items: []); + final column1 = AFBoardColumnData(id: "To Do", name: "To Do", items: a); + final column2 = AFBoardColumnData( + id: "In Progress", + name: "In Progress", + items: [ + RichTextItem(title: "Card 10", subtitle: 'Aug 1, 2020 4:05 PM'), + TextItem("Card 11"), + ], + ); + + final column3 = + AFBoardColumnData(id: "Done", name: "Done", items: []); boardDataController.addColumn(column1); boardDataController.addColumn(column2); @@ -71,17 +77,28 @@ class _MultiBoardListExampleState extends State { headerBuilder: (context, columnData) { return AppFlowyColumnHeader( icon: const Icon(Icons.lightbulb_circle), - title: Text(columnData.id), + title: SizedBox( + width: 60, + child: TextField( + controller: TextEditingController() + ..text = columnData.headerData.columnName, + onSubmitted: (val) { + boardDataController + .getColumnController(columnData.headerData.columnId)! + .updateColumnName(val); + }, + ), + ), addIcon: const Icon(Icons.add, size: 20), moreIcon: const Icon(Icons.more_horiz, size: 20), height: 50, margin: config.columnItemPadding, ); }, - cardBuilder: (context, item) { + cardBuilder: (context, column, columnItem) { return AppFlowyColumnItemCard( - key: ObjectKey(item), - child: _buildCard(item), + key: ValueKey(columnItem.id), + child: _buildCard(columnItem), ); }, columnConstraints: const BoxConstraints.tightFor(width: 240), @@ -98,40 +115,63 @@ class _MultiBoardListExampleState extends State { return Align( alignment: Alignment.centerLeft, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), child: Text(item.s), ), ); } if (item is RichTextItem) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - style: const TextStyle(fontSize: 14), - textAlign: TextAlign.left, - ), - const SizedBox(height: 10), - Text( - item.subtitle, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ) - ], - ), - ), - ); + return RichTextCard(item: item); } throw UnimplementedError(); } } +class RichTextCard extends StatefulWidget { + final RichTextItem item; + const RichTextCard({ + required this.item, + Key? key, + }) : super(key: key); + + @override + State createState() => _RichTextCardState(); +} + +class _RichTextCardState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.item.title, + style: const TextStyle(fontSize: 14), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + Text( + widget.item.subtitle, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ) + ], + ), + ), + ); + } +} + class TextItem extends AFColumnItem { final String s; 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 97e83df448..f22c562343 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 @@ -13,12 +13,16 @@ class _SingleBoardListExampleState extends State { @override void initState() { - final column = AFBoardColumnData(id: "1", items: [ - TextItem("a"), - TextItem("b"), - TextItem("c"), - TextItem("d"), - ]); + final column = AFBoardColumnData( + id: "1", + name: "1", + items: [ + TextItem("a"), + TextItem("b"), + TextItem("c"), + TextItem("d"), + ], + ); boardData.addColumn(column); super.initState(); @@ -28,8 +32,9 @@ class _SingleBoardListExampleState extends State { Widget build(BuildContext context) { return AFBoard( dataController: boardData, - cardBuilder: (context, item) { - return _RowWidget(item: item as TextItem, key: ObjectKey(item)); + cardBuilder: (context, column, columnItem) { + return _RowWidget( + item: columnItem as TextItem, key: ObjectKey(columnItem)); }, ); } diff --git a/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml index 1a90f3b84a..c9bd9ef0f9 100644 --- a/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_board/example/pubspec.yaml @@ -46,7 +46,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^2.0.0 + flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec 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 e07ee39d61..a565838da4 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 @@ -1,23 +1,43 @@ +import 'package:appflowy_board/src/utils/log.dart'; import 'package:flutter/material.dart'; 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_state.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 AFBoardScrollManager { + BoardColumnsState? _columnState; + + // AFBoardScrollManager(); + + void scrollToBottom(String columnId, VoidCallback? completed) { + _columnState + ?.getReorderFlexState(columnId: columnId) + ?.scrollToBottom(completed); + } +} + class AFBoardConfig { final double cornerRadius; final EdgeInsets columnPadding; final EdgeInsets columnItemPadding; + final EdgeInsets footerPadding; + final EdgeInsets headerPadding; + final EdgeInsets cardPadding; final Color columnBackgroundColor; const AFBoardConfig({ this.cornerRadius = 6.0, this.columnPadding = const EdgeInsets.symmetric(horizontal: 8), - this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 10), + this.columnItemPadding = const EdgeInsets.symmetric(horizontal: 12), + this.footerPadding = const EdgeInsets.symmetric(horizontal: 12), + this.headerPadding = const EdgeInsets.symmetric(horizontal: 16), + this.cardPadding = const EdgeInsets.symmetric(horizontal: 3, vertical: 4), this.columnBackgroundColor = Colors.transparent, }); } @@ -44,12 +64,16 @@ class AFBoard extends StatelessWidget { final BoxConstraints columnConstraints; /// - final BoardPhantomController phantomController; + late final BoardPhantomController phantomController; final ScrollController? scrollController; final AFBoardConfig config; + final AFBoardScrollManager? scrollManager; + + final BoardColumnsState _columnState = BoardColumnsState(); + AFBoard({ required this.dataController, required this.cardBuilder, @@ -57,11 +81,16 @@ class AFBoard extends StatelessWidget { this.footBuilder, this.headerBuilder, this.scrollController, + this.scrollManager, this.columnConstraints = const BoxConstraints(maxWidth: 200), this.config = const AFBoardConfig(), Key? key, - }) : phantomController = BoardPhantomController(delegate: dataController), - super(key: key); + }) : super(key: key) { + phantomController = BoardPhantomController( + delegate: dataController, + columnsState: _columnState, + ); + } @override Widget build(BuildContext context) { @@ -69,10 +98,16 @@ class AFBoard extends StatelessWidget { value: dataController, child: Consumer( builder: (context, notifier, child) { - return BoardContent( + if (scrollManager != null) { + scrollManager!._columnState = _columnState; + } + + return AFBoardContent( config: config, dataController: dataController, scrollController: scrollController, + scrollManager: scrollManager, + columnsState: _columnState, background: background, delegate: phantomController, columnConstraints: columnConstraints, @@ -88,7 +123,7 @@ class AFBoard extends StatelessWidget { } } -class BoardContent extends StatefulWidget { +class AFBoardContent extends StatefulWidget { final ScrollController? scrollController; final OnDragStarted? onDragStarted; final OnReorder onReorder; @@ -98,6 +133,8 @@ class BoardContent extends StatefulWidget { final AFBoardConfig config; final ReorderFlexConfig reorderFlexConfig; final BoxConstraints columnConstraints; + final AFBoardScrollManager? scrollManager; + final BoardColumnsState columnsState; /// final AFBoardColumnCardBuilder cardBuilder; @@ -112,11 +149,13 @@ class BoardContent extends StatefulWidget { final BoardPhantomController phantomController; - const BoardContent({ + const AFBoardContent({ required this.config, required this.onReorder, required this.delegate, required this.dataController, + required this.scrollManager, + required this.columnsState, this.onDragStarted, this.onDragEnded, this.scrollController, @@ -131,12 +170,12 @@ class BoardContent extends StatefulWidget { super(key: key); @override - State createState() => _BoardContentState(); + State createState() => _AFBoardContentState(); } -class _BoardContentState extends State { - final GlobalKey _columnContainerOverlayKey = - GlobalKey(debugLabel: '$BoardContent overlay key'); +class _AFBoardContentState extends State { + final GlobalKey _boardContentKey = + GlobalKey(debugLabel: '$AFBoardContent overlay key'); late BoardOverlayEntry _overlayEntry; @override @@ -147,10 +186,10 @@ class _BoardContentState extends State { reorderFlexId: widget.dataController.identifier, acceptedReorderFlexId: widget.dataController.columnIds, delegate: widget.delegate, + columnsState: widget.columnsState, ); final reorderFlex = ReorderFlex( - key: widget.key, config: widget.reorderFlexConfig, scrollController: widget.scrollController, onDragStarted: widget.onDragStarted, @@ -159,7 +198,8 @@ class _BoardContentState extends State { dataSource: widget.dataController, direction: Axis.horizontal, interceptor: interceptor, - children: _buildColumns(interceptor.columnKeys), + reorderable: false, + children: _buildColumns(), ); return Stack( @@ -186,12 +226,12 @@ class _BoardContentState extends State { @override Widget build(BuildContext context) { return BoardOverlay( - key: _columnContainerOverlayKey, + key: _boardContentKey, initialEntries: [_overlayEntry], ); } - List _buildColumns(List columnKeys) { + List _buildColumns() { final List children = widget.dataController.columnDatas.asMap().entries.map( (item) { @@ -205,13 +245,14 @@ class _BoardContentState extends State { return ChangeNotifierProvider.value( key: ValueKey(columnData.id), - value: widget.dataController.columnController(columnData.id), + value: widget.dataController.getColumnController(columnData.id), child: Consumer( builder: (context, value, child) { final boardColumn = AFBoardColumnWidget( + // key: PageStorageKey(columnData.id), margin: _marginFromIndex(columnIndex), itemMargin: widget.config.columnItemPadding, - headerBuilder: widget.headerBuilder, + headerBuilder: _buildHeader, footBuilder: widget.footBuilder, cardBuilder: widget.cardBuilder, dataSource: dataSource, @@ -220,17 +261,11 @@ class _BoardContentState extends State { onReorder: widget.dataController.moveColumnItem, cornerRadius: widget.config.cornerRadius, backgroundColor: widget.config.columnBackgroundColor, + dragStateStorage: widget.columnsState, + dragTargetIndexKeyStorage: widget.columnsState, ); - // columnKeys - // .removeWhere((element) => element.columnId == columnData.id); - - // columnKeys.add( - // ColumnKey( - // columnId: columnData.id, - // key: boardColumn.columnGlobalKey, - // ), - // ); + widget.columnsState.addColumn(columnData.id, boardColumn); return ConstrainedBox( constraints: widget.columnConstraints, @@ -245,6 +280,21 @@ class _BoardContentState extends State { return children; } + Widget? _buildHeader( + BuildContext context, + AFBoardColumnData columnData, + ) { + if (widget.headerBuilder == null) { + return null; + } + return Selector( + selector: (context, controller) => controller.columnData.headerData, + builder: (context, headerData, _) { + return widget.headerBuilder!(context, columnData)!; + }, + ); + } + EdgeInsets _marginFromIndex(int index) { if (widget.dataController.columnDatas.isEmpty) { return widget.config.columnPadding; @@ -273,8 +323,82 @@ class _BoardColumnDataSourceImpl extends AFBoardColumnDataDataSource { @override AFBoardColumnData get columnData => - dataController.columnController(columnId).columnData; + dataController.getColumnController(columnId)!.columnData; @override List get acceptedColumnIds => dataController.columnIds; } + +class BoardColumnContext { + GlobalKey? columnKey; + DraggingState? draggingState; +} + +class BoardColumnsState extends DraggingStateStorage + with ReorderDragTargetIndexKeyStorage { + /// Quick access to the [AFBoardColumnWidget] + final Map columnKeys = {}; + final Map columnDragStates = {}; + final Map> columnDragDragTargets = {}; + + void addColumn(String columnId, AFBoardColumnWidget columnWidget) { + columnKeys[columnId] = columnWidget.globalKey; + } + + ReorderFlexState? getReorderFlexState({required String columnId}) { + final flexGlobalKey = columnKeys[columnId]; + if (flexGlobalKey == null) return null; + if (flexGlobalKey.currentState is! ReorderFlexState) return null; + final state = flexGlobalKey.currentState as ReorderFlexState; + return state; + } + + ReorderFlex? getReorderFlex({required String columnId}) { + final flexGlobalKey = columnKeys[columnId]; + if (flexGlobalKey == null) return null; + if (flexGlobalKey.currentWidget is! ReorderFlex) return null; + final widget = flexGlobalKey.currentWidget as ReorderFlex; + return widget; + } + + @override + DraggingState? read(String reorderFlexId) { + return columnDragStates[reorderFlexId]; + } + + @override + void write(String reorderFlexId, DraggingState state) { + Log.trace('$reorderFlexId Write dragging state: $state'); + columnDragStates[reorderFlexId] = state; + } + + @override + void remove(String reorderFlexId) { + columnDragStates.remove(reorderFlexId); + } + + @override + void addKey( + String reorderFlexId, + String key, + GlobalObjectKey> value, + ) { + Map? column = columnDragDragTargets[reorderFlexId]; + if (column == null) { + column = {}; + columnDragDragTargets[reorderFlexId] = column; + } + column[key] = value; + } + + @override + GlobalObjectKey>? readKey( + String reorderFlexId, String key) { + Map? column = columnDragDragTargets[reorderFlexId]; + if (column != null) { + return column[key]; + } else { + return null; + } + } +} 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 d4e5ff8800..79fe534941 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 @@ -1,5 +1,6 @@ import 'dart:collection'; +import 'package:appflowy_board/src/widgets/reorder_flex/drag_state.dart'; import 'package:flutter/material.dart'; import '../../rendering/board_overlay.dart'; import '../../utils/log.dart'; @@ -24,12 +25,13 @@ typedef OnColumnInserted = void Function(String listId, int insertedIndex); typedef AFBoardColumnCardBuilder = Widget Function( BuildContext context, + AFBoardColumnData columnData, AFColumnItem item, ); -typedef AFBoardColumnHeaderBuilder = Widget Function( +typedef AFBoardColumnHeaderBuilder = Widget? Function( BuildContext context, - AFBoardColumnData columnData, + AFBoardColumnData headerData, ); typedef AFBoardColumnFooterBuilder = Widget Function( @@ -64,7 +66,6 @@ class AFBoardColumnWidget extends StatefulWidget { final AFBoardColumnDataDataSource dataSource; final ScrollController? scrollController; final ReorderFlexConfig config; - final OnColumnDragStarted? onDragStarted; final OnColumnReorder onReorder; final OnColumnDragEnded? onDragEnded; @@ -87,7 +88,11 @@ class AFBoardColumnWidget extends StatefulWidget { final Color backgroundColor; - final GlobalKey columnGlobalKey = GlobalKey(); + final DraggingStateStorage? dragStateStorage; + + final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; + + final GlobalObjectKey globalKey; AFBoardColumnWidget({ Key? key, @@ -97,14 +102,17 @@ class AFBoardColumnWidget extends StatefulWidget { required this.onReorder, required this.dataSource, required this.phantomController, - this.onDragStarted, + this.dragStateStorage, + this.dragTargetIndexKeyStorage, this.scrollController, + this.onDragStarted, this.onDragEnded, this.margin = EdgeInsets.zero, this.itemMargin = EdgeInsets.zero, this.cornerRadius = 0.0, this.backgroundColor = Colors.transparent, - }) : config = const ReorderFlexConfig(), + }) : globalKey = GlobalObjectKey(dataSource.columnData.id), + config = const ReorderFlexConfig(setStateWhenEndDrag: false), super(key: key); @override @@ -114,7 +122,6 @@ class AFBoardColumnWidget extends StatefulWidget { class _AFBoardColumnWidgetState extends State { final GlobalKey _columnOverlayKey = GlobalKey(debugLabel: '$AFBoardColumnWidget overlay key'); - late BoardOverlayEntry _overlayEntry; @override @@ -139,7 +146,9 @@ class _AFBoardColumnWidgetState extends State { ); Widget reorderFlex = ReorderFlex( - key: widget.columnGlobalKey, + key: widget.globalKey, + dragStateStorage: widget.dragStateStorage, + dragTargetIndexKeyStorage: widget.dragTargetIndexKeyStorage, scrollController: widget.scrollController, config: widget.config, onDragStarted: (index) { @@ -162,9 +171,6 @@ class _AFBoardColumnWidgetState extends State { children: children, ); - // reorderFlex = - // KeyedSubtree(key: widget.columnGlobalKey, child: reorderFlex); - return Container( margin: widget.margin, clipBehavior: Clip.hardEdge, @@ -176,10 +182,7 @@ class _AFBoardColumnWidgetState extends State { children: [ if (header != null) header, Expanded( - child: Padding( - padding: widget.itemMargin, - child: reorderFlex, - ), + child: Padding(padding: widget.itemMargin, child: reorderFlex), ), if (footer != null) footer, ], @@ -207,7 +210,7 @@ class _AFBoardColumnWidgetState extends State { passthroughPhantomContext: item.phantomContext, ); } else { - return widget.cardBuilder(context, item); + return widget.cardBuilder(context, widget.dataSource.columnData, item); } } } 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 f26bd16c50..bc442acd2a 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 @@ -34,6 +34,13 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin { UnmodifiableListView get items => UnmodifiableListView(columnData.items); + void updateColumnName(String newName) { + if (columnData.headerData.columnName != newName) { + columnData.headerData.columnName = newName; + notifyListeners(); + } + } + /// Remove the item at [index]. /// * [index] the index of the item you want to remove /// * [notify] the default value of [notify] is true, it will notify the @@ -114,6 +121,10 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin { columnData._items.add(newItem); Log.debug('[$AFBoardColumnDataController] $columnData add $newItem'); } else { + if (index >= columnData._items.length) { + return; + } + final removedItem = columnData._items.removeAt(index); columnData._items.insert(index, newItem); Log.debug( @@ -123,6 +134,18 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin { notifyListeners(); } + void replaceOrInsertItem(AFColumnItem newItem) { + final index = columnData._items.indexWhere((item) => item.id == newItem.id); + if (index != -1) { + columnData._items.removeAt(index); + columnData._items.insert(index, newItem); + notifyListeners(); + } else { + columnData._items.add(newItem); + notifyListeners(); + } + } + bool _containsItem(AFColumnItem item) { return columnData._items.indexWhere((element) => element.id == item.id) != -1; @@ -133,16 +156,20 @@ class AFBoardColumnDataController extends ChangeNotifier with EquatableMixin { class AFBoardColumnData extends ReoderFlexItem with EquatableMixin { @override final String id; - final String desc; + AFBoardColumnHeaderData headerData; final List _items; final CustomData? customData; AFBoardColumnData({ this.customData, required this.id, - this.desc = "", + required String name, List items = const [], - }) : _items = items; + }) : _items = items, + headerData = AFBoardColumnHeaderData( + columnId: id, + columnName: name, + ); /// Returns the readonly List UnmodifiableListView get items => @@ -156,3 +183,10 @@ class AFBoardColumnData extends ReoderFlexItem with EquatableMixin { return 'Column:[$id]'; } } + +class AFBoardColumnHeaderData { + String columnId; + String columnName; + + AFBoardColumnHeaderData({required this.columnId, required this.columnName}); +} 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 a08bba378f..75282d183c 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 @@ -89,10 +89,6 @@ class AFBoardDataController extends ChangeNotifier if (columnIds.isNotEmpty && notify) notifyListeners(); } - AFBoardColumnDataController columnController(String columnId) { - return _columnControllers[columnId]!; - } - AFBoardColumnDataController? getColumnController(String columnId) { final columnController = _columnControllers[columnId]; if (columnController == null) { @@ -129,6 +125,10 @@ class AFBoardDataController extends ChangeNotifier getColumnController(columnId)?.removeWhere((item) => item.id == itemId); } + void updateColumnItem(String columnId, AFColumnItem item) { + getColumnController(columnId)?.replaceOrInsertItem(item); + } + @override @protected void swapColumnItem( @@ -137,15 +137,14 @@ class AFBoardDataController extends ChangeNotifier String toColumnId, int toColumnIndex, ) { - final item = columnController(fromColumnId).removeAt(fromColumnIndex); - - if (columnController(toColumnId).items.length > toColumnIndex) { - assert(columnController(toColumnId).items[toColumnIndex] - is PhantomColumnItem); + final fromColumnController = getColumnController(fromColumnId)!; + final toColumnController = getColumnController(toColumnId)!; + final item = fromColumnController.removeAt(fromColumnIndex); + if (toColumnController.items.length > toColumnIndex) { + assert(toColumnController.items[toColumnIndex] is PhantomColumnItem); } - columnController(toColumnId).replace(toColumnIndex, item); - + toColumnController.replace(toColumnIndex, item); onMoveColumnItemToColumn?.call( fromColumnId, fromColumnIndex, @@ -174,9 +173,12 @@ class AFBoardDataController extends ChangeNotifier @override @protected bool removePhantom(String columnId) { - final columnController = this.columnController(columnId); + final columnController = getColumnController(columnId); + if (columnController == null) { + Log.warn('Can not find the column controller with columnId: $columnId'); + return false; + } final index = columnController.items.indexWhere((item) => item.isPhantom); - final isExist = index != -1; if (isExist) { columnController.removeAt(index); @@ -190,11 +192,10 @@ class AFBoardDataController extends ChangeNotifier @override @protected void updatePhantom(String columnId, int newIndex) { - final columnDataController = columnController(columnId); + final columnDataController = getColumnController(columnId)!; final index = columnDataController.items.indexWhere((item) => item.isPhantom); - assert(index != -1); if (index != -1) { if (index != newIndex) { Log.trace( @@ -208,6 +209,6 @@ class AFBoardDataController extends ChangeNotifier @override @protected void insertPhantom(String columnId, int index, PhantomColumnItem item) { - columnController(columnId).insert(index, item); + getColumnController(columnId)!.insert(index, item); } } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart index 592277afbc..f795efa6b3 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_state.dart @@ -24,6 +24,10 @@ class FlexDragTargetData extends DragTargetData { final String dragTargetId; + Offset dragTargetOffset = Offset.zero; + + final GlobalObjectKey dragTargetIndexKey; + final String reorderFlexId; final ReoderFlexItem reorderFlexItem; @@ -33,6 +37,7 @@ class FlexDragTargetData extends DragTargetData { required this.draggingIndex, required this.reorderFlexId, required this.reorderFlexItem, + required this.dragTargetIndexKey, required DraggingState state, }) : _state = state; @@ -40,6 +45,50 @@ class FlexDragTargetData extends DragTargetData { String toString() { return 'ReorderFlexId: $reorderFlexId, dragTargetId: $dragTargetId'; } + + bool isOverlapWithWidgets(List widgetKeys) { + final renderBox = dragTargetIndexKey.currentContext?.findRenderObject(); + + if (renderBox == null) return false; + if (renderBox is! RenderBox) return false; + final size = feedbackSize ?? Size.zero; + final Rect rect = dragTargetOffset & size; + + for (final widgetKey in widgetKeys) { + final renderObject = widgetKey.currentContext?.findRenderObject(); + if (renderObject != null && renderObject is RenderBox) { + Rect widgetRect = + renderObject.localToGlobal(Offset.zero) & renderObject.size; + // return rect.overlaps(widgetRect); + if (rect.right <= widgetRect.left || widgetRect.right <= rect.left) { + return false; + } + + if (rect.bottom <= widgetRect.top || widgetRect.bottom <= rect.top) { + return false; + } + return true; + } + } + + // final HitTestResult result = HitTestResult(); + // WidgetsBinding.instance.hitTest(result, position); + // for (final HitTestEntry entry in result.path) { + // final HitTestTarget target = entry.target; + // if (target is RenderMetaData) { + // print(target.metaData); + // } + // print(target); + // } + + return false; + } +} + +abstract class DraggingStateStorage { + void write(String reorderFlexId, DraggingState state); + void remove(String reorderFlexId); + DraggingState? read(String reorderFlexId); } class DraggingState { @@ -128,6 +177,7 @@ class DraggingState { /// Set the currentIndex to nextIndex void moveDragTargetToNext() { + Log.debug('$reorderFlexId updateCurrentIndex: $nextIndex'); currentIndex = nextIndex; } @@ -136,6 +186,14 @@ class DraggingState { nextIndex = index; } + void setStartDraggingIndex(int index) { + Log.debug('$reorderFlexId setDragIndex: $index'); + dragStartIndex = index; + phantomIndex = index; + currentIndex = index; + nextIndex = index; + } + bool isNotDragging() { return dragStartIndex == -1; } 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 8217d2736f..9da9e393ad 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 @@ -26,6 +26,11 @@ typedef DragTargetWillAccepted = bool Function( /// typedef DragTargetOnStarted = void Function(Widget, int, Size?); +typedef DragTargetOnMove = void Function( + T dragTargetData, + Offset offset, +); + /// typedef DragTargetOnEnded = void Function( T dragTargetData); @@ -39,13 +44,15 @@ class ReorderDragTarget extends StatefulWidget { final Widget child; final T dragTargetData; - final GlobalObjectKey _indexGlobalKey; + final GlobalObjectKey indexGlobalKey; /// Called when dragTarget is being dragging. final DragTargetOnStarted onDragStarted; final DragTargetOnEnded onDragEnded; + final DragTargetOnMove onDragMoved; + /// Called to determine whether this widget is interested in receiving a given /// piece of data being dragged over this drag target. /// @@ -68,22 +75,25 @@ class ReorderDragTarget extends StatefulWidget { final AnimationController deleteAnimationController; final bool useMoveAnimation; + final bool draggable; - ReorderDragTarget({ + const ReorderDragTarget({ Key? key, required this.child, + required this.indexGlobalKey, required this.dragTargetData, required this.onDragStarted, + required this.onDragMoved, required this.onDragEnded, required this.onWillAccept, required this.insertAnimationController, required this.deleteAnimationController, required this.useMoveAnimation, + required this.draggable, this.onAccept, this.onLeave, this.draggableTargetBuilder, - }) : _indexGlobalKey = GlobalObjectKey(child.key!), - super(key: key); + }) : super(key: key); @override State> createState() => _ReorderDragTargetState(); @@ -104,6 +114,9 @@ class _ReorderDragTargetState return widget.onWillAccept(dragTargetData); }, onAccept: widget.onAccept, + onMove: (detail) { + widget.onDragMoved(detail.data, detail.offset); + }, onLeave: (dragTargetData) { assert(dragTargetData != null); if (dragTargetData != null) { @@ -112,7 +125,7 @@ class _ReorderDragTargetState }, ); - dragTarget = KeyedSubtree(key: widget._indexGlobalKey, child: dragTarget); + dragTarget = KeyedSubtree(key: widget.indexGlobalKey, child: dragTarget); return dragTarget; } @@ -121,6 +134,9 @@ class _ReorderDragTargetState List acceptedCandidates, List rejectedCandidates, ) { + if (!widget.draggable) { + return widget.child; + } Widget feedbackBuilder = Builder(builder: (BuildContext context) { BoxConstraints contentSizeConstraints = BoxConstraints.loose(_draggingFeedbackSize!); @@ -140,7 +156,7 @@ class _ReorderDragTargetState widget.insertAnimationController, widget.deleteAnimationController, ) ?? - LongPressDraggable( + Draggable( maxSimultaneousDrags: 1, data: widget.dragTargetData, ignoringFeedbackSemantics: false, @@ -150,7 +166,7 @@ class _ReorderDragTargetState child: widget.child, ), onDragStarted: () { - _draggingFeedbackSize = widget._indexGlobalKey.currentContext?.size; + _draggingFeedbackSize = widget.indexGlobalKey.currentContext?.size; widget.onDragStarted( widget.child, widget.dragTargetData.draggingIndex, diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart index 36366cd1e0..20f66c0921 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/drag_target_interceptor.dart @@ -1,7 +1,7 @@ import 'dart:async'; +import 'package:appflowy_board/src/widgets/board.dart'; import 'package:flutter/material.dart'; - import '../../utils/log.dart'; import 'drag_state.dart'; import 'drag_target.dart'; @@ -41,7 +41,7 @@ abstract class OverlapDragTargetDelegate { int dragTargetIndex, ); - int canMoveTo(String dragTargetId); + int getInsertedIndex(String dragTargetId); } /// [OverlappingDragTargetInterceptor] is used to receive the overlapping @@ -55,13 +55,14 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor { final String reorderFlexId; final List acceptedReorderFlexId; final OverlapDragTargetDelegate delegate; - final List columnKeys = []; + final BoardColumnsState columnsState; Timer? _delayOperation; OverlappingDragTargetInterceptor({ required this.delegate, required this.reorderFlexId, required this.acceptedReorderFlexId, + required this.columnsState, }); @override @@ -79,24 +80,30 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor { if (dragTargetId == dragTargetData.reorderFlexId) { delegate.cancel(); } else { + // Ignore the event if the dragTarget overlaps with the other column's dragTargets. + final columnKeys = columnsState.columnDragDragTargets[dragTargetId]; + if (columnKeys != null) { + final keys = columnKeys.values.toList(); + if (dragTargetData.isOverlapWithWidgets(keys)) { + _delayOperation?.cancel(); + return true; + } + } + /// The priority of the column interactions is high than the cross column. /// Workaround: delay 100 milliseconds to lower the cross column event priority. + /// _delayOperation?.cancel(); _delayOperation = Timer(const Duration(milliseconds: 100), () { - final index = delegate.canMoveTo(dragTargetId); + final index = delegate.getInsertedIndex(dragTargetId); if (index != -1) { Log.trace( '[$OverlappingDragTargetInterceptor] move to $dragTargetId at $index'); delegate.moveTo(dragTargetId, dragTargetData, index); - // final columnIndex = columnKeys - // .indexWhere((element) => element.columnId == dragTargetId); - // if (columnIndex != -1) { - // final state = columnKeys[columnIndex].key.currentState; - // if (state is ReorderFlexState) { - // state.handleOnWillAccept(context, index); - // } - // } + columnsState + .getReorderFlexState(columnId: dragTargetId) + ?.resetDragTargetIndex(index); } }); } @@ -105,12 +112,6 @@ class OverlappingDragTargetInterceptor extends DragTargetInterceptor { } } -class ColumnKey { - String columnId; - GlobalKey key; - ColumnKey({required this.columnId, required this.key}); -} - abstract class CrossReorderFlexDragTargetDelegate { /// * [reorderFlexId] is the id that the [ReorderFlex] passed in. bool acceptNewDragTargetData( @@ -130,6 +131,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor { final String reorderFlexId; final List acceptedReorderFlexIds; final CrossReorderFlexDragTargetDelegate delegate; + @override final ReorderFlexDraggableTargetBuilder? draggableTargetBuilder; @@ -187,7 +189,7 @@ class CrossReorderFlexDragTargetInterceptor extends DragTargetInterceptor { ); Log.debug( - '[$CrossReorderFlexDragTargetInterceptor] dargTargetIndex: $dragTargetIndex, reorderFlexId: $reorderFlexId'); + '[$CrossReorderFlexDragTargetInterceptor] isNewDragTarget: $isNewDragTarget, dargTargetIndex: $dragTargetIndex, reorderFlexId: $reorderFlexId'); if (isNewDragTarget == false) { delegate.updateDragTargetData(reorderFlexId, dragTargetIndex); 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 26b68c2304..bff76cfe52 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_flex/reorder_flex.dart @@ -31,6 +31,11 @@ abstract class ReoderFlexItem { String get id; } +abstract class ReorderDragTargetIndexKeyStorage { + void addKey(String reorderFlexId, String key, GlobalObjectKey value); + GlobalObjectKey? readKey(String reorderFlexId, String key); +} + class ReorderFlexConfig { /// The opacity of the dragging widget final double draggingWidgetOpacity = 0.3; @@ -41,18 +46,25 @@ class ReorderFlexConfig { // How long an animation to scroll to an off-screen element final Duration scrollAnimationDuration = const Duration(milliseconds: 300); + /// Determines if setSatte method needs to be called when the drag is complete. + /// Default value is [true]. + /// + /// If the [ReorderFlex] will be rebuild after the [ReorderFlex]'s children + /// were changed, then the [setStateWhenEndDrag] should set to [false]. + final bool setStateWhenEndDrag; + final bool useMoveAnimation; final bool useMovePlaceholder; const ReorderFlexConfig({ this.useMoveAnimation = true, + this.setStateWhenEndDrag = true, }) : useMovePlaceholder = !useMoveAnimation; } class ReorderFlex extends StatefulWidget { final ReorderFlexConfig config; - final List children; /// [direction] How to place the children, default is Axis.vertical @@ -74,18 +86,29 @@ class ReorderFlex extends StatefulWidget { final DragTargetInterceptor? interceptor; - const ReorderFlex({ + final DraggingStateStorage? dragStateStorage; + + final ReorderDragTargetIndexKeyStorage? dragTargetIndexKeyStorage; + + final bool reorderable; + + ReorderFlex({ Key? key, this.scrollController, required this.dataSource, required this.children, required this.config, required this.onReorder, + this.reorderable = true, + this.dragStateStorage, + this.dragTargetIndexKeyStorage, this.onDragStarted, this.onDragEnded, this.interceptor, this.direction = Axis.vertical, - }) : super(key: key); + }) : assert(children.every((Widget w) => w.key != null), + 'All child must have a key.'), + super(key: key); @override State createState() => ReorderFlexState(); @@ -115,7 +138,12 @@ class ReorderFlexState extends State @override void initState() { _notifier = ReorderFlexNotifier(); - dragState = DraggingState(widget.reorderFlexId); + final flexId = widget.reorderFlexId; + dragState = widget.dragStateStorage?.read(flexId) ?? + DraggingState(widget.reorderFlexId); + Log.trace('[DragTarget] init dragState: $dragState'); + + widget.dragStateStorage?.remove(flexId); _animation = DragTargetAnimation( reorderAnimationDuration: widget.config.reorderAnimationDuration, @@ -159,7 +187,17 @@ class ReorderFlexState extends State for (int i = 0; i < widget.children.length; i += 1) { Widget child = widget.children[i]; - children.add(_wrap(child, i)); + final ReoderFlexItem item = widget.dataSource.items[i]; + + final indexKey = GlobalObjectKey(child.key!); + // Save the index key for quick access + widget.dragTargetIndexKeyStorage?.addKey( + widget.reorderFlexId, + item.id, + indexKey, + ); + + children.add(_wrap(child, i, indexKey)); // if (widget.config.useMovePlaceholder) { // children.add(DragTargeMovePlaceholder( @@ -203,10 +241,10 @@ class ReorderFlexState extends State /// [child]: the child will be wrapped with dartTarget /// [childIndex]: the index of the child in a list - Widget _wrap(Widget child, int childIndex) { + Widget _wrap(Widget child, int childIndex, GlobalObjectKey indexKey) { return Builder(builder: (context) { final ReorderDragTarget dragTarget = - _buildDragTarget(context, child, childIndex); + _buildDragTarget(context, child, childIndex, indexKey); int shiftedIndex = childIndex; if (dragState.isOverlapWithPhantom()) { @@ -312,44 +350,58 @@ class ReorderFlexState extends State } ReorderDragTarget _buildDragTarget( - BuildContext builderContext, Widget child, int dragTargetIndex) { - final ReoderFlexItem reorderFlexItem = - widget.dataSource.items[dragTargetIndex]; + BuildContext builderContext, + Widget child, + int dragTargetIndex, + GlobalObjectKey indexKey, + ) { + final reorderFlexItem = widget.dataSource.items[dragTargetIndex]; return ReorderDragTarget( + indexGlobalKey: indexKey, dragTargetData: FlexDragTargetData( draggingIndex: dragTargetIndex, reorderFlexId: widget.reorderFlexId, reorderFlexItem: reorderFlexItem, state: dragState, dragTargetId: reorderFlexItem.id, + dragTargetIndexKey: indexKey, ), onDragStarted: (draggingWidget, draggingIndex, size) { Log.debug( "[DragTarget] Column:[${widget.dataSource.identifier}] start dragging item at $draggingIndex"); _startDragging(draggingWidget, draggingIndex, size); widget.onDragStarted?.call(draggingIndex); + widget.dragStateStorage?.remove(widget.reorderFlexId); + }, + onDragMoved: (dragTargetData, offset) { + dragTargetData.dragTargetOffset = offset; }, onDragEnded: (dragTargetData) { if (!mounted) return; - Log.debug( "[DragTarget]: Column:[${widget.dataSource.identifier}] end dragging"); _notifier.updateDragTargetIndex(-1); - setState(() { + + onDragEnded() { if (dragTargetData.reorderFlexId == widget.reorderFlexId) { _onReordered( dragState.dragStartIndex, dragState.currentIndex, ); } - dragState.endDragging(); widget.onDragEnded?.call(); - }); + } + + if (widget.config.setStateWhenEndDrag) { + setState(() => onDragEnded()); + } else { + onDragEnded(); + } }, onWillAccept: (FlexDragTargetData dragTargetData) { // Do not receive any events if the Insert item is animating. - if (_animation.deleteController.isAnimating) { + if (_animation.insertController.isAnimating) { return false; } @@ -385,6 +437,7 @@ class ReorderFlexState extends State deleteAnimationController: _animation.deleteController, draggableTargetBuilder: widget.interceptor?.draggableTargetBuilder, useMoveAnimation: widget.config.useMoveAnimation, + draggable: widget.reorderable, child: child, ); } @@ -431,14 +484,20 @@ class ReorderFlexState extends State }); } + void resetDragTargetIndex(int dragTargetIndex) { + dragState.setStartDraggingIndex(dragTargetIndex); + widget.dragStateStorage?.write( + widget.reorderFlexId, + dragState, + ); + } + bool handleOnWillAccept(BuildContext context, int dragTargetIndex) { final dragIndex = dragState.dragStartIndex; /// The [willAccept] will be true if the dargTarget is the widget that gets /// dragged and it is dragged on top of the other dragTargets. /// - Log.trace( - '[$ReorderDragTarget] ${widget.dataSource.identifier} on will accept, dragIndex:$dragIndex, dragTargetIndex:$dragTargetIndex, count: ${widget.dataSource.items.length}'); bool willAccept = dragState.dragStartIndex == dragIndex && dragIndex != dragTargetIndex; @@ -452,6 +511,9 @@ class ReorderFlexState extends State _requestAnimationToNextIndex(isAcceptingNewTarget: true); }); + Log.trace( + '[$ReorderDragTarget] ${widget.reorderFlexId} dragging state: $dragState}'); + _scrollTo(context); /// If the target is not the original starting point, then we will accept the drop. @@ -515,6 +577,50 @@ class ReorderFlexState extends State } } + void scrollToBottom(VoidCallback? completed) { + if (_scrolling) { + completed?.call(); + return; + } + + if (widget.dataSource.items.isNotEmpty) { + final item = widget.dataSource.items.last; + final indexKey = widget.dragTargetIndexKeyStorage?.readKey( + widget.reorderFlexId, + item.id, + ); + if (indexKey == null) { + completed?.call(); + return; + } + + final indexContext = indexKey.currentContext; + if (indexContext == null || _scrollController.hasClients == false) { + completed?.call(); + return; + } + + final renderObject = indexContext.findRenderObject(); + if (renderObject != null) { + _scrolling = true; + _scrollController.position + .ensureVisible( + renderObject, + alignment: 0.5, + duration: const Duration(milliseconds: 120), + ) + .then((value) { + setState(() { + _scrolling = false; + completed?.call(); + }); + }); + } else { + completed?.call(); + } + } + } + // Scrolls to a target context if that context is not on the screen. void _scrollTo(BuildContext context) { if (_scrolling) return; 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 4dd4f05a74..0f63266e51 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,7 +1,7 @@ +import 'package:appflowy_board/appflowy_board.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_interceptor.dart'; @@ -39,8 +39,12 @@ class BoardPhantomController extends OverlapDragTargetDelegate with CrossReorderFlexDragTargetDelegate { PhantomRecord? phantomRecord; final BoardPhantomControllerDelegate delegate; - final columnsState = ColumnPhantomStateController(); - BoardPhantomController({required this.delegate}); + final BoardColumnsState columnsState; + final phantomState = ColumnPhantomState(); + BoardPhantomController({ + required this.delegate, + required this.columnsState, + }); bool isFromColumn(String columnId) { if (phantomRecord != null) { @@ -59,19 +63,19 @@ class BoardPhantomController extends OverlapDragTargetDelegate } void columnStartDragging(String columnId) { - columnsState.setColumnIsDragging(columnId, true); + phantomState.setColumnIsDragging(columnId, true); } /// Remove the phantom in the column when the column is end dragging. void columnEndDragging(String columnId) { - columnsState.setColumnIsDragging(columnId, false); + phantomState.setColumnIsDragging(columnId, false); if (phantomRecord == null) return; final fromColumnId = phantomRecord!.fromColumnId; final toColumnId = phantomRecord!.toColumnId; if (fromColumnId == columnId) { - columnsState.notifyDidRemovePhantom(toColumnId); + phantomState.notifyDidRemovePhantom(toColumnId); } if (phantomRecord!.toColumnId == columnId) { @@ -82,8 +86,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate phantomRecord!.toColumnIndex, ); - Log.debug( - "[$BoardPhantomController] did move ${phantomRecord.toString()}"); + // Log.debug( + // "[$BoardPhantomController] did move ${phantomRecord.toString()}"); phantomRecord = null; } } @@ -91,8 +95,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate /// Remove the phantom in the column if it contains phantom void _removePhantom(String columnId) { if (delegate.removePhantom(columnId)) { - columnsState.notifyDidRemovePhantom(columnId); - columnsState.removeColumnListener(columnId); + phantomState.notifyDidRemovePhantom(columnId); + phantomState.removeColumnListener(columnId); } } @@ -105,7 +109,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate index: phantomIndex, dragTargetData: dragTargetData, ); - columnsState.addColumnListener(toColumnId, phantomContext); + phantomState.addColumnListener(toColumnId, phantomContext); delegate.insertPhantom( toColumnId, @@ -113,7 +117,7 @@ class BoardPhantomController extends OverlapDragTargetDelegate PhantomColumnItem(phantomContext), ); - columnsState.notifyDidInsertPhantom(toColumnId, phantomIndex); + phantomState.notifyDidInsertPhantom(toColumnId, phantomIndex); } /// Reset or initial the [PhantomRecord] @@ -150,7 +154,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate if (phantomRecord == null) { _resetPhantomRecord(reorderFlexId, dragTargetData, dragTargetIndex); _insertPhantom(reorderFlexId, dragTargetData, dragTargetIndex); - return false; + + return true; } final isNewDragTarget = phantomRecord!.toColumnId != reorderFlexId; @@ -203,8 +208,8 @@ class BoardPhantomController extends OverlapDragTargetDelegate } @override - int canMoveTo(String dragTargetId) { - if (columnsState.isDragging(dragTargetId)) { + int getInsertedIndex(String dragTargetId) { + if (phantomState.isDragging(dragTargetId)) { return -1; } @@ -243,8 +248,7 @@ class PhantomRecord { if (fromColumnIndex == index) { return; } - Log.debug( - '[$PhantomRecord] Update Column:[$fromColumnId] remove position to $index'); + fromColumnIndex = index; } diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart index 443d7fb936..c550ee3bca 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/reorder_phantom/phantom_state.dart @@ -1,7 +1,7 @@ import 'phantom_controller.dart'; import 'package:flutter/material.dart'; -class ColumnPhantomStateController { +class ColumnPhantomState { final _states = {}; void setColumnIsDragging(String columnId, bool isDragging) { diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart index 323964c75f..77cfc1cb13 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/card.dart @@ -2,16 +2,17 @@ import 'package:flutter/material.dart'; class AppFlowyColumnItemCard extends StatefulWidget { final Widget? child; - final Color backgroundColor; - final double cornerRadius; final EdgeInsets margin; final BoxConstraints boxConstraints; + final BoxDecoration decoration; const AppFlowyColumnItemCard({ this.child, - this.cornerRadius = 0.0, this.margin = const EdgeInsets.all(4), - this.backgroundColor = Colors.white, + this.decoration = const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.zero, + ), this.boxConstraints = const BoxConstraints(minHeight: 40), Key? key, }) : super(key: key); @@ -24,14 +25,11 @@ class _AppFlowyColumnItemCardState extends State { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(4), + padding: widget.margin, child: Container( clipBehavior: Clip.hardEdge, constraints: widget.boxConstraints, - decoration: BoxDecoration( - color: widget.backgroundColor, - borderRadius: BorderRadius.circular(widget.cornerRadius), - ), + decoration: widget.decoration, child: widget.child, ), ); diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart index 7f5655fe60..c877e4fe4d 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/footer.dart @@ -12,7 +12,7 @@ class AppFlowyColumnFooter extends StatefulWidget { const AppFlowyColumnFooter({ this.icon, this.title, - this.margin = EdgeInsets.zero, + this.margin = const EdgeInsets.symmetric(horizontal: 12), required this.height, this.onAddButtonClick, Key? key, @@ -30,12 +30,13 @@ class _AppFlowyColumnFooterState extends State { child: SizedBox( height: widget.height, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: widget.margin, child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (widget.icon != null) widget.icon!, + const SizedBox(width: 8), if (widget.title != null) widget.title!, ], ), diff --git a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart index fdebc7ef21..88f52c9134 100644 --- a/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart +++ b/frontend/app_flowy/packages/appflowy_board/lib/src/widgets/styled_widgets/header.dart @@ -45,15 +45,25 @@ class _AppFlowyColumnHeaderState extends State { } if (widget.moreIcon != null) { - children.add(const Spacer()); + // children.add(const Spacer()); children.add( - IconButton(onPressed: widget.onMoreButtonClick, icon: widget.moreIcon!), + IconButton( + onPressed: widget.onMoreButtonClick, + icon: widget.moreIcon!, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), ); } if (widget.addIcon != null) { children.add( - IconButton(onPressed: widget.onAddButtonClick, icon: widget.addIcon!), + IconButton( + onPressed: widget.onAddButtonClick, + icon: widget.addIcon!, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), ); } @@ -61,9 +71,7 @@ class _AppFlowyColumnHeaderState extends State { height: widget.height, child: Padding( padding: widget.margin, - child: Row( - children: children, - ), + child: Row(children: children), ), ); } diff --git a/frontend/app_flowy/packages/appflowy_board/pubspec.yaml b/frontend/app_flowy/packages/appflowy_board/pubspec.yaml index 6bb2feabfe..14dc501dcc 100644 --- a/frontend/app_flowy/packages/appflowy_board/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_board/pubspec.yaml @@ -1,6 +1,6 @@ name: appflowy_board description: AppFlowy board implementation. -version: 0.0.5 +version: 0.0.6 homepage: https://github.com/AppFlowy-IO/AppFlowy repository: https://github.com/AppFlowy-IO/AppFlowy/tree/main/frontend/app_flowy/packages/appflowy_board @@ -17,7 +17,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^2.0.0 + flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md b/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md index bac617937b..2d76371553 100644 --- a/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md +++ b/frontend/app_flowy/packages/appflowy_editor/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.0.3 +* Support insert image. +* Support insert link. +* Fix some bugs. + ## 0.0.2 Minor Updates to Documentation. diff --git a/frontend/app_flowy/packages/appflowy_editor/README.md b/frontend/app_flowy/packages/appflowy_editor/README.md index 8e239b6004..e450a5a7c1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/README.md +++ b/frontend/app_flowy/packages/appflowy_editor/README.md @@ -21,7 +21,7 @@ and the Flutter guide for

- +
## Key Features diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg new file mode 100644 index 0000000000..7f303d737f --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/clear.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg new file mode 100644 index 0000000000..101cf34205 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg new file mode 100644 index 0000000000..b7f242542d --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_center.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_center.svg new file mode 100644 index 0000000000..ae9c2cfd44 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_left.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_left.svg new file mode 100644 index 0000000000..b4f2d0101e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_right.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_right.svg new file mode 100644 index 0000000000..86a1facaac --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/align_right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg new file mode 100644 index 0000000000..101cf34205 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg new file mode 100644 index 0000000000..5a3d972872 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg new file mode 100644 index 0000000000..3e57a6b000 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/divider.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/share.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/share.svg new file mode 100644 index 0000000000..279e7ac471 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/image_toolbar/share.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg new file mode 100644 index 0000000000..5fbcc8d787 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg index 0f3d33f6d3..1393e71556 100644 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/quote.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg new file mode 100644 index 0000000000..0e2aafe0ec --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/quote.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/quote.svg new file mode 100644 index 0000000000..5c1cbb4a50 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/selection_menu/quote.svg @@ -0,0 +1,4 @@ + + + + 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 index 612e8377b6..279e7ac471 100644 --- a/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/toolbar/link.svg @@ -1,4 +1,4 @@ - - + + diff --git a/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json index 901e57f796..48184a6511 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json +++ b/frontend/app_flowy/packages/appflowy_editor/example/assets/example.json @@ -1,267 +1,102 @@ { "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" + "image_src": "https://s1.ax1x.com/2022/08/26/v2sSbR.jpg", + "align": "center" } }, { "type": "text", + "attributes": { "subtype": "heading", "heading": "h1" }, "delta": [ + { "insert": "👋 " }, + { "insert": "Welcome to ", "attributes": { "bold": true } }, { - "insert": "🌶 Read Me" - } - ], - "attributes": { - "subtype": "heading", - "heading": "h1" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "👋 Welcome to FlowyEditor" - } - ], - "attributes": { - "subtype": "heading", - "heading": "h2" - } - }, - { - "type": "text", - "delta": [ - { - "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", + "insert": "AppFlowy Editor", "attributes": { - "href": "https://github.com/AppFlowy-IO/AppFlowy" + "href": "appflowy.io", + "italic": true, + "bold": true + } + } + ] + }, + { "type": "text", "delta": [] }, + { + "type": "text", + "delta": [ + { "insert": "AppFlowy Editor is a " }, + { "insert": "highly customizable", "attributes": { "bold": true } }, + { "insert": " " }, + { "insert": "rich-text editor", "attributes": { "italic": true } }, + { "insert": " for " }, + { "insert": "Flutter", "attributes": { "underline": true } } + ] + }, + { + "type": "text", + "attributes": { "checkbox": true, "subtype": "checkbox" }, + "delta": [{ "insert": "Customizable" }] + }, + { + "type": "text", + "attributes": { "checkbox": true, "subtype": "checkbox" }, + "delta": [{ "insert": "Test-covered" }] + }, + { + "type": "text", + "attributes": { "checkbox": false, "subtype": "checkbox" }, + "delta": [{ "insert": "more to come!" }] + }, + { "type": "text", "delta": [] }, + { + "type": "text", + "attributes": { "subtype": "quote" }, + "delta": [{ "insert": "Here is an exmaple you can give it a try" }] + }, + { "type": "text", "delta": [] }, + { + "type": "text", + "delta": [ + { "insert": "You can also use " }, + { + "insert": "AppFlowy Editor", + "attributes": { + "italic": true, + "bold": true, + "backgroundColor": "0x6000BCF0" } }, + { "insert": " as a component to build your own app." } + ] + }, + { "type": "text", "delta": [] }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [{ "insert": "Use / to insert blocks" }] + }, + { + "type": "text", + "attributes": { "subtype": "bulleted-list" }, + "delta": [ { - "insert": " helps you. 😊😊😊" + "insert": "Select text to trigger to the toolbar to format your notes." } ] }, + { "type": "text", "delta": [] }, { "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.😄😄😄" + "insert": "If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!" } ] - }, - { - "type": "text", - "delta": [ - { - "insert": "Here are the basics:" - } - ], - "attributes": { - "subtype": "heading", - "heading": "h3" - } - }, - { - "type": "text", - "delta": [ - { "insert": "Click " }, - { "insert": "anywhere", "attributes": { "underline": true } }, - { "insert": " and just typing." } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "Hit" - }, - { - "insert": " / ", - "attributes": { "backgroundColor": "0xFFFFFF00" } - }, - { - "insert": "to see all the types of content you can add - headers, bulleted lists, checkboxes, etc." - } - ] - }, - { - "type": "text", - "delta": [ - { - "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 } }, - { "insert": "writing", "attributes": { "strikethrough": true } }, - { "insert": "." } - ] - }, - { - "type": "text", - "delta": [ - { - "insert": "Here are the plugins:" - } - ], - "attributes": { - "subtype": "heading", - "heading": "h3" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "checkbox", - "checkbox": false - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "checkbox", - "checkbox": false - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "checkbox", - "checkbox": false - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "bulleted-list" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "bulleted-list" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello " - }, - { - "insert": "world", - "attributes": { "bold": true } - } - ], - "attributes": { - "subtype": "bulleted-list" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "quote" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "quote" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "quote" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "number-list", - "number": 1 - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "number-list", - "number": 2 - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Hello world" - } - ], - "attributes": { - "subtype": "number-list", - "number": 3 - } } ] } diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart index e17088c954..4bd1cb1972 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart @@ -1,14 +1,15 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.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:path_provider/path_provider.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'expandable_floating_action_button.dart'; + void main() { runApp(const MyApp()); } @@ -16,20 +17,11 @@ void main() { class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( + debugShowCheckedModeBanner: false, theme: ThemeData( - // This is the theme of your application. - // - // Try running your application with "flutter run". You'll see the - // application has a blue toolbar. Then, without quitting the app, try - // changing the primarySwatch below to Colors.green and then invoke - // "hot reload" (press "r" in the console where you ran "flutter run", - // or simply save your changes to "hot reload" in a Flutter IDE). - // Notice that the counter didn't reset back to zero; the application - // is not restarted. primarySwatch: Colors.blue, ), home: const MyHomePage(title: 'AppFlowyEditor Example'), @@ -39,16 +31,6 @@ class MyApp extends StatelessWidget { class MyHomePage extends StatefulWidget { const MyHomePage({Key? key, required this.title}) : super(key: key); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - final String title; @override @@ -56,54 +38,65 @@ class MyHomePage extends StatefulWidget { } class _MyHomePageState extends State { - final editorKey = GlobalKey(); - int page = 0; + int _pageIndex = 0; + late EditorState _editorState; + Future? _jsonString; @override Widget build(BuildContext context) { return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: _buildBody(), - ), + extendBodyBehindAppBar: true, + body: _buildEditor(context), floatingActionButton: _buildExpandableFab(), ); } - Widget _buildBody() { - if (page == 0) { - return _buildAppFlowyEditorWithExample(); - } else if (page == 1) { - return _buildAppFlowyEditorWithEmptyDocument(); - } else if (page == 2) { - return _buildAppFlowyEditorWithBigDocument(); + Widget _buildEditor(BuildContext context) { + if (_jsonString != null) { + return _buildEditorWithJsonString(_jsonString!); } - return Container(); + if (_pageIndex == 0) { + return _buildEditorWithJsonString( + rootBundle.loadString('assets/example.json'), + ); + } else if (_pageIndex == 1) { + return _buildEditorWithJsonString( + rootBundle.loadString('assets/big_document.json'), + ); + } else if (_pageIndex == 2) { + return _buildEditorWithJsonString( + Future.value( + jsonEncode(EditorState.empty().document.toJson()), + ), + ); + } + throw UnimplementedError(); } - Widget _buildAppFlowyEditorWithEmptyDocument() { - final editorState = EditorState.empty(); - final editor = AppFlowyEditor( - editorState: editorState, - keyEventHandlers: const [], - customBuilders: const {}, - ); - return editor; - } - - Widget _buildAppFlowyEditorWithExample() { + Widget _buildEditorWithJsonString(Future jsonString) { return FutureBuilder( - future: rootBundle.loadString('assets/example.json'), - builder: (context, snapshot) { + future: jsonString, + builder: (_, snapshot) { if (snapshot.hasData) { - final data = Map.from(json.decode(snapshot.data!)); - final editorState = EditorState(document: StateTree.fromJson(data)); - editorState.logConfiguration + _editorState = EditorState( + document: StateTree.fromJson( + Map.from( + json.decode(snapshot.data!), + ), + ), + ); + _editorState.logConfiguration ..level = LogLevel.all ..handler = (message) { debugPrint(message); }; - return _buildAppFlowyEditor(editorState); + return SizedBox( + width: MediaQuery.of(context).size.width, + child: AppFlowyEditor( + editorState: _editorState, + editorStyle: const EditorStyle.defaultStyle(), + ), + ); } else { return const Center( child: CircularProgressIndicator(), @@ -113,71 +106,64 @@ class _MyHomePageState extends State { ); } - 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, children: [ ActionButton( - onPressed: () { - if (page == 0) return; - setState(() { - page = 0; - }); - }, - icon: const Icon(Icons.note_add), + icon: const Icon(Icons.abc), + onPressed: () => _switchToPage(0), ), ActionButton( - icon: const Icon(Icons.document_scanner), - onPressed: () { - if (page == 1) return; - setState(() { - page = 1; - }); - }, + icon: const Icon(Icons.abc), + onPressed: () => _switchToPage(1), ), ActionButton( - onPressed: () { - if (page == 2) return; - setState(() { - page = 2; - }); - }, - icon: const Icon(Icons.text_fields), + icon: const Icon(Icons.abc), + onPressed: () => _switchToPage(2), + ), + ActionButton( + icon: const Icon(Icons.print), + onPressed: () => {_exportDocument(_editorState)}), + ActionButton( + icon: const Icon(Icons.import_export), + onPressed: () => _importDocument(), ), ], ); } + + void _exportDocument(EditorState editorState) async { + final document = editorState.document.toJson(); + final json = jsonEncode(document); + final directory = await getTemporaryDirectory(); + final path = directory.path; + final file = File('$path/editor.json'); + await file.writeAsString(json); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('The document is saved to the ${file.path}'), + ), + ); + } + } + + void _importDocument() async { + final directory = await getTemporaryDirectory(); + final path = directory.path; + final file = File('$path/editor.json'); + setState(() { + _jsonString = file.readAsString(); + }); + } + + void _switchToPage(int pageIndex) { + if (pageIndex != _pageIndex) { + setState(() { + _pageIndex = pageIndex; + }); + } + } } diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift index cc167443dc..08b7c3b866 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import path_provider_macos import rich_clipboard_macos import url_launcher_macos import wakelock_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin")) diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock index d3a1dd3611..1fcb47735c 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock +++ b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock @@ -1,5 +1,7 @@ PODS: - FlutterMacOS (1.0.0) + - path_provider_macos (0.0.1): + - FlutterMacOS - rich_clipboard_macos (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): @@ -9,6 +11,7 @@ PODS: DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - wakelock_macos (from `Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos`) @@ -16,6 +19,8 @@ DEPENDENCIES: EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral + path_provider_macos: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos rich_clipboard_macos: :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos url_launcher_macos: @@ -25,6 +30,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 wakelock_macos: bc3f2a9bd8d2e6c89fee1e1822e7ddac3bd004a9 diff --git a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml index 4e2e2d68ab..482cad0875 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: video_player: ^2.4.5 pod_player: 0.0.8 flutter_inappwebview: ^5.4.3+7 + path_provider: ^2.0.11 dev_dependencies: flutter_test: @@ -50,7 +51,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^2.0.0 + flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 14826ff713..12b3a29252 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -2,6 +2,7 @@ library appflowy_editor; export 'src/infra/log.dart'; +export 'src/render/style/editor_style.dart'; export 'src/document/node.dart'; export 'src/document/path.dart'; export 'src/document/position.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart index a0c4e33a70..909e7dd494 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart @@ -163,7 +163,8 @@ class Node extends ChangeNotifier with LinkedListEntry { 'type': type, }; if (children.isNotEmpty) { - map['children'] = children.map((node) => node.toJson()); + map['children'] = + (children.map((node) => node.toJson())).toList(growable: false); } if (_attributes.isNotEmpty) { map['attributes'] = _attributes; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart index 99a81c9273..986dd37468 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart @@ -46,9 +46,9 @@ class Selection { (start.path <= end.path && !pathEquals(start.path, end.path)) || (isSingle && start.offset < end.offset); - Selection normalize() { + Selection get normalize { if (isForward) { - return Selection(start: end, end: start); + return reversed; } return this; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart index 5bf49c0048..a4a9869df5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart @@ -33,6 +33,12 @@ class StateTree { return StateTree(root: root); } + Map toJson() { + return { + 'document': root.toJson(), + }; + } + Node? nodeAtPath(Path path) { return root.childAtPath(path); } @@ -56,10 +62,17 @@ class StateTree { } return false; } - for (var i = 0; i < nodes.length; i++) { - final node = nodes[i]; - insertedNode!.insertAfter(node); - insertedNode = node; + if (path.last <= 0) { + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + insertedNode.insertBefore(node); + } + } else { + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + insertedNode!.insertAfter(node); + insertedNode = node; + } } return true; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index 396b428baf..2750af07a6 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -1,6 +1,7 @@ import 'dart:async'; 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/render/style/editor_style.dart'; import 'package:appflowy_editor/src/service/service.dart'; import 'package:flutter/material.dart'; @@ -58,6 +59,9 @@ class EditorState { /// Stores the selection menu items. List selectionMenuItems = []; + /// Stores the editor style. + EditorStyle editorStyle = const EditorStyle.defaultStyle(); + final UndoManager undoManager = UndoManager(); Selection? _cursorSelection; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart new file mode 100644 index 0000000000..56b0c7726f --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart @@ -0,0 +1,8 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +extension EditorStateExtensions on EditorState { + List get selectedTextNodes => + service.selectionService.currentSelectedNodes + .whereType() + .toList(growable: false); +} 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 index 1d7c68ab80..a07529cfdf 100644 --- 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 @@ -6,23 +6,7 @@ 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, - ) { + dynamic getAttributeInSelection(Selection selection, String styleKey) { final ops = delta.whereType(); final startOffset = selection.isBackward ? selection.start.offset : selection.end.offset; @@ -35,15 +19,73 @@ extension TextNodeExtension on TextNode { } final length = op.length; if (start < endOffset && start + length > startOffset) { - if (op.attributes == null || - !op.attributes!.containsKey(styleKey) || - op.attributes![styleKey] != value) { - return false; + if (op.attributes?.containsKey(styleKey) == true) { + return op.attributes![styleKey]; } } start += length; } - return true; + return null; + } + + bool allSatisfyLinkInSelection(Selection selection) => + allSatisfyInSelection(selection, StyleKey.href, (value) { + return value != null; + }); + + bool allSatisfyBoldInSelection(Selection selection) => + allSatisfyInSelection(selection, StyleKey.bold, (value) { + return value == true; + }); + + bool allSatisfyItalicInSelection(Selection selection) => + allSatisfyInSelection(selection, StyleKey.italic, (value) { + return value == true; + }); + + bool allSatisfyUnderlineInSelection(Selection selection) => + allSatisfyInSelection(selection, StyleKey.underline, (value) { + return value == true; + }); + + bool allSatisfyStrikethroughInSelection(Selection selection) => + allSatisfyInSelection(selection, StyleKey.strikethrough, (value) { + return value == true; + }); + + bool allSatisfyInSelection( + Selection selection, + String styleKey, + bool Function(dynamic value) test, + ) { + if (StyleKey.globalStyleKeys.contains(styleKey)) { + if (attributes.containsKey(styleKey)) { + return test(attributes[styleKey]); + } + } else if (StyleKey.partialStyleKeys.contains(styleKey)) { + 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) || + !test(op.attributes![styleKey])) { + return false; + } + } + start += length; + } + return true; + } + return false; } bool allNotSatisfyInSelection( @@ -76,48 +118,78 @@ extension TextNodeExtension on TextNode { } extension TextNodesExtension on List { - bool allSatisfyBoldInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.bold, selection, true); + bool allSatisfyBoldInSelection(Selection selection) => allSatisfyInSelection( + selection, + StyleKey.bold, + (value) => value == true, + ); bool allSatisfyItalicInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.italic, selection, true); + allSatisfyInSelection( + selection, + StyleKey.italic, + (value) => value == true, + ); bool allSatisfyUnderlineInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.underline, selection, true); + allSatisfyInSelection( + selection, + StyleKey.underline, + (value) => value == true, + ); bool allSatisfyStrikethroughInSelection(Selection selection) => - allSatisfyInSelection(StyleKey.strikethrough, selection, true); + allSatisfyInSelection( + selection, + StyleKey.strikethrough, + (value) => value == true, + ); bool allSatisfyInSelection( - String styleKey, Selection selection, - dynamic value, + String styleKey, + bool Function(dynamic value) test, ) { if (isEmpty) { return false; } if (length == 1) { - return first.allSatisfyInSelection(styleKey, value, selection); + return first.allSatisfyInSelection(selection, styleKey, (value) { + return test(value); + }); } 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), - ); + if (selection.isBackward) { + newSelection = selection.copyWith( + end: Position(path: node.path, offset: node.toRawString().length), + ); + } else { + newSelection = selection.copyWith( + end: Position(path: node.path, offset: 0), + ); + } } else if (i == length - 1 && pathEquals(node.path, selection.end.path)) { - newSelection = selection.copyWith( - start: Position(path: node.path, offset: 0), - ); + if (selection.isBackward) { + newSelection = selection.copyWith( + start: Position(path: node.path, offset: 0), + ); + } else { + newSelection = selection.copyWith( + start: + Position(path: node.path, offset: node.toRawString().length), + ); + } } 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)) { + if (!node.allSatisfyInSelection(newSelection, styleKey, test)) { return false; } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart new file mode 100644 index 0000000000..1c0ea30c82 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart @@ -0,0 +1,14 @@ +import 'package:url_launcher/url_launcher_string.dart'; + +Future safeLaunchUrl(String? href) async { + if (href == null) { + return Future.value(false); + } + final uri = Uri.parse(href); + // url_launcher cannot open a link without scheme. + final newHref = (uri.scheme.isNotEmpty ? href : 'http://$href').trim(); + if (await canLaunchUrlString(newHref)) { + await launchUrlString(newHref); + } + return Future.value(true); +} 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 index 2218b10181..8175ecb705 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/infra/log.dart @@ -75,6 +75,11 @@ class Log { /// For example, uses the logger when processing scroll events. static Log scroll = Log._(name: 'scroll'); + /// For logging message related to [AppFlowyToolbarService]. + /// + /// For example, uses the logger when processing toolbar events. + static Log toolbar = Log._(name: 'toolbar'); + /// For logging message related to UI. /// /// For example, uses the logger when building the widget. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index 4f6de6e9b0..1390b23918 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart @@ -115,12 +115,19 @@ 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]) { + insertText( + TextNode node, + int index, + String content, { + Attributes? attributes, + }) { var newAttributes = attributes; if (index != 0 && attributes == null) { newAttributes = node.delta.slice(max(index - 1, 0), index).first.attributes; + if (newAttributes != null) { + newAttributes = Attributes.from(newAttributes); + } } textEdit( node, @@ -132,7 +139,8 @@ class TransactionBuilder { ), ); afterSelection = Selection.collapsed( - Position(path: node.path, offset: index + content.length)); + Position(path: node.path, offset: index + content.length), + ); } /// Assigns formatting attributes to a range of text. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart index e71dc7c79b..4167ca1b38 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart @@ -33,7 +33,7 @@ class EditorNodeWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: node.children .map( (child) => diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart new file mode 100644 index 0000000000..ad3cf19d53 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart @@ -0,0 +1,73 @@ +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/operation/transaction_builder.dart'; +import 'package:appflowy_editor/src/service/render_plugin_service.dart'; +import 'package:flutter/material.dart'; +import 'package:rich_clipboard/rich_clipboard.dart'; + +import 'image_node_widget.dart'; + +class ImageNodeBuilder extends NodeWidgetBuilder { + @override + Widget build(NodeWidgetContext context) { + final src = context.node.attributes['image_src']; + final align = context.node.attributes['align']; + double? width; + if (context.node.attributes.containsKey('width')) { + width = context.node.attributes['width'].toDouble(); + } + return ImageNodeWidget( + key: context.node.key, + node: context.node, + src: src, + width: width, + alignment: _textToAlignment(align), + onCopy: () { + RichClipboard.setData(RichClipboardData(text: src)); + }, + onDelete: () { + TransactionBuilder(context.editorState) + ..deleteNode(context.node) + ..commit(); + }, + onAlign: (alignment) { + TransactionBuilder(context.editorState) + ..updateNode(context.node, { + 'align': _alignmentToText(alignment), + }) + ..commit(); + }, + onResize: (width) { + TransactionBuilder(context.editorState) + ..updateNode(context.node, { + 'width': width, + }) + ..commit(); + }, + ); + } + + @override + NodeValidator get nodeValidator => ((node) { + return node.type == 'image' && + node.attributes.containsKey('image_src') && + node.attributes.containsKey('align'); + }); + + Alignment _textToAlignment(String text) { + if (text == 'left') { + return Alignment.centerLeft; + } else if (text == 'right') { + return Alignment.centerRight; + } + return Alignment.center; + } + + String _alignmentToText(Alignment alignment) { + if (alignment == Alignment.centerLeft) { + return 'left'; + } else if (alignment == Alignment.centerRight) { + return 'right'; + } + return 'center'; + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart new file mode 100644 index 0000000000..a65df11541 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart @@ -0,0 +1,392 @@ +import 'package:appflowy_editor/src/extensions/object_extensions.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/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; +import 'package:flutter/material.dart'; + +class ImageNodeWidget extends StatefulWidget { + const ImageNodeWidget({ + Key? key, + required this.node, + required this.src, + this.width, + required this.alignment, + required this.onCopy, + required this.onDelete, + required this.onAlign, + required this.onResize, + }) : super(key: key); + + final Node node; + final String src; + final double? width; + final Alignment alignment; + final VoidCallback onCopy; + final VoidCallback onDelete; + final void Function(Alignment alignment) onAlign; + final void Function(double width) onResize; + + @override + State createState() => _ImageNodeWidgetState(); +} + +class _ImageNodeWidgetState extends State with Selectable { + final _imageKey = GlobalKey(); + + double? _imageWidth; + double _initial = 0; + double _distance = 0; + bool _onFocus = false; + + ImageStream? _imageStream; + late ImageStreamListener _imageStreamListener; + + @override + void initState() { + super.initState(); + + _imageWidth = widget.width; + _imageStreamListener = ImageStreamListener( + (image, _) { + _imageWidth = _imageKey.currentContext + ?.findRenderObject() + ?.unwrapOrNull() + ?.size + .width; + }, + ); + } + + @override + void dispose() { + _imageStream?.removeListener(_imageStreamListener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // only support network image. + return Container( + key: _imageKey, + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: _buildNetworkImage(context), + ); + } + + @override + Position start() { + return Position(path: widget.node.path, offset: 0); + } + + @override + Position end() { + return Position(path: widget.node.path, offset: 1); + } + + @override + Position getPositionInOffset(Offset start) { + return end(); + } + + @override + Rect? getCursorRectInPosition(Position position) { + return null; + } + + @override + List getRectsInSelection(Selection selection) { + final renderBox = context.findRenderObject() as RenderBox; + return [Offset.zero & renderBox.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) { + if (start <= end) { + return Selection(start: this.start(), end: this.end()); + } else { + return Selection(start: this.end(), end: this.start()); + } + } + + @override + Offset localToGlobal(Offset offset) { + final renderBox = context.findRenderObject() as RenderBox; + return renderBox.localToGlobal(offset); + } + + Widget _buildNetworkImage(BuildContext context) { + return Align( + alignment: widget.alignment, + child: MouseRegion( + onEnter: (event) => setState(() { + _onFocus = true; + }), + onExit: (event) => setState(() { + _onFocus = false; + }), + child: _buildResizableImage(context), + ), + ); + } + + Widget _buildResizableImage(BuildContext context) { + final networkImage = Image.network( + widget.src, + width: _imageWidth == null ? null : _imageWidth! - _distance, + gaplessPlayback: true, + loadingBuilder: (context, child, loadingProgress) => + loadingProgress == null ? child : _buildLoading(context), + errorBuilder: (context, error, stackTrace) { + // _imageWidth ??= defaultMaxTextNodeWidth; + return _buildError(context); + }, + ); + if (_imageWidth == null) { + _imageStream = networkImage.image.resolve(const ImageConfiguration()) + ..addListener(_imageStreamListener); + } + return Stack( + children: [ + networkImage, + _buildEdgeGesture( + context, + top: 0, + left: 0, + bottom: 0, + width: 5, + onUpdate: (distance) { + setState(() { + _distance = distance; + }); + }, + ), + _buildEdgeGesture( + context, + top: 0, + right: 0, + bottom: 0, + width: 5, + onUpdate: (distance) { + setState(() { + _distance = -distance; + }); + }, + ), + if (_onFocus) + ImageToolbar( + top: 8, + right: 8, + height: 30, + alignment: widget.alignment, + onAlign: widget.onAlign, + onCopy: widget.onCopy, + onDelete: widget.onDelete, + ) + ], + ); + } + + Widget _buildLoading(BuildContext context) { + return SizedBox( + height: 150, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.fromSize( + size: const Size(18, 18), + child: const CircularProgressIndicator(), + ), + SizedBox.fromSize( + size: const Size(10, 10), + ), + const Text('Loading'), + ], + ), + ); + } + + Widget _buildError(BuildContext context) { + return Container( + height: 100, + width: _imageWidth, + alignment: Alignment.center, + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + border: Border.all(width: 1, color: Colors.black), + ), + child: const Text('Could not load the image'), + ); + } + + Widget _buildEdgeGesture( + BuildContext context, { + double? top, + double? left, + double? right, + double? bottom, + double? width, + void Function(double distance)? onUpdate, + }) { + return Positioned( + top: top, + left: left, + right: right, + bottom: bottom, + width: width, + child: GestureDetector( + onHorizontalDragStart: (details) { + _initial = details.globalPosition.dx; + }, + onHorizontalDragUpdate: (details) { + if (onUpdate != null) { + onUpdate(details.globalPosition.dx - _initial); + } + }, + onHorizontalDragEnd: (details) { + _imageWidth = _imageWidth! - _distance; + _initial = 0; + _distance = 0; + + widget.onResize(_imageWidth!); + }, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: _onFocus + ? Center( + child: Container( + height: 40, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.2), + borderRadius: const BorderRadius.all( + Radius.circular(5.0), + ), + ), + ), + ) + : null, + ), + ), + ); + } +} + +@visibleForTesting +class ImageToolbar extends StatelessWidget { + const ImageToolbar({ + Key? key, + required this.top, + required this.right, + required this.height, + required this.alignment, + required this.onCopy, + required this.onDelete, + required this.onAlign, + }) : super(key: key); + + final double top; + final double right; + final double height; + final Alignment alignment; + final VoidCallback onCopy; + final VoidCallback onDelete; + final void Function(Alignment alignment) onAlign; + + @override + Widget build(BuildContext context) { + return Positioned( + top: top, + right: right, + height: height, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF333333), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(8.0), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + hoverColor: Colors.transparent, + constraints: const BoxConstraints(), + padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0), + icon: FlowySvg( + name: 'image_toolbar/align_left', + color: alignment == Alignment.centerLeft + ? const Color(0xFF00BCF0) + : null, + ), + onPressed: () { + onAlign(Alignment.centerLeft); + }, + ), + IconButton( + hoverColor: Colors.transparent, + constraints: const BoxConstraints(), + padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0), + icon: FlowySvg( + name: 'image_toolbar/align_center', + color: alignment == Alignment.center + ? const Color(0xFF00BCF0) + : null, + ), + onPressed: () { + onAlign(Alignment.center); + }, + ), + IconButton( + hoverColor: Colors.transparent, + constraints: const BoxConstraints(), + padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0), + icon: FlowySvg( + name: 'image_toolbar/align_right', + color: alignment == Alignment.centerRight + ? const Color(0xFF00BCF0) + : null, + ), + onPressed: () { + onAlign(Alignment.centerRight); + }, + ), + const Center( + child: FlowySvg( + name: 'image_toolbar/divider', + ), + ), + IconButton( + hoverColor: Colors.transparent, + constraints: const BoxConstraints(), + padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0), + icon: const FlowySvg( + name: 'image_toolbar/copy', + ), + onPressed: () { + onCopy(); + }, + ), + IconButton( + hoverColor: Colors.transparent, + constraints: const BoxConstraints(), + padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0), + icon: const FlowySvg( + name: 'image_toolbar/delete', + ), + onPressed: () { + onDelete(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart new file mode 100644 index 0000000000..a8728341df --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_upload_widget.dart @@ -0,0 +1,202 @@ +import 'dart:collection'; + +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/selection_menu/selection_menu_service.dart'; +import 'package:flutter/material.dart'; + +OverlayEntry? _imageUploadMenu; +EditorState? _editorState; +void showImageUploadMenu( + EditorState editorState, + SelectionMenuService menuService, + BuildContext context, +) { + menuService.dismiss(); + + _imageUploadMenu?.remove(); + _imageUploadMenu = OverlayEntry(builder: (context) { + return Positioned( + top: menuService.topLeft.dy, + left: menuService.topLeft.dx, + child: Material( + child: ImageUploadMenu( + onSubmitted: (text) { + // _dismissImageUploadMenu(); + editorState.insertImageNode(text); + }, + onUpload: (text) { + // _dismissImageUploadMenu(); + editorState.insertImageNode(text); + }, + ), + ), + ); + }); + + Overlay.of(context)?.insert(_imageUploadMenu!); + + editorState.service.selectionService.currentSelection + .addListener(_dismissImageUploadMenu); +} + +void _dismissImageUploadMenu() { + _imageUploadMenu?.remove(); + _imageUploadMenu = null; + + _editorState?.service.selectionService.currentSelection + .removeListener(_dismissImageUploadMenu); + _editorState = null; +} + +class ImageUploadMenu extends StatefulWidget { + const ImageUploadMenu({ + Key? key, + required this.onSubmitted, + required this.onUpload, + }) : super(key: key); + + final void Function(String text) onSubmitted; + final void Function(String text) onUpload; + + @override + State createState() => _ImageUploadMenuState(); +} + +class _ImageUploadMenuState extends State { + final _textEditingController = TextEditingController(); + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 300, + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + borderRadius: BorderRadius.circular(6.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: 16.0), + _buildInput(), + const SizedBox(height: 18.0), + _buildUploadButton(context), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return const Text( + 'URL Image', + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 14.0, + color: Colors.black, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildInput() { + return TextField( + focusNode: _focusNode, + style: const TextStyle(fontSize: 14.0), + textAlign: TextAlign.left, + controller: _textEditingController, + onSubmitted: widget.onSubmitted, + decoration: InputDecoration( + hintText: 'URL', + hintStyle: const TextStyle(fontSize: 14.0), + contentPadding: const EdgeInsets.all(16.0), + isDense: true, + suffixIcon: IconButton( + padding: const EdgeInsets.all(4.0), + icon: const FlowySvg( + name: 'clear', + width: 24, + height: 24, + ), + onPressed: () { + _textEditingController.clear(); + }, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + borderSide: BorderSide(color: Color(0xFFBDBDBD)), + ), + ), + ); + } + + Widget _buildUploadButton(BuildContext context) { + return SizedBox( + width: 170, + height: 48, + child: TextButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + ), + ), + onPressed: () { + widget.onUpload(_textEditingController.text); + }, + child: const Text( + 'Upload', + style: TextStyle(color: Colors.white, fontSize: 14.0), + ), + ), + ); + } +} + +extension on EditorState { + void insertImageNode(String src) { + final selection = service.selectionService.currentSelection.value; + if (selection == null) { + return; + } + final imageNode = Node( + type: 'image', + children: LinkedList(), + attributes: { + 'image_src': src, + 'align': 'center', + }, + ); + TransactionBuilder(this) + ..insertNode( + selection.start.path, + imageNode, + ) + ..commit(); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart new file mode 100644 index 0000000000..13396a33c4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart @@ -0,0 +1,168 @@ +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:flutter/material.dart'; + +class LinkMenu extends StatefulWidget { + const LinkMenu({ + Key? key, + this.linkText, + required this.onSubmitted, + required this.onOpenLink, + required this.onCopyLink, + required this.onRemoveLink, + required this.onFocusChange, + }) : super(key: key); + + final String? linkText; + final void Function(String text) onSubmitted; + final VoidCallback onOpenLink; + final VoidCallback onCopyLink; + final VoidCallback onRemoveLink; + final void Function(bool value) onFocusChange; + + @override + State createState() => _LinkMenuState(); +} + +class _LinkMenuState extends State { + final _textEditingController = TextEditingController(); + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _textEditingController.text = widget.linkText ?? ''; + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _textEditingController.dispose(); + _focusNode.removeListener(_onFocusChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 350, + 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: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + const SizedBox(height: 16.0), + _buildInput(), + const SizedBox(height: 16.0), + if (widget.linkText != null) ...[ + _buildIconButton( + iconName: 'link', + text: 'Open link', + onPressed: widget.onOpenLink, + ), + _buildIconButton( + iconName: 'copy', + color: Colors.black, + text: 'Copy link', + onPressed: widget.onCopyLink, + ), + _buildIconButton( + iconName: 'delete', + text: 'Remove link', + onPressed: widget.onRemoveLink, + ), + ] + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return const Text( + 'Add your link', + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildInput() { + return TextField( + focusNode: _focusNode, + style: const TextStyle(fontSize: 14.0), + textAlign: TextAlign.left, + controller: _textEditingController, + onSubmitted: widget.onSubmitted, + decoration: InputDecoration( + hintText: 'URL', + hintStyle: const TextStyle(fontSize: 14.0), + contentPadding: const EdgeInsets.all(16.0), + isDense: true, + suffixIcon: IconButton( + padding: const EdgeInsets.all(4.0), + icon: const FlowySvg( + name: 'clear', + width: 24, + height: 24, + ), + onPressed: () { + _textEditingController.clear(); + }, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12.0)), + borderSide: BorderSide(color: Color(0xFFBDBDBD)), + ), + ), + ); + } + + Widget _buildIconButton({ + required String iconName, + Color? color, + required String text, + required VoidCallback onPressed, + }) { + return TextButton.icon( + icon: FlowySvg( + name: iconName, + color: color, + ), + style: TextButton.styleFrom( + minimumSize: const Size.fromHeight(40), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + alignment: Alignment.centerLeft, + ), + label: Text( + text, + textAlign: TextAlign.left, + style: const TextStyle( + color: Colors.black, + fontSize: 14.0, + ), + ), + onPressed: onPressed, + ); + } + + void _onFocusChange() { + widget.onFocusChange(_focusNode.hasFocus); + } +} 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 index 267a5acc66..7f0f0363f8 100644 --- 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 @@ -56,33 +56,27 @@ class _BulletedListTextNodeWidgetState extends State @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', + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + name: 'point', + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'List', + textNode: widget.textNode, + editorState: widget.editorState, ), - 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/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart index 9b7d3a730f..ed6748a43e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -63,45 +63,38 @@ class _CheckboxNodeWidgetState extends State Widget _buildWithSingle(BuildContext context) { final check = widget.textNode.attributes.check; - 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(); - }, + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + key: iconKey, + child: FlowySvg( + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + name: check ? 'check' : 'uncheck', ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'To-do', - textNode: widget.textNode, - textSpanDecorator: _textSpanDecorator, - placeholderTextSpanDecorator: _textSpanDecorator, - editorState: widget.editorState, - ), + onTap: () { + TransactionBuilder(widget.editorState) + ..updateNode(widget.textNode, { + StyleKey.checkbox: !check, + }) + ..commit(); + }, + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'To-do', + textNode: widget.textNode, + textSpanDecorator: _textSpanDecorator, + placeholderTextSpanDecorator: _textSpanDecorator, + editorState: widget.editorState, ), - ], - ), + ), + ], ), ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart index 91b9cbf981..c477478deb 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart @@ -32,7 +32,8 @@ mixin DefaultSelectable { Selection getSelectionInRange(Offset start, Offset end) => forward.getSelectionInRange(start, end); - Offset localToGlobal(Offset offset) => forward.localToGlobal(offset); + Offset localToGlobal(Offset offset) => + forward.localToGlobal(offset) - baseOffset; Selection? getWorldBoundaryInOffset(Offset offset) => forward.getWorldBoundaryInOffset(offset); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 89df8a54b1..9d1b7f119e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -1,5 +1,9 @@ +import 'dart:async'; import 'dart:ui'; +import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -39,7 +43,7 @@ class FlowyRichText extends StatefulWidget { } class _FlowyRichTextState extends State with Selectable { - final _textKey = GlobalKey(); + var _textKey = GlobalKey(); final _placeholderTextKey = GlobalKey(); final _lineHeight = 1.5; @@ -50,6 +54,17 @@ class _FlowyRichTextState extends State with Selectable { RenderParagraph get _placeholderRenderParagraph => _placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph; + @override + void didUpdateWidget(covariant FlowyRichText oldWidget) { + super.didUpdateWidget(oldWidget); + + // https://github.com/flutter/flutter/issues/110342 + if (_textKey.currentWidget is RichText) { + // Force refresh the RichText widget. + _textKey = GlobalKey(); + } + } + @override Widget build(BuildContext context) { return _buildRichText(context); @@ -117,17 +132,24 @@ class _FlowyRichTextState extends State with Selectable { @override List getRectsInSelection(Selection selection) { - assert(pathEquals(selection.start.path, selection.end.path) && + assert(selection.isSingle && pathEquals(selection.start.path, widget.textNode.path)); final textSelection = TextSelection( baseOffset: selection.start.offset, extentOffset: selection.end.offset, ); - return _renderParagraph + final rects = _renderParagraph .getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max) .map((box) => box.toRect()) - .toList(); + .toList(growable: false); + if (rects.isEmpty) { + // If the rich text widget does not contain any text, + // there will be no selection boxes, + // so we need to return to the default selection. + return [Rect.fromLTWH(0, 0, 0, _renderParagraph.size.height)]; + } + return rects; } @override @@ -143,6 +165,11 @@ class _FlowyRichTextState extends State with Selectable { ); } + @override + Offset localToGlobal(Offset offset) { + return _renderParagraph.localToGlobal(offset); + } + Widget _buildRichText(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.text, @@ -174,51 +201,42 @@ class _FlowyRichTextState extends State with Selectable { return RichText( key: _textKey, textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, applyHeightToLastDescent: false), + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), text: widget.textSpanDecorator != null ? widget.textSpanDecorator!(textSpan) : textSpan, ); } - // 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() - // ], - // ); - // } - - @override - Offset localToGlobal(Offset offset) { - return _renderParagraph.localToGlobal(offset); + TextSpan get _textSpan { + var offset = 0; + return TextSpan( + children: widget.textNode.delta.whereType().map((insert) { + GestureRecognizer? gestureRecognizer; + if (insert.attributes?[StyleKey.href] != null) { + gestureRecognizer = _buildTapHrefGestureRecognizer( + insert.attributes![StyleKey.href], + Selection.single( + path: widget.textNode.path, + startOffset: offset, + endOffset: offset + insert.length, + ), + ); + } + offset += insert.length; + final textSpan = RichTextStyle( + attributes: insert.attributes ?? {}, + text: insert.content, + height: _lineHeight, + gestureRecognizer: gestureRecognizer, + ).toTextSpan(); + return textSpan; + }).toList(growable: false), + ); } - TextSpan get _textSpan => TextSpan( - 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, @@ -228,4 +246,34 @@ class _FlowyRichTextState extends State with Selectable { height: _lineHeight, ).toTextSpan() ]); + + GestureRecognizer _buildTapHrefGestureRecognizer( + String href, Selection selection) { + Timer? timer; + var tapCount = 0; + final tapGestureRecognizer = TapGestureRecognizer() + ..onTap = () async { + // implement a simple double tap logic + tapCount += 1; + timer?.cancel(); + + if (tapCount == 2) { + tapCount = 0; + safeLaunchUrl(href); + return; + } + + timer = Timer(const Duration(milliseconds: 200), () { + tapCount = 0; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + showLinkMenu( + context, + widget.editorState, + customSelection: selection, + ); + }); + }); + }; + return tapGestureRecognizer; + } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart index 050b330f8b..93defaae8e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart @@ -38,7 +38,6 @@ class HeadingTextNodeWidget extends StatefulWidget { } // customize - class _HeadingTextNodeWidgetState extends State with Selectable, DefaultSelectable { @override @@ -63,16 +62,13 @@ class _HeadingTextNodeWidgetState extends State top: _topPadding, bottom: defaultLinePadding, ), - child: SizedBox( - width: defaultMaxTextNodeWidth, - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Heading', - placeholderTextSpanDecorator: _placeholderTextSpanDecorator, - textSpanDecorator: _textSpanDecorator, - textNode: widget.textNode, - editorState: widget.editorState, - ), + 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 index de3b0b55b6..36cf91bdce 100644 --- 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 @@ -56,32 +56,27 @@ class _NumberListTextNodeWidgetState extends State @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, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + number: widget.textNode.attributes.number, + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'List', + textNode: widget.textNode, + editorState: widget.editorState, ), - 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 index 0389dfa50f..9c2366d1cb 100644 --- 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 @@ -55,39 +55,29 @@ class _QuotedTextNodeWidgetState extends State @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, - ), - ), - ], + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + name: 'quote', ), - ), - )); - } - - double get _quoteHeight { - final lines = - widget.textNode.toRawString().characters.where((c) => c == '\n').length; - return (lines + 1) * _iconWidth; + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'Quote', + textNode: widget.textNode, + editorState: widget.editorState, + ), + ), + ], + ), + ), + ); } } 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 index d8dcfb91f6..b9a3e2f314 100644 --- 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 @@ -52,15 +52,12 @@ class _RichTextNodeWidgetState extends State @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, - ), + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, ), ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart index 7bd68c45e7..2cd03fe389 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart @@ -1,8 +1,6 @@ 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: @@ -49,6 +47,8 @@ class StyleKey { StyleKey.italic, StyleKey.underline, StyleKey.strikethrough, + StyleKey.backgroundColor, + StyleKey.href, ]; static List globalStyleKeys = [ @@ -63,7 +63,6 @@ class StyleKey { } // TODO: customize -double defaultMaxTextNodeWidth = 780.0; double defaultLinePadding = 8.0; double baseFontSize = 16.0; String defaultHighlightColor = '0x6000BCF0'; @@ -80,7 +79,10 @@ Map headingToFontSize = { extension NodeAttributesExtensions on Attributes { String? get heading { - if (containsKey(StyleKey.heading) && this[StyleKey.heading] is String) { + if (containsKey(StyleKey.subtype) && + containsKey(StyleKey.heading) && + this[StyleKey.subtype] == StyleKey.heading && + this[StyleKey.heading] is String) { return this[StyleKey.heading]; } return null; @@ -182,14 +184,13 @@ class RichTextStyle { RichTextStyle({ required this.attributes, required this.text, + this.gestureRecognizer, this.height = 1.5, }); - RichTextStyle.fromTextNode(TextNode textNode) - : this(attributes: textNode.attributes, text: textNode.toRawString()); - final Attributes attributes; final String text; + final GestureRecognizer? gestureRecognizer; final double height; TextSpan toTextSpan() => _toTextSpan(height); @@ -201,6 +202,7 @@ class RichTextStyle { TextSpan _toTextSpan(double? height) { return TextSpan( text: text, + recognizer: _recognizer, style: TextStyle( fontWeight: _fontWeight, fontStyle: _fontStyle, @@ -210,7 +212,6 @@ class RichTextStyle { background: _background, height: height, ), - recognizer: _recognizer, ); } @@ -273,13 +274,6 @@ class RichTextStyle { // recognizer GestureRecognizer? get _recognizer { - final href = attributes.href; - if (href != null) { - return TapGestureRecognizer() - ..onTap = () async { - await launchUrlString(href); - }; - } - return null; + return gestureRecognizer; } } 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 deleted file mode 100644 index 4c2b621795..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/toolbar_widget.dart +++ /dev/null @@ -1,217 +0,0 @@ -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 index 36e0a2e02e..3b7307f039 100644 --- 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 @@ -45,7 +45,7 @@ class SelectionMenuItemWidget extends StatelessWidget { ), ), onPressed: () { - item.handler(editorState, menuService); + item.handler(editorState, menuService, context); }, ), ), 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 index 94fa6190d8..f4f2006af4 100644 --- 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 @@ -1,5 +1,6 @@ import 'package:appflowy_editor/src/editor_state.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/image/image_upload_widget.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'; @@ -23,6 +24,7 @@ class SelectionMenu implements SelectionMenuService { OverlayEntry? _selectionMenuEntry; bool _selectionUpdateByInner = false; + Offset? _topLeft; @override void dismiss() { @@ -53,6 +55,7 @@ class SelectionMenu implements SelectionMenuService { return; } final offset = selectionRects.first.bottomRight + const Offset(10, 10); + _topLeft = offset; _selectionMenuEntry = OverlayEntry(builder: (context) { return Positioned( @@ -84,8 +87,9 @@ class SelectionMenu implements SelectionMenuService { } @override - // TODO: implement topLeft - Offset get topLeft => throw UnimplementedError(); + Offset get topLeft { + return _topLeft ?? Offset.zero; + } void _onSelectionChange() { // workaround: SelectionService has been released after hot reload. @@ -115,7 +119,7 @@ final List _defaultSelectionMenuItems = [ name: 'Text', icon: _selectionMenuIcon('text'), keywords: ['text'], - handler: (editorState, menuService) { + handler: (editorState, _, __) { insertTextNodeAfterSelection(editorState, {}); }, ), @@ -123,7 +127,7 @@ final List _defaultSelectionMenuItems = [ name: 'Heading 1', icon: _selectionMenuIcon('h1'), keywords: ['heading 1, h1'], - handler: (editorState, menuService) { + handler: (editorState, _, __) { insertHeadingAfterSelection(editorState, StyleKey.h1); }, ), @@ -131,7 +135,7 @@ final List _defaultSelectionMenuItems = [ name: 'Heading 2', icon: _selectionMenuIcon('h2'), keywords: ['heading 2, h2'], - handler: (editorState, menuService) { + handler: (editorState, _, __) { insertHeadingAfterSelection(editorState, StyleKey.h2); }, ), @@ -139,15 +143,21 @@ final List _defaultSelectionMenuItems = [ name: 'Heading 3', icon: _selectionMenuIcon('h3'), keywords: ['heading 3, h3'], - handler: (editorState, menuService) { + handler: (editorState, _, __) { insertHeadingAfterSelection(editorState, StyleKey.h3); }, ), + SelectionMenuItem( + name: 'Image', + icon: _selectionMenuIcon('image'), + keywords: ['image'], + handler: showImageUploadMenu, + ), SelectionMenuItem( name: 'Bulleted list', icon: _selectionMenuIcon('bulleted_list'), keywords: ['bulleted list', 'list', 'unordered list'], - handler: (editorState, menuService) { + handler: (editorState, _, __) { insertBulletedListAfterSelection(editorState); }, ), @@ -155,10 +165,18 @@ final List _defaultSelectionMenuItems = [ name: 'Checkbox', icon: _selectionMenuIcon('checkbox'), keywords: ['todo list', 'list', 'checkbox list'], - handler: (editorState, menuService) { + handler: (editorState, _, __) { insertCheckboxAfterSelection(editorState); }, ), + SelectionMenuItem( + name: 'Quote', + icon: _selectionMenuIcon('quote'), + keywords: ['quote', 'refer'], + handler: (editorState, _, __) { + insertQuoteAfterSelection(editorState); + }, + ), ]; Widget _selectionMenuIcon(String name) { 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 index 70f7bbc337..1553085349 100644 --- 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 @@ -22,8 +22,11 @@ class SelectionMenuItem { /// /// The keywords are used to quickly retrieve items. final List keywords; - final void Function(EditorState editorState, SelectionMenuService menuService) - handler; + final void Function( + EditorState editorState, + SelectionMenuService menuService, + BuildContext context, + ) handler; } class SelectionMenuWidget extends StatefulWidget { @@ -202,8 +205,10 @@ class _SelectionMenuWidgetState extends State { if (event.logicalKey == LogicalKeyboardKey.enter) { if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) { _deleteLastCharacters(length: keyword.length + 1); - _showingItems[_selectedIndex] - .handler(widget.editorState, widget.menuService); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _showingItems[_selectedIndex] + .handler(widget.editorState, widget.menuService, context); + }); return KeyEventResult.handled; } } else if (event.logicalKey == LogicalKeyboardKey.escape) { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart new file mode 100644 index 0000000000..e691ea689e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Editor style configuration +class EditorStyle { + const EditorStyle({ + required this.padding, + }); + + const EditorStyle.defaultStyle() + : padding = const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0); + + /// The margin of the document context from the editor. + final EdgeInsets padding; + + EditorStyle copyWith({EdgeInsets? padding}) { + return EditorStyle( + padding: padding ?? this.padding, + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart new file mode 100644 index 0000000000..ff94dfc111 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart @@ -0,0 +1,369 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart'; +import 'package:appflowy_editor/src/infra/flowy_svg.dart'; +import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/extensions/text_node_extensions.dart'; +import 'package:appflowy_editor/src/extensions/editor_state_extensions.dart'; +import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart'; +import 'package:flutter/material.dart'; +import 'package:rich_clipboard/rich_clipboard.dart'; + +typedef ToolbarItemEventHandler = void Function( + EditorState editorState, BuildContext context); +typedef ToolbarItemValidator = bool Function(EditorState editorState); +typedef ToolbarItemHighlightCallback = bool Function(EditorState editorState); + +class ToolbarItem { + ToolbarItem({ + required this.id, + required this.type, + required this.iconBuilder, + this.tooltipsMessage = '', + required this.validator, + required this.highlightCallback, + required this.handler, + }); + + final String id; + final int type; + final Widget Function(bool isHighlight) iconBuilder; + final String tooltipsMessage; + final ToolbarItemValidator validator; + final ToolbarItemEventHandler handler; + final ToolbarItemHighlightCallback highlightCallback; + + factory ToolbarItem.divider() { + return ToolbarItem( + id: 'divider', + type: -1, + iconBuilder: (_) => const FlowySvg(name: 'toolbar/divider'), + validator: (editorState) => true, + handler: (editorState, context) {}, + highlightCallback: (editorState) => false, + ); + } + + @override + bool operator ==(Object other) { + if (other is! ToolbarItem) { + return false; + } + if (identical(this, other)) { + return true; + } + return id == other.id; + } + + @override + int get hashCode => id.hashCode; +} + +List defaultToolbarItems = [ + ToolbarItem( + id: 'appflowy.toolbar.h1', + type: 1, + tooltipsMessage: 'Heading 1', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/h1', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _onlyShowInSingleTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.heading, + (value) => value == StyleKey.h1, + ), + handler: (editorState, context) => formatHeading(editorState, StyleKey.h1), + ), + ToolbarItem( + id: 'appflowy.toolbar.h2', + type: 1, + tooltipsMessage: 'Heading 2', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/h2', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _onlyShowInSingleTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.heading, + (value) => value == StyleKey.h2, + ), + handler: (editorState, context) => formatHeading(editorState, StyleKey.h2), + ), + ToolbarItem( + id: 'appflowy.toolbar.h3', + type: 1, + tooltipsMessage: 'Heading 3', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/h3', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _onlyShowInSingleTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.heading, + (value) => value == StyleKey.h3, + ), + handler: (editorState, context) => formatHeading(editorState, StyleKey.h3), + ), + ToolbarItem( + id: 'appflowy.toolbar.bold', + type: 2, + tooltipsMessage: 'Bold', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/bold', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _showInTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.bold, + (value) => value == true, + ), + handler: (editorState, context) => formatBold(editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.italic', + type: 2, + tooltipsMessage: 'Italic', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/italic', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _showInTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.italic, + (value) => value == true, + ), + handler: (editorState, context) => formatItalic(editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.underline', + type: 2, + tooltipsMessage: 'Underline', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/underline', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _showInTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.underline, + (value) => value == true, + ), + handler: (editorState, context) => formatUnderline(editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.strikethrough', + type: 2, + tooltipsMessage: 'Strikethrough', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/strikethrough', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _showInTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.strikethrough, + (value) => value == true, + ), + handler: (editorState, context) => formatStrikethrough(editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.quote', + type: 3, + tooltipsMessage: 'Quote', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/quote', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _onlyShowInSingleTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.subtype, + (value) => value == StyleKey.quote, + ), + handler: (editorState, context) => formatQuote(editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.bulleted_list', + type: 3, + tooltipsMessage: 'Bulleted list', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/bulleted_list', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _onlyShowInSingleTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.subtype, + (value) => value == StyleKey.bulletedList, + ), + handler: (editorState, context) => formatBulletedList(editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.link', + type: 4, + tooltipsMessage: 'Link', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/link', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _onlyShowInSingleTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.href, + (value) => value != null, + ), + handler: (editorState, context) => showLinkMenu(context, editorState), + ), + ToolbarItem( + id: 'appflowy.toolbar.highlight', + type: 4, + tooltipsMessage: 'Highlight', + iconBuilder: (isHighlight) => FlowySvg( + name: 'toolbar/highlight', + color: isHighlight ? Colors.lightBlue : null, + ), + validator: _showInTextSelection, + highlightCallback: (editorState) => _allSatisfy( + editorState, + StyleKey.backgroundColor, + (value) => value != null, + ), + handler: (editorState, context) => formatHighlight(editorState), + ), +]; + +ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) { + final nodes = editorState.service.selectionService.currentSelectedNodes; + return (nodes.length == 1 && nodes.first is TextNode); +}; + +ToolbarItemValidator _showInTextSelection = (editorState) { + final nodes = editorState.service.selectionService.currentSelectedNodes + .whereType(); + return nodes.isNotEmpty; +}; + +bool _allSatisfy( + EditorState editorState, + String styleKey, + bool Function(dynamic value) test, +) { + final selection = editorState.service.selectionService.currentSelection.value; + return selection != null && + editorState.selectedTextNodes.allSatisfyInSelection( + selection, + styleKey, + test, + ); +} + +OverlayEntry? _linkMenuOverlay; +EditorState? _editorState; +bool _changeSelectionInner = false; +void showLinkMenu( + BuildContext context, + EditorState editorState, { + Selection? customSelection, +}) { + final rects = editorState.service.selectionService.selectionRects; + var maxBottom = 0.0; + late Rect matchRect; + for (final rect in rects) { + if (rect.bottom > maxBottom) { + maxBottom = rect.bottom; + matchRect = rect; + } + } + + _dismissLinkMenu(); + _editorState = editorState; + + // Since the link menu will only show in single text selection, + // We get the text node directly instead of judging details again. + final selection = customSelection ?? + editorState.service.selectionService.currentSelection.value; + final node = editorState.service.selectionService.currentSelectedNodes; + if (selection == null || node.isEmpty || node.first is! TextNode) { + return; + } + final index = + selection.isBackward ? selection.start.offset : selection.end.offset; + final length = (selection.start.offset - selection.end.offset).abs(); + final textNode = node.first as TextNode; + String? linkText; + if (textNode.allSatisfyLinkInSelection(selection)) { + linkText = textNode.getAttributeInSelection(selection, StyleKey.href); + } + _linkMenuOverlay = OverlayEntry(builder: (context) { + return Positioned( + top: matchRect.bottom + 5.0, + left: matchRect.left, + child: Material( + child: LinkMenu( + linkText: linkText, + onOpenLink: () async { + await safeLaunchUrl(linkText); + }, + onSubmitted: (text) { + TransactionBuilder(editorState) + ..formatText(textNode, index, length, {StyleKey.href: text}) + ..commit(); + _dismissLinkMenu(); + }, + onCopyLink: () { + RichClipboard.setData(RichClipboardData(text: linkText)); + _dismissLinkMenu(); + }, + onRemoveLink: () { + TransactionBuilder(editorState) + ..formatText(textNode, index, length, {StyleKey.href: null}) + ..commit(); + _dismissLinkMenu(); + }, + onFocusChange: (value) { + if (value && customSelection != null) { + _changeSelectionInner = true; + editorState.service.selectionService + .updateSelection(customSelection); + } + }, + ), + ), + ); + }); + Overlay.of(context)?.insert(_linkMenuOverlay!); + + editorState.service.scrollService?.disable(); + editorState.service.selectionService.currentSelection + .addListener(_dismissLinkMenu); +} + +void _dismissLinkMenu() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + _editorState?.service.selectionServiceKey.currentState == null; + if (isSelectionDisposed) { + return; + } + if (_editorState?.service.selectionService.currentSelection.value == null) { + return; + } + if (_changeSelectionInner) { + _changeSelectionInner = false; + return; + } + _linkMenuOverlay?.remove(); + _linkMenuOverlay = null; + + _editorState?.service.scrollService?.enable(); + _editorState?.service.keyboardService?.enable(); + _editorState?.service.selectionService.currentSelection + .removeListener(_dismissLinkMenu); + _editorState = null; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart new file mode 100644 index 0000000000..4fefa8eadb --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'toolbar_item.dart'; + +class ToolbarItemWidget extends StatelessWidget { + const ToolbarItemWidget({ + Key? key, + required this.item, + required this.isHighlight, + required this.onPressed, + }) : super(key: key); + + final ToolbarItem item; + final VoidCallback onPressed; + final bool isHighlight; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: Tooltip( + preferBelow: false, + message: item.tooltipsMessage, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: IconButton( + highlightColor: Colors.yellow, + padding: EdgeInsets.zero, + icon: item.iconBuilder(isHighlight), + iconSize: 28, + onPressed: onPressed, + ), + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart new file mode 100644 index 0000000000..18c2cdc0b1 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart @@ -0,0 +1,80 @@ +import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; +import 'package:flutter/material.dart'; + +import 'package:appflowy_editor/src/editor_state.dart'; + +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.items, + }) : super(key: key); + + final EditorState editorState; + final LayerLink layerLink; + final Offset offset; + final List items; + + @override + State createState() => _ToolbarWidgetState(); +} + +class _ToolbarWidgetState extends State with ToolbarMixin { + 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(8.0), + color: const Color(0xFF333333), + child: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: SizedBox( + height: 32.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.items + .map( + (item) => Center( + child: ToolbarItemWidget( + item: item, + isHighlight: item.highlightCallback(widget.editorState), + onPressed: () { + item.handler(widget.editorState, context); + }, + ), + ), + ) + .toList(growable: false), + ), + ), + ), + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart index c4f765f2f4..038f3119af 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart @@ -157,7 +157,7 @@ bool formatRichTextPartialStyle(EditorState editorState, String styleKey, } bool _allSatisfyInSelection( - EditorState editorState, String styleKey, dynamic value) { + EditorState editorState, String styleKey, dynamic matchValue) { final selection = editorState.service.selectionService.currentSelection.value; final nodes = editorState.service.selectionService.currentSelectedNodes; final textNodes = nodes.whereType().toList(growable: false); @@ -166,7 +166,9 @@ bool _allSatisfyInSelection( return false; } - return textNodes.allSatisfyInSelection(styleKey, selection, value); + return textNodes.allSatisfyInSelection(selection, styleKey, (value) { + return value == matchValue; + }); } bool formatRichTextStyle(EditorState editorState, Attributes attributes) { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index d7b4f33914..3a8d75560b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -1,4 +1,6 @@ +import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:appflowy_editor/src/render/style/editor_style.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart'; import 'package:flutter/material.dart'; @@ -25,6 +27,7 @@ NodeWidgetBuilders defaultBuilders = { 'text/bulleted-list': BulletedListTextNodeWidgetBuilder(), 'text/number-list': NumberListTextNodeWidgetBuilder(), 'text/quote': QuotedTextNodeWidgetBuilder(), + 'image': ImageNodeBuilder(), }; class AppFlowyEditor extends StatefulWidget { @@ -34,6 +37,7 @@ class AppFlowyEditor extends StatefulWidget { this.customBuilders = const {}, this.keyEventHandlers = const [], this.selectionMenuItems = const [], + this.editorStyle = const EditorStyle.defaultStyle(), }) : super(key: key); final EditorState editorState; @@ -46,6 +50,8 @@ class AppFlowyEditor extends StatefulWidget { final List selectionMenuItems; + final EditorStyle editorStyle; + @override State createState() => _AppFlowyEditorState(); } @@ -58,6 +64,7 @@ class _AppFlowyEditorState extends State { super.initState(); editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.editorStyle = widget.editorStyle; editorState.service.renderPluginService = _createRenderPlugin(); } @@ -66,6 +73,8 @@ class _AppFlowyEditorState extends State { super.didUpdateWidget(oldWidget); if (editorState.service != oldWidget.editorState.service) { + editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.editorStyle = widget.editorStyle; editorState.service.renderPluginService = _createRenderPlugin(); } } @@ -74,27 +83,31 @@ class _AppFlowyEditorState extends State { Widget build(BuildContext context) { return AppFlowyScroll( key: editorState.service.scrollServiceKey, - child: AppFlowySelection( - key: editorState.service.selectionServiceKey, - editorState: editorState, - child: AppFlowyInput( - key: editorState.service.inputServiceKey, + child: Padding( + padding: widget.editorStyle.padding, + child: AppFlowySelection( + key: editorState.service.selectionServiceKey, editorState: editorState, - child: AppFlowyKeyboard( - key: editorState.service.keyboardServiceKey, - handlers: [ - ...defaultKeyEventHandlers, - ...widget.keyEventHandlers, - ], + child: AppFlowyInput( + key: editorState.service.inputServiceKey, editorState: editorState, - child: FlowyToolbar( - key: editorState.service.toolbarServiceKey, + child: AppFlowyKeyboard( + key: editorState.service.keyboardServiceKey, + handlers: [ + ...defaultKeyEventHandlers, + ...widget.keyEventHandlers, + ], editorState: editorState, - child: editorState.service.renderPluginService.buildPluginWidget( - NodeWidgetContext( - context: context, - node: editorState.document.root, - editorState: editorState, + child: FlowyToolbar( + key: editorState.service.toolbarServiceKey, + editorState: editorState, + child: + editorState.service.renderPluginService.buildPluginWidget( + NodeWidgetContext( + context: context, + node: editorState.document.root, + editorState: editorState, + ), ), ), ), diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index 9aae2b5fcb..a92fae1b95 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart @@ -87,15 +87,18 @@ class _AppFlowyInputState extends State @override void attach(TextEditingValue textEditingValue) { - _textInputConnection ??= TextInput.attach( - this, - const TextInputConfiguration( - // TODO: customize - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - ), - ); + if (_textInputConnection == null || + _textInputConnection!.attached == false) { + _textInputConnection = TextInput.attach( + this, + const TextInputConfiguration( + // TODO: customize + enableDeltaModel: true, + inputType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + ), + ); + } _textInputConnection! ..setEditingState(textEditingValue) diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/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 index ecaf3325e4..a19541da0f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/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,26 +1,225 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/node_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -int _endOffsetOfNode(Node node) { - if (node is TextNode) { - return node.delta.length; +AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) { + if (!_arrowKeys.contains(event.logicalKey)) { + return KeyEventResult.ignored; } - return 0; + + if (event.isMetaPressed && event.isShiftPressed) { + return _arrowKeysWithMetaAndShift(editorState, event); + } else if (event.isMetaPressed) { + return _arrowKeysWithMeta(editorState, event); + } else if (event.isShiftPressed) { + return _arrowKeysWithShift(editorState, event); + } else { + return _arrowKeysOnly(editorState, event); + } +}; + +final _arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown +]; + +KeyEventResult _arrowKeysWithMetaAndShift( + EditorState editorState, RawKeyEvent event) { + if (!event.isMetaPressed || + !event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + final selection = editorState.service.selectionService.currentSelection.value; + if (nodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + + var start = selection.start; + var end = selection.end; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final position = nodes.first.selectable?.start(); + if (position != null) { + end = position; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final position = nodes.first.selectable?.end(); + if (position != null) { + end = position; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final position = editorState.document.root.children + .whereType() + .first + .selectable + ?.start(); + if (position != null) { + end = position; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final position = editorState.document.root.children + .whereType() + .last + .selectable + ?.end(); + if (position != null) { + end = position; + } + } + editorState.service.selectionService.updateSelection( + selection.copyWith(start: start, end: end), + ); + return KeyEventResult.handled; +} + +// Move the cursor to top, bottom, left and right of the document. +KeyEventResult _arrowKeysWithMeta(EditorState editorState, RawKeyEvent event) { + if (!event.isMetaPressed || + event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + Position? position; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + position = nodes.first.selectable?.start(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + position = nodes.last.selectable?.end(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + position = editorState.document.root.children + .whereType() + .first + .selectable + ?.start(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + position = editorState.document.root.children + .whereType() + .last + .selectable + ?.end(); + } + if (position == null) { + return KeyEventResult.ignored; + } + editorState.service.selectionService.updateSelection( + Selection.collapsed(position), + ); + return KeyEventResult.handled; +} + +KeyEventResult _arrowKeysWithShift(EditorState editorState, RawKeyEvent event) { + if (event.isMetaPressed || + !event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + final selection = editorState.service.selectionService.currentSelection.value; + if (nodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + Position? end; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + end = selection.end.goLeft(editorState); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + end = selection.end.goRight(editorState); + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + end = _goUp(editorState); + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + end = _goDown(editorState); + } + if (end == null) { + return KeyEventResult.ignored; + } + editorState.service.selectionService + .updateSelection(selection.copyWith(end: end)); + return KeyEventResult.handled; +} + +KeyEventResult _arrowKeysOnly(EditorState editorState, RawKeyEvent event) { + if (event.isMetaPressed || + event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + final selection = + editorState.service.selectionService.currentSelection.value?.normalize; + if (nodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (selection.isCollapsed) { + final leftPosition = selection.start.goLeft(editorState); + if (leftPosition != null) { + editorState.service.selectionService.updateSelection( + Selection.collapsed(leftPosition), + ); + } + } else { + editorState.service.selectionService.updateSelection( + Selection.collapsed(selection.start), + ); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (selection.isCollapsed) { + final rightPosition = selection.start.goRight(editorState); + if (rightPosition != null) { + editorState.service.selectionService.updateSelection( + Selection.collapsed(rightPosition), + ); + } + } else { + editorState.service.selectionService.updateSelection( + Selection.collapsed(selection.end), + ); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final upPosition = _goUp(editorState); + editorState.updateCursorSelection( + upPosition == null ? null : Selection.collapsed(upPosition), + ); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final downPosition = _goDown(editorState); + editorState.updateCursorSelection( + downPosition == null ? null : Selection.collapsed(downPosition), + ); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } extension on Position { Position? goLeft(EditorState editorState) { - final node = editorState.document.nodeAtPath(path)!; + final node = editorState.document.nodeAtPath(path); + if (node == null) { + return null; + } if (offset == 0) { - final prevNode = node.previous; - if (prevNode != null) { - return Position( - path: prevNode.path, offset: _endOffsetOfNode(prevNode)); + final previousEnd = node.previous?.selectable?.end(); + if (previousEnd != null) { + return previousEnd; } return null; } - if (node is TextNode) { return Position(path: path, offset: node.delta.prevRunePosition(offset)); } else { @@ -29,16 +228,18 @@ extension on Position { } Position? goRight(EditorState editorState) { - final node = editorState.document.nodeAtPath(path)!; - final lengthOfNode = _endOffsetOfNode(node); - if (offset >= lengthOfNode) { - final nextNode = node.next; - if (nextNode != null) { - return Position(path: nextNode.path, offset: 0); + final node = editorState.document.nodeAtPath(path); + if (node == null) { + return null; + } + final end = node.selectable?.end(); + if (end != null && offset >= end.offset) { + final nextStart = node.next?.selectable?.start(); + if (nextStart != null) { + return nextStart; } return null; } - if (node is TextNode) { return Position(path: path, offset: node.delta.nextRunePosition(offset)); } else { @@ -48,106 +249,43 @@ extension on Position { } Position? _goUp(EditorState editorState) { + final selection = editorState.service.selectionService.currentSelection.value; final rects = editorState.service.selectionService.selectionRects; - if (rects.isEmpty) { + if (rects.isEmpty || selection == null) { 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.getPositionInOffset(hitOffset); + Offset offset; + if (selection.isBackward) { + final rect = rects.reduce( + (current, next) => current.bottom >= next.bottom ? current : next, + ); + offset = rect.topRight.translate(0, -rect.height); + } else { + final rect = rects.reduce( + (current, next) => current.top <= next.top ? current : next, + ); + offset = rect.topLeft.translate(0, -rect.height); + } + return editorState.service.selectionService.getPositionInOffset(offset); } Position? _goDown(EditorState editorState) { + final selection = editorState.service.selectionService.currentSelection.value; final rects = editorState.service.selectionService.selectionRects; - if (rects.isEmpty) { + if (rects.isEmpty || selection == null) { 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.getPositionInOffset(hitOffset); + Offset offset; + if (selection.isBackward) { + final rect = rects.reduce( + (current, next) => current.bottom >= next.bottom ? current : next, + ); + offset = rect.bottomRight.translate(0, rect.height); + } else { + final rect = rects.reduce( + (current, next) => current.top <= next.top ? current : next, + ); + offset = rect.bottomLeft.translate(0, rect.height); + } + return editorState.service.selectionService.getPositionInOffset(offset); } - -KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { - final currentSelection = editorState.cursorSelection; - if (currentSelection == null) { - return KeyEventResult.ignored; - } - - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - final leftPosition = currentSelection.end.goLeft(editorState); - editorState.updateCursorSelection(leftPosition == null - ? null - : Selection(start: currentSelection.start, end: leftPosition)); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - final rightPosition = currentSelection.start.goRight(editorState); - editorState.updateCursorSelection(rightPosition == null - ? null - : Selection(start: rightPosition, end: currentSelection.end)); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - final position = _goUp(editorState); - editorState.updateCursorSelection(position == null - ? null - : Selection(start: position, end: currentSelection.end)); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - final position = _goDown(editorState); - editorState.updateCursorSelection(position == null - ? null - : Selection(start: currentSelection.start, end: position)); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -} - -AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) { - if (event.isShiftPressed) { - return _handleShiftKey(editorState, event); - } - - final currentSelection = editorState.cursorSelection; - if (currentSelection == null) { - return KeyEventResult.ignored; - } - - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - if (currentSelection.isCollapsed) { - final leftPosition = currentSelection.start.goLeft(editorState); - if (leftPosition != null) { - editorState.updateCursorSelection(Selection.collapsed(leftPosition)); - } - } else { - editorState.updateCursorSelection( - currentSelection.collapse(atStart: currentSelection.isBackward), - ); - } - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - if (currentSelection.isCollapsed) { - final rightPosition = currentSelection.end.goRight(editorState); - if (rightPosition != null) { - editorState.updateCursorSelection(Selection.collapsed(rightPosition)); - } - } else { - editorState.updateCursorSelection( - currentSelection.collapse(atStart: !currentSelection.isBackward), - ); - } - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - final position = _goUp(editorState); - editorState.updateCursorSelection( - position == null ? null : Selection.collapsed(position)); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - final position = _goDown(editorState); - editorState.updateCursorSelection( - position == null ? null : Selection.collapsed(position)); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; -}; 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/backspace_handler.dart similarity index 84% rename from frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart index b931ee3d61..675ee5b446 100644 --- 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/backspace_handler.dart @@ -1,3 +1,4 @@ +import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -11,13 +12,16 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { 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 nonTextNodes = + nodes.where((node) => node is! TextNode).toList(growable: false); final transactionBuilder = TransactionBuilder(editorState); + + if (nonTextNodes.isNotEmpty) { + transactionBuilder.deleteNodes(nonTextNodes); + } + if (textNodes.length == 1) { final textNode = textNodes.first; final index = textNode.delta.prevRunePosition(selection.start.offset); @@ -26,7 +30,8 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { if (textNode.subtype != null) { transactionBuilder ..updateNode(textNode, { - 'subtype': null, + StyleKey.subtype: null, + textNode.subtype!: null, }) ..afterSelection = Selection.collapsed( Position( @@ -37,9 +42,9 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { } else { // 2. non-style // find previous text node. - while (textNode.previous != null) { - if (textNode.previous is TextNode) { - final previous = textNode.previous as TextNode; + var previous = textNode.previous; + while (previous != null) { + if (previous is TextNode) { transactionBuilder ..mergeText(previous, textNode) ..deleteNode(textNode) @@ -50,6 +55,8 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { ), ); break; + } else { + previous = previous.previous; } } } @@ -69,10 +76,15 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { } } } else { - _deleteNodes(transactionBuilder, textNodes, selection); + if (textNodes.isNotEmpty) { + _deleteTextNodes(transactionBuilder, textNodes, selection); + } } if (transactionBuilder.operations.isNotEmpty) { + if (nonTextNodes.isNotEmpty) { + transactionBuilder.afterSelection = Selection.collapsed(selection.start); + } transactionBuilder.commit(); } @@ -122,7 +134,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) { } } } else { - _deleteNodes(transactionBuilder, textNodes, selection); + _deleteTextNodes(transactionBuilder, textNodes, selection); } transactionBuilder.commit(); @@ -130,7 +142,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) { return KeyEventResult.handled; } -void _deleteNodes(TransactionBuilder transactionBuilder, +void _deleteTextNodes(TransactionBuilder transactionBuilder, List textNodes, Selection selection) { final first = textNodes.first; final last = textNodes.last; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/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 index 3f49a4b566..f6dba97c49 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/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,13 +1,12 @@ 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'; _handleCopy(EditorState editorState) async { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null || selection.isCollapsed) { return; } @@ -43,7 +42,7 @@ _handleCopy(EditorState editorState) async { } _pasteHTML(EditorState editorState, String html) { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null) { return; } @@ -191,7 +190,7 @@ Delta _lineContentToDelta(String lineContent) { } _handlePastePlainText(EditorState editorState, String plainText) { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null) { return; } @@ -256,7 +255,7 @@ _handleCut(EditorState editorState) { } _deleteSelectedContent(EditorState editorState) { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null || selection.isCollapsed) { return; } 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 index 468eda4e98..f8c7fa71cb 100644 --- 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 @@ -1,7 +1,7 @@ -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/backspace_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/arrow_keys_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'; @@ -13,6 +13,7 @@ import 'package:appflowy_editor/src/service/keyboard_service.dart'; List defaultKeyEventHandlers = [ deleteTextHandler, slashShortcutHandler, + // arrowKeysHandler, arrowKeysHandler, copyPasteKeysHandler, redoUndoKeysHandler, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart new file mode 100644 index 0000000000..ecaf3325e4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart @@ -0,0 +1,153 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +int _endOffsetOfNode(Node node) { + if (node is TextNode) { + return node.delta.length; + } + return 0; +} + +extension on Position { + Position? goLeft(EditorState editorState) { + final node = editorState.document.nodeAtPath(path)!; + if (offset == 0) { + final prevNode = node.previous; + if (prevNode != null) { + return Position( + path: prevNode.path, offset: _endOffsetOfNode(prevNode)); + } + return null; + } + + if (node is TextNode) { + return Position(path: path, offset: node.delta.prevRunePosition(offset)); + } else { + return Position(path: path, offset: offset); + } + } + + Position? goRight(EditorState editorState) { + final node = editorState.document.nodeAtPath(path)!; + final lengthOfNode = _endOffsetOfNode(node); + if (offset >= lengthOfNode) { + final nextNode = node.next; + if (nextNode != null) { + return Position(path: nextNode.path, offset: 0); + } + return null; + } + + 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.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.getPositionInOffset(hitOffset); +} + +Position? _goDown(EditorState editorState) { + 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.getPositionInOffset(hitOffset); +} + +KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { + final currentSelection = editorState.cursorSelection; + if (currentSelection == null) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final leftPosition = currentSelection.end.goLeft(editorState); + editorState.updateCursorSelection(leftPosition == null + ? null + : Selection(start: currentSelection.start, end: leftPosition)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final rightPosition = currentSelection.start.goRight(editorState); + editorState.updateCursorSelection(rightPosition == null + ? null + : Selection(start: rightPosition, end: currentSelection.end)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final position = _goUp(editorState); + editorState.updateCursorSelection(position == null + ? null + : Selection(start: position, end: currentSelection.end)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final position = _goDown(editorState); + editorState.updateCursorSelection(position == null + ? null + : Selection(start: currentSelection.start, end: position)); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +} + +AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) { + if (event.isShiftPressed) { + return _handleShiftKey(editorState, event); + } + + final currentSelection = editorState.cursorSelection; + if (currentSelection == null) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (currentSelection.isCollapsed) { + final leftPosition = currentSelection.start.goLeft(editorState); + if (leftPosition != null) { + editorState.updateCursorSelection(Selection.collapsed(leftPosition)); + } + } else { + editorState.updateCursorSelection( + currentSelection.collapse(atStart: currentSelection.isBackward), + ); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (currentSelection.isCollapsed) { + final rightPosition = currentSelection.end.goRight(editorState); + if (rightPosition != null) { + editorState.updateCursorSelection(Selection.collapsed(rightPosition)); + } + } else { + editorState.updateCursorSelection( + currentSelection.collapse(atStart: !currentSelection.isBackward), + ); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final position = _goUp(editorState); + editorState.updateCursorSelection( + position == null ? null : Selection.collapsed(position)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final position = _goDown(editorState); + editorState.updateCursorSelection( + position == null ? null : Selection.collapsed(position)); + return KeyEventResult.handled; + } + + 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 index 32e27a808a..79b7ee6579 100644 --- 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 @@ -25,10 +25,6 @@ AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) { 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 ?? '') 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 index 0eb926525b..00b304f527 100644 --- 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 @@ -36,6 +36,12 @@ AppFlowyKeyEventHandler updateTextStyleByCommandXHandler = event.isShiftPressed) { formatHighlight(editorState); return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyK) { + if (editorState.service.toolbarService + ?.triggerHandler('appflowy.toolbar.link') == + true) { + return KeyEventResult.handled; + } } return KeyEventResult.ignored; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart index 1867574993..dee3f42725 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -129,6 +129,10 @@ class _AppFlowyKeyboardState extends State void _onFocusChange(bool value) { Log.keyboard.debug('on keyboard event focus change $value'); + isFocus = value; + if (!value) { + widget.editorState.service.selectionService.clearCursor(); + } } KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { 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 index c5e351059c..ca442f4ff9 100644 --- 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 @@ -57,6 +57,9 @@ abstract class AppFlowySelectionService { /// Clears the selection area, cursor area and the popup list area. void clearSelection(); + /// Clears the cursor area. + void clearCursor(); + /// Returns the [Node]s in [Selection]. List getNodesInSelection(Selection selection); @@ -205,16 +208,23 @@ class _AppFlowySelectionState extends State currentSelectedNodes = []; currentSelection.value = null; + clearCursor(); // clear selection areas _selectionAreas ..forEach((overlay) => overlay.remove()) ..clear(); // clear cursor areas + + // hide toolbar + editorState.service.toolbarService?.hide(); + } + + @override + void clearCursor() { + // clear cursor areas _cursorAreas ..forEach((overlay) => overlay.remove()) ..clear(); - // hide toolbar - editorState.service.toolbarService?.hide(); } @override @@ -338,10 +348,8 @@ class _AppFlowySelectionState extends State 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); + final normalizedSelection = selection.normalize; + assert(normalizedSelection.isBackward); for (var i = 0; i < backwardNodes.length; i++) { final node = backwardNodes[i]; @@ -350,7 +358,7 @@ class _AppFlowySelectionState extends State continue; } - var newSelection = backwardSelection.copy(); + var newSelection = normalizedSelection.copy(); /// In the case of multiple selections, /// we need to return a new selection for each selected node individually. @@ -360,7 +368,7 @@ class _AppFlowySelectionState extends State /// text: ghijkl /// text: mn>opqr /// - if (!backwardSelection.isSingle) { + if (!normalizedSelection.isSingle) { if (i == 0) { newSelection = newSelection.copyWith(end: selectable.end()); } else if (i == nodes.length - 1) { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart index e3436ea7ee..b9b6ac390a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/service.dart @@ -36,10 +36,10 @@ class FlowyService { // toolbar service final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service'); - FlowyToolbarService? get toolbarService { + AppFlowyToolbarService? get toolbarService { if (toolbarServiceKey.currentState != null && - toolbarServiceKey.currentState is FlowyToolbarService) { - return toolbarServiceKey.currentState! as FlowyToolbarService; + toolbarServiceKey.currentState is AppFlowyToolbarService) { + return toolbarServiceKey.currentState! as AppFlowyToolbarService; } return null; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart index bf380290f9..5d79c44824 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart @@ -1,15 +1,19 @@ +import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; import 'package:flutter/material.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/render/selection/toolbar_widget.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; import 'package:appflowy_editor/src/extensions/object_extensions.dart'; -abstract class FlowyToolbarService { +abstract class AppFlowyToolbarService { /// Show the toolbar widget beside the offset. void showInOffset(Offset offset, LayerLink layerLink); /// Hide the toolbar widget. void hide(); + + /// Trigger the specified handler. + bool triggerHandler(String id); } class FlowyToolbar extends StatefulWidget { @@ -27,7 +31,7 @@ class FlowyToolbar extends StatefulWidget { } class _FlowyToolbarState extends State - implements FlowyToolbarService { + implements AppFlowyToolbarService { OverlayEntry? _toolbarOverlay; final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget'); @@ -41,7 +45,7 @@ class _FlowyToolbarState extends State editorState: widget.editorState, layerLink: layerLink, offset: offset.translate(0, -37.0), - handlers: const {}, + items: _filterItems(defaultToolbarItems), ), ); Overlay.of(context)?.insert(_toolbarOverlay!); @@ -54,6 +58,17 @@ class _FlowyToolbarState extends State _toolbarOverlay = null; } + @override + bool triggerHandler(String id) { + final items = defaultToolbarItems.where((item) => item.id == id); + if (items.length != 1) { + assert(items.length == 1, 'The toolbar item\'s id must be unique'); + return false; + } + items.first.handler(widget.editorState, context); + return true; + } + @override Widget build(BuildContext context) { return Container( @@ -67,4 +82,29 @@ class _FlowyToolbarState extends State super.dispose(); } + + // Filter items that should not be displayed, sort according to type, + // and insert dividers between different types. + List _filterItems(List items) { + final filterItems = items + .where((item) => item.validator(widget.editorState)) + .toList(growable: false) + ..sort((a, b) => a.type.compareTo(b.type)); + if (filterItems.isEmpty) { + return []; + } + final List dividedItems = [filterItems.first]; + for (var i = 1; i < filterItems.length; i++) { + if (filterItems[i].type != filterItems[i - 1].type) { + dividedItems.add(ToolbarItem.divider()); + } + dividedItems.add(filterItems[i]); + } + return dividedItems; + } + + // List _highlightItems( + // List items, + // Selection selection, + // ) {} } diff --git a/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml index 33f443d066..295a45ad8c 100644 --- a/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml @@ -1,6 +1,6 @@ name: appflowy_editor description: A highly customizable rich-text editor for Flutter -version: 0.0.2 +version: 0.0.3 homepage: https://github.com/AppFlowy-IO/AppFlowy environment: @@ -21,7 +21,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^2.0.0 + flutter_lints: ^2.0.1 + network_image_mock: ^2.1.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -32,6 +33,7 @@ flutter: assets: - assets/images/toolbar/ - assets/images/selection_menu/ + - assets/images/image_toolbar/ - assets/images/ # # For details regarding assets in packages, see 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 index 8c89b603aa..e3a5a7d0c5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart @@ -57,6 +57,19 @@ class EditorWidgetTester { ); } + void insertImageNode(String src, {String? align}) { + insert( + Node( + type: 'image', + children: LinkedList(), + attributes: { + 'image_src': src, + 'align': align ?? 'center', + }, + ), + ); + } + Node? nodeAtPath(Path path) { return root.childAtPath(path); } @@ -67,7 +80,7 @@ class EditorWidgetTester { } else { _editorState.service.selectionService.updateSelection(selection); } - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); expect(_editorState.service.selectionService.currentSelection.value, selection); 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 index 150a3e2d00..47cacc18b1 100644 --- 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 @@ -103,6 +103,9 @@ extension on LogicalKeyboardKey { if (this == LogicalKeyboardKey.slash) { return PhysicalKeyboardKey.slash; } + if (this == LogicalKeyboardKey.arrowUp) { + return PhysicalKeyboardKey.arrowUp; + } if (this == LogicalKeyboardKey.arrowDown) { return PhysicalKeyboardKey.arrowDown; } @@ -115,6 +118,9 @@ extension on LogicalKeyboardKey { if (this == LogicalKeyboardKey.keyI) { return PhysicalKeyboardKey.keyI; } + if (this == LogicalKeyboardKey.keyK) { + return PhysicalKeyboardKey.keyK; + } if (this == LogicalKeyboardKey.keyS) { return PhysicalKeyboardKey.keyS; } diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart new file mode 100644 index 0000000000..a9732d8a20 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart @@ -0,0 +1,132 @@ +import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; +import 'package:appflowy_editor/src/service/editor_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; + +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('image_node_builder.dart', () { + testWidgets('render image node', (tester) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = + 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; + final editor = tester.editor + ..insertTextNode(text) + ..insertImageNode(src) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 3); + expect(find.byType(Image), findsOneWidget); + }); + }); + + testWidgets('render image align', (tester) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = + 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; + final editor = tester.editor + ..insertTextNode(text) + ..insertImageNode(src, align: 'left') + ..insertImageNode(src, align: 'center') + ..insertImageNode(src, align: 'right') + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 5); + final imageFinder = find.byType(Image); + expect(imageFinder, findsNWidgets(3)); + + final editorFinder = find.byType(AppFlowyEditor); + final editorRect = tester.getRect(editorFinder); + + final leftImageRect = tester.getRect(imageFinder.at(0)); + expect(leftImageRect.left, editor.editorState.editorStyle.padding.left); + final rightImageRect = tester.getRect(imageFinder.at(2)); + expect(rightImageRect.right, + editorRect.right - editor.editorState.editorStyle.padding.right); + final centerImageRect = tester.getRect(imageFinder.at(1)); + expect(centerImageRect.left, + (leftImageRect.left + rightImageRect.left) / 2.0); + expect(leftImageRect.size, centerImageRect.size); + expect(rightImageRect.size, centerImageRect.size); + + final imageNodeWidgetFinder = find.byType(ImageNodeWidget); + + final leftImage = + tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; + + leftImage.onAlign(Alignment.center); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester.getRect(imageFinder.at(0)).left, + centerImageRect.left, + ); + + leftImage.onAlign(Alignment.centerRight); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester.getRect(imageFinder.at(0)).right, + rightImageRect.right, + ); + }); + }); + + testWidgets('render image copy', (tester) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = + 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; + final editor = tester.editor + ..insertTextNode(text) + ..insertImageNode(src) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 3); + final imageFinder = find.byType(Image); + expect(imageFinder, findsOneWidget); + + final imageNodeWidgetFinder = find.byType(ImageNodeWidget); + final image = + tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; + image.onCopy(); + }); + }); + + testWidgets('render image delete', (tester) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = + 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; + final editor = tester.editor + ..insertTextNode(text) + ..insertImageNode(src) + ..insertImageNode(src) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 4); + final imageFinder = find.byType(Image); + expect(imageFinder, findsNWidgets(2)); + + final imageNodeWidgetFinder = find.byType(ImageNodeWidget); + final image = + tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget; + image.onDelete(); + + await tester.pump(const Duration(milliseconds: 100)); + expect(editor.documentLength, 3); + expect(find.byType(Image), findsNWidgets(1)); + }); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart new file mode 100644 index 0000000000..a566b7ec07 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart @@ -0,0 +1,92 @@ +import 'dart:collection'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('image_node_widget.dart', () { + testWidgets('build the image node widget', (tester) async { + mockNetworkImagesFor(() async { + var onCopyHit = false; + var onDeleteHit = false; + var onAlignHit = false; + const src = + 'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb'; + + final widget = ImageNodeWidget( + src: src, + node: Node( + type: 'image', + children: LinkedList(), + attributes: { + 'image_src': src, + 'align': 'center', + }, + ), + alignment: Alignment.center, + onCopy: () { + onCopyHit = true; + }, + onDelete: () { + onDeleteHit = true; + }, + onAlign: (alignment) { + onAlignHit = true; + }, + onResize: (width) {}, + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: widget, + ), + ), + ); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + final gesture = + await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + + expect(find.byType(ImageToolbar), findsNothing); + + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget))); + await tester.pump(); + + expect(find.byType(ImageToolbar), findsOneWidget); + + final iconFinder = find.byType(IconButton); + expect(iconFinder, findsNWidgets(5)); + + await tester.tap(iconFinder.at(0)); + expect(onAlignHit, true); + onAlignHit = false; + + await tester.tap(iconFinder.at(1)); + expect(onAlignHit, true); + onAlignHit = false; + + await tester.tap(iconFinder.at(2)); + expect(onAlignHit, true); + onAlignHit = false; + + await tester.tap(iconFinder.at(3)); + expect(onCopyHit, true); + + await tester.tap(iconFinder.at(4)); + expect(onDeleteHit, true); + }); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart new file mode 100644 index 0000000000..cef16a1cec --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart @@ -0,0 +1,43 @@ +import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('link_menu.dart', () { + testWidgets('test empty link menu actions', (tester) async { + const link = 'appflowy.io'; + var submittedText = ''; + final linkMenu = LinkMenu( + onOpenLink: () {}, + onCopyLink: () {}, + onRemoveLink: () {}, + onFocusChange: (value) {}, + onSubmitted: (text) { + submittedText = text; + }, + ); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: linkMenu, + ), + ), + ); + + expect(find.byType(TextButton), findsNothing); + expect(find.byType(TextField), findsOneWidget); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), link); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(submittedText, link); + }); + }); +} 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 index f039c227d9..afd89ddee9 100644 --- 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 @@ -10,8 +10,8 @@ void main() async { TestWidgetsFlutterBinding.ensureInitialized(); }); - group('delete_text_handler.dart', () { - testWidgets('Presses backspace key in empty document', (tester) async { + group('checkbox_text_handler.dart', () { + testWidgets('Click checkbox icon', (tester) async { // Before // // [BIUS]Welcome to Appflowy 😁[BIUS] 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 index 1488b15b18..01c1403738 100644 --- 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 @@ -20,7 +20,7 @@ void main() async { name: 'example', icon: icon, keywords: ['example A', 'example B'], - handler: (editorState, menuService) { + handler: (editorState, menuService, context) { flag = true; }, ); 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 index 1efcfa640d..6006fe6a7a 100644 --- 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 @@ -25,7 +25,9 @@ void main() async { find.byType(SelectionMenuWidget, skipOffstage: false), findsNothing, ); - await _testDefaultSelectionMenuItems(i, editor); + if (defaultSelectionMenuItems[i].name != 'Image') { + await _testDefaultSelectionMenuItems(i, editor); + } }); } }); @@ -36,17 +38,17 @@ void main() async { await editor.pressLogicKey(LogicalKeyboardKey.keyE); expect( find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(2), + findsNWidgets(3), ); await editor.pressLogicKey(LogicalKeyboardKey.backspace); expect( find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(3), + findsNWidgets(4), ); await editor.pressLogicKey(LogicalKeyboardKey.keyE); expect( find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(2), + findsNWidgets(3), ); await editor.pressLogicKey(LogicalKeyboardKey.keyX); expect( @@ -71,7 +73,7 @@ void main() async { await editor.pressLogicKey(LogicalKeyboardKey.keyE); expect( find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(2), + findsNWidgets(3), ); await editor.pressLogicKey(LogicalKeyboardKey.escape); expect( @@ -87,7 +89,7 @@ void main() async { await editor.pressLogicKey(LogicalKeyboardKey.keyE); expect( find.byType(SelectionMenuItemWidget, skipOffstage: false), - findsNWidgets(2), + findsNWidgets(3), ); await editor.pressLogicKey(LogicalKeyboardKey.backspace); await editor.pressLogicKey(LogicalKeyboardKey.backspace); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart new file mode 100644 index 0000000000..3d212691cb --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart @@ -0,0 +1,62 @@ +import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('toolbar_item_widget.dart', () { + testWidgets('test single toolbar item widget', (tester) async { + final key = GlobalKey(); + final iconKey = GlobalKey(); + var hit = false; + final item = ToolbarItem( + id: 'appflowy.toolbar.test', + type: 1, + iconBuilder: (isHighlight) { + return Icon( + key: iconKey, + Icons.abc, + color: isHighlight ? Colors.lightBlue : null, + ); + }, + validator: (editorState) => true, + handler: (editorState, context) {}, + highlightCallback: (editorState) { + return true; + }, + ); + final widget = ToolbarItemWidget( + key: key, + item: item, + isHighlight: true, + onPressed: (() { + hit = true; + }), + ); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: widget, + ), + ), + ); + + expect(find.byKey(key), findsOneWidget); + expect(find.byKey(iconKey), findsOneWidget); + expect( + (tester.firstWidget(find.byKey(iconKey)) as Icon).color, + Colors.lightBlue, + ); + + await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + + expect(hit, true); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart new file mode 100644 index 0000000000..d7e6b906f8 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_widget_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('toolbar_widget.dart', () { + testWidgets('test toolbar widget', (tester) async {}); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart index e4631b56ad..ebbd71a392 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart @@ -58,6 +58,219 @@ void main() async { (tester) async { await _testPressArrowKeyInNotCollapsedSelection(tester, false); }); + + testWidgets('Presses arrow left/right + shift in collapsed selection', + (tester) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + const offset = 8; + final selection = Selection.single(path: [1], startOffset: offset); + await editor.updateSelection(selection); + for (var i = offset - 1; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [1], offset: i), + ), + ); + } + for (var i = text.length; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = 1; i <= text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = 0; i < text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [1], offset: i), + ), + ); + } + }); + + testWidgets( + 'Presses arrow left/right + shift in not collapsed and backward selection', + (tester) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + const start = 8; + const end = 12; + final selection = Selection.single( + path: [0], + startOffset: start, + endOffset: end, + ); + await editor.updateSelection(selection); + for (var i = end + 1; i <= text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = text.length - 1; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + }); + + testWidgets( + 'Presses arrow left/right + command in not collapsed and forward selection', + (tester) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + const start = 12; + const end = 8; + final selection = Selection.single( + path: [0], + startOffset: start, + endOffset: end, + ); + await editor.updateSelection(selection); + for (var i = end - 1; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = 1; i <= text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + }); + + testWidgets('Presses arrow left/right/up/down + meta in collapsed selection', + (tester) async { + await _testPressArrowKeyWithMetaInSelection(tester, true, false); + }); + + testWidgets( + 'Presses arrow left/right/up/down + meta in not collapsed and backward selection', + (tester) async { + await _testPressArrowKeyWithMetaInSelection(tester, false, true); + }); + + testWidgets( + 'Presses arrow left/right/up/down + meta in not collapsed and forward selection', + (tester) async { + await _testPressArrowKeyWithMetaInSelection(tester, false, false); + }); + + testWidgets('Presses arrow up/down + shift in not collapsed selection', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(null) + ..insertTextNode(text) + ..insertTextNode(null) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + final selection = Selection.single(path: [3], startOffset: 8); + await editor.updateSelection(selection); + for (int i = 0; i < 3; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowUp, + isShiftPressed: true, + ); + } + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: 0), + ), + ); + for (int i = 0; i < 7; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowDown, + isShiftPressed: true, + ); + } + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [6], offset: 0), + ), + ); + for (int i = 0; i < 3; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowUp, + isShiftPressed: true, + ); + } + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [3], offset: 0), + ), + ); + }); } Future _testPressArrowKeyInNotCollapsedSelection( @@ -82,3 +295,72 @@ Future _testPressArrowKeyInNotCollapsedSelection( await editor.pressLogicKey(LogicalKeyboardKey.arrowRight); expect(editor.documentSelection?.end, end); } + +Future _testPressArrowKeyWithMetaInSelection( + WidgetTester tester, + bool isSingle, + bool isBackward, +) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + Selection selection; + if (isSingle) { + selection = Selection.single( + path: [0], + startOffset: 8, + ); + } else { + if (isBackward) { + selection = Selection.single( + path: [0], + startOffset: 8, + endOffset: text.length, + ); + } else { + selection = Selection.single( + path: [0], + startOffset: text.length, + endOffset: 8, + ); + } + } + await editor.updateSelection(selection); + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: text.length), + ); + + await editor.pressLogicKey( + LogicalKeyboardKey.arrowUp, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressLogicKey( + LogicalKeyboardKey.arrowDown, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: text.length), + ); +} 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/backspace_handler_test.dart similarity index 69% rename from frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart rename to frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart index 1e7bf4e842..da5d22a786 100644 --- 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/backspace_handler_test.dart @@ -1,7 +1,9 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/image/image_node_widget.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 'package:network_image_mock/network_image_mock.dart'; import '../../infra/test_editor.dart'; void main() async { @@ -9,7 +11,7 @@ void main() async { TestWidgetsFlutterBinding.ensureInitialized(); }); - group('delete_text_handler.dart', () { + group('backspace_handler.dart', () { testWidgets('Presses backspace key in empty document', (tester) async { // Before // @@ -167,6 +169,161 @@ void main() async { testWidgets('Presses delete key in styled text (quote)', (tester) async { await _deleteStyledTextByDelete(tester, StyleKey.quote); }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // [Image] + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + testWidgets('Deletes the image surrounded by text', (tester) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertImageNode(src) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 5); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + await editor.updateSelection( + Selection( + start: Position(path: [1], offset: 0), + end: Position(path: [3], offset: text.length), + ), + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 3); + expect(find.byType(ImageNodeWidget), findsNothing); + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: 0), + ); + }); + }); + + testWidgets('Deletes the first image, and selection is backward', + (tester) async { + await _deleteFirstImage(tester, true); + }); + + testWidgets('Deletes the first image, and selection is not backward', + (tester) async { + await _deleteFirstImage(tester, false); + }); + + testWidgets('Deletes the last image and selection is backward', + (tester) async { + await _deleteLastImage(tester, true); + }); + + testWidgets('Deletes the last image and selection is not backward', + (tester) async { + await _deleteLastImage(tester, false); + }); + + testWidgets('Removes the style of heading text and revert', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + + final textNode = editor.nodeAtPath([0]) as TextNode; + + await editor.insertText(textNode, '#', 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect( + (editor.nodeAtPath([0]) as TextNode).attributes.heading, + StyleKey.h1, + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect( + textNode.attributes.heading, + null, + ); + + await editor.insertText(textNode, '#', 0); + await editor.pressLogicKey(LogicalKeyboardKey.space); + expect( + (editor.nodeAtPath([0]) as TextNode).attributes.heading, + StyleKey.h1, + ); + }); +} + +Future _deleteFirstImage(WidgetTester tester, bool isBackward) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; + final editor = tester.editor + ..insertImageNode(src) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 3); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + final start = Position(path: [0], offset: 0); + final end = Position(path: [1], offset: 1); + await editor.updateSelection( + Selection( + start: isBackward ? start : end, + end: isBackward ? end : start, + ), + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 2); + expect(find.byType(ImageNodeWidget), findsNothing); + expect(editor.documentSelection, Selection.collapsed(start)); + }); +} + +Future _deleteLastImage(WidgetTester tester, bool isBackward) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertImageNode(src); + await editor.startTesting(); + + expect(editor.documentLength, 3); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + final start = Position(path: [1], offset: 0); + final end = Position(path: [2], offset: 1); + await editor.updateSelection( + Selection( + start: isBackward ? start : end, + end: isBackward ? end : start, + ), + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 2); + expect(find.byType(ImageNodeWidget), findsNothing); + expect(editor.documentSelection, Selection.collapsed(start)); + }); } Future _deleteStyledTextByBackspace( diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart index ee21dfa455..5bfe1ada67 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart @@ -116,6 +116,27 @@ void main() async { (tester) async { _testMultipleSelection(tester, false); }); + + testWidgets('Presses enter key in the first line', (tester) async { + // Before + // + // Welcome to Appflowy 😁 + // + // After + // + // [Empty Line] + // Welcome to Appflowy 😁 + // + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.pressLogicKey(LogicalKeyboardKey.enter); + expect(editor.documentLength, 2); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text); + }); }); } 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 index 2e93d4c5f5..d7afacb27a 100644 --- 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 @@ -1,6 +1,9 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/link_menu/link_menu.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:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../infra/test_editor.dart'; @@ -54,6 +57,10 @@ void main() async { LogicalKeyboardKey.keyH, ); }); + + testWidgets('Presses Command + K to trigger link menu', (tester) async { + await _testLinkMenuInSingleTextSelection(tester); + }); }); } @@ -82,7 +89,14 @@ Future _testUpdateTextStyleByCommandX( ); var textNode = editor.nodeAtPath([1]) as TextNode; expect( - textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true); + textNode.allSatisfyInSelection( + selection, + matchStyle, + (value) { + return value == matchValue; + }, + ), + true); selection = Selection.single(path: [1], startOffset: 0, endOffset: text.length); @@ -94,7 +108,14 @@ Future _testUpdateTextStyleByCommandX( ); textNode = editor.nodeAtPath([1]) as TextNode; expect( - textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true); + textNode.allSatisfyInSelection( + selection, + matchStyle, + (value) { + return value == matchValue; + }, + ), + true); await editor.updateSelection(selection); await editor.pressLogicKey( @@ -122,10 +143,15 @@ Future _testUpdateTextStyleByCommandX( for (final node in nodes) { expect( node.allSatisfyInSelection( - matchStyle, - matchValue, Selection.single( - path: node.path, startOffset: 0, endOffset: text.length), + path: node.path, + startOffset: 0, + endOffset: text.length, + ), + matchStyle, + (value) { + return value == matchValue; + }, ), true, ); @@ -152,3 +178,69 @@ Future _testUpdateTextStyleByCommandX( ); } } + +Future _testLinkMenuInSingleTextSelection(WidgetTester tester) async { + const link = 'appflowy.io'; + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + final selection = + Selection.single(path: [1], startOffset: 0, endOffset: text.length); + await editor.updateSelection(selection); + + // show toolbar + expect(find.byType(ToolbarWidget), findsOneWidget); + + // trigger the link menu + await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); + + expect(find.byType(LinkMenu), findsOneWidget); + + await tester.enterText(find.byType(TextField), link); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.byType(LinkMenu), findsNothing); + + final node = editor.nodeAtPath([1]) as TextNode; + expect( + node.allSatisfyInSelection( + selection, + StyleKey.href, + (value) => value == link, + ), + true); + + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); + expect(find.byType(LinkMenu), findsOneWidget); + expect( + find.text(link, findRichText: true, skipOffstage: false), findsOneWidget); + + // Copy link + final copyLink = find.text('Copy link'); + expect(copyLink, findsOneWidget); + await tester.tap(copyLink); + await tester.pumpAndSettle(); + expect(find.byType(LinkMenu), findsNothing); + + // Remove link + await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true); + final removeLink = find.text('Remove link'); + expect(removeLink, findsOneWidget); + await tester.tap(removeLink); + await tester.pumpAndSettle(); + expect(find.byType(LinkMenu), findsNothing); + + expect( + node.allSatisfyInSelection( + selection, + StyleKey.href, + (value) => value == link, + ), + false); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart new file mode 100644 index 0000000000..23759e449c --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart @@ -0,0 +1,228 @@ +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/toolbar/toolbar_item.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart'; +import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('toolbar_service.dart', () { + testWidgets('Test toolbar service in multi text selection', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + final selection = Selection( + start: Position(path: [0], offset: 0), + end: Position(path: [1], offset: text.length), + ); + await editor.updateSelection(selection); + + expect(find.byType(ToolbarWidget), findsOneWidget); + + // no link item + final item = defaultToolbarItems + .where((item) => item.id == 'appflowy.toolbar.link') + .first; + final finder = find.byType(ToolbarItemWidget); + + expect( + tester + .widgetList(finder) + .toList(growable: false) + .where((element) => element.item.id == item.id) + .isEmpty, + true, + ); + }); + + testWidgets( + 'Test toolbar service in single text selection with StyleKey.partialStyleKeys', + (tester) async { + final attributes = StyleKey.partialStyleKeys.fold({}, + (previousValue, element) { + if (element == StyleKey.backgroundColor) { + previousValue[element] = '0x6000BCF0'; + } else if (element == StyleKey.href) { + previousValue[element] = 'appflowy.io'; + } else { + previousValue[element] = true; + } + return previousValue; + }); + + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode( + null, + delta: Delta([ + TextInsert(text), + TextInsert(text, attributes), + TextInsert(text), + ]), + ); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: text.length), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + + void testHighlight(bool expectedValue) { + for (final styleKey in StyleKey.partialStyleKeys) { + var key = styleKey; + if (styleKey == StyleKey.backgroundColor) { + key = 'highlight'; + } else if (styleKey == StyleKey.href) { + key = 'link'; + } + final itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.$key'); + expect(itemWidget.isHighlight, expectedValue); + } + } + + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0, endOffset: text.length * 2), + ); + testHighlight(false); + + await editor.updateSelection( + Selection.single( + path: [1], + startOffset: text.length, + endOffset: text.length * 2, + ), + ); + testHighlight(true); + + await editor.updateSelection( + Selection.single( + path: [1], + startOffset: text.length + 2, + endOffset: text.length * 2 - 2, + ), + ); + testHighlight(true); + }); + + testWidgets( + 'Test toolbar service in single text selection with StyleKey.globalStyleKeys', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + + final editor = tester.editor + ..insertTextNode(text, attributes: { + StyleKey.subtype: StyleKey.heading, + StyleKey.heading: StyleKey.h1, + }) + ..insertTextNode( + text, + attributes: {StyleKey.subtype: StyleKey.quote}, + ) + ..insertTextNode( + text, + attributes: {StyleKey.subtype: StyleKey.bulletedList}, + ); + await editor.startTesting(); + + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0, endOffset: text.length), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + var itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.h1'); + expect(itemWidget.isHighlight, true); + + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0, endOffset: text.length), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.quote'); + expect(itemWidget.isHighlight, true); + + await editor.updateSelection( + Selection.single(path: [2], startOffset: 0, endOffset: text.length), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + itemWidget = _itemWidgetForId(tester, 'appflowy.toolbar.bulleted_list'); + expect(itemWidget.isHighlight, true); + }); + + testWidgets('Test toolbar service in multi text selection', (tester) async { + const text = 'Welcome to Appflowy 😁'; + + /// [h1][bold] Welcome to Appflowy 😁 + /// [EmptyLine] + /// Welcome to Appflowy 😁 + final editor = tester.editor + ..insertTextNode( + null, + attributes: { + StyleKey.subtype: StyleKey.heading, + StyleKey.heading: StyleKey.h1, + }, + delta: Delta([ + TextInsert(text, { + StyleKey.bold: true, + }) + ]), + ) + ..insertTextNode(null) + ..insertTextNode(text); + await editor.startTesting(); + + await editor.updateSelection( + Selection.single(path: [2], startOffset: text.length, endOffset: 0), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + expect( + _itemWidgetForId(tester, 'appflowy.toolbar.h1').isHighlight, + false, + ); + expect( + _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, + false, + ); + + await editor.updateSelection( + Selection( + start: Position(path: [2], offset: text.length), + end: Position(path: [1], offset: 0), + ), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + expect( + _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, + false, + ); + + await editor.updateSelection( + Selection( + start: Position(path: [2], offset: text.length), + end: Position(path: [0], offset: 0), + ), + ); + expect(find.byType(ToolbarWidget), findsOneWidget); + expect( + _itemWidgetForId(tester, 'appflowy.toolbar.bold').isHighlight, + false, + ); + }); + }); +} + +ToolbarItemWidget _itemWidgetForId(WidgetTester tester, String id) { + final finder = find.byType(ToolbarItemWidget); + final itemWidgets = tester + .widgetList(finder) + .where((element) => element.item.id == id); + expect(itemWidgets.length, 1); + return itemWidgets.first; +} diff --git a/frontend/app_flowy/packages/flowy_infra/lib/notifier.dart b/frontend/app_flowy/packages/flowy_infra/lib/notifier.dart index 7eaf139f0c..bbb55bf885 100644 --- a/frontend/app_flowy/packages/flowy_infra/lib/notifier.dart +++ b/frontend/app_flowy/packages/flowy_infra/lib/notifier.dart @@ -31,19 +31,26 @@ class PublishNotifier extends ChangeNotifier { T? get currentValue => _value; - void addPublishListener(void Function(T) callback, {bool Function()? listenWhen}) { + void addPublishListener(void Function(T) callback, + {bool Function()? listenWhen}) { super.addListener( () { if (_value == null) { return; - } + } else {} if (listenWhen != null && listenWhen() == false) { return; } - callback(_value!); + callback(_value as T); }, ); } } + +class Notifier extends ChangeNotifier { + void notify() { + notifyListeners(); + } +} diff --git a/frontend/app_flowy/packages/flowy_infra/pubspec.lock b/frontend/app_flowy/packages/flowy_infra/pubspec.lock index fc492e7344..23fd739d68 100644 --- a/frontend/app_flowy/packages/flowy_infra/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_infra/pubspec.lock @@ -68,14 +68,14 @@ packages: name: flutter_lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.1" flutter_svg: dependency: "direct main" 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 @@ -87,7 +87,7 @@ packages: name: lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "2.0.0" matcher: dependency: transitive description: @@ -122,21 +122,21 @@ packages: name: path_drawing url: "https://pub.dartlang.org" source: hosted - version: "0.5.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" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.2.0" + version: "5.0.0" sky_engine: dependency: transitive description: flutter @@ -225,7 +225,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.2.0" + version: "6.1.0" sdks: - dart: ">=2.17.0-0 <3.0.0" - flutter: ">=1.24.0-7.0" + dart: ">=2.17.0 <3.0.0" + flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_infra/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra/pubspec.yaml index ebd7656af9..63f1b66b4e 100644 --- a/frontend/app_flowy/packages/flowy_infra/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle b/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle index 76e9272bbf..d129628362 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle +++ b/frontend/app_flowy/packages/flowy_infra_ui/android/build.gradle @@ -2,6 +2,7 @@ group 'com.example.flowy_infra_ui' version '1.0' buildscript { + ext.kotlin_version = '1.6.10' repositories { google() mavenCentral() diff --git a/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUiPlugin.java b/frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java similarity index 100% rename from frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUiPlugin.java rename to frontend/app_flowy/packages/flowy_infra_ui/android/src/main/java/com/example/flowy_infra_ui/FlowyInfraUIPlugin.java diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/build.gradle b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/build.gradle index 7356196bc3..1aa1cc480b 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/build.gradle +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/build.gradle @@ -24,8 +24,16 @@ if (flutterVersionName == null) { apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" +//apply plugin: 'kotlin-android-extensions' + + +//androidExtensions { +// experimental = true +//} android { + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -43,20 +51,23 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.flowy_infra_ui_example" - minSdkVersion 16 - targetSdkVersion 30 + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + multiDexEnabled true } buildTypes { release { + minifyEnabled true + shrinkResources true // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } - compileSdkVersion 30 + } flutter { @@ -65,4 +76,5 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.android.support:multidex:2.0.1' } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/java/com/example/flowy_infra_ui_example/MainActivity.java b/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/java/com/example/flowy_infra_ui_example/MainActivity.java deleted file mode 100644 index 8adb012232..0000000000 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/app/src/main/java/com/example/flowy_infra_ui_example/MainActivity.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.flowy_infra_ui_example; - -import io.flutter.embedding.android.FlutterActivity; - -public class MainActivity extends FlutterActivity { - -} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/build.gradle b/frontend/app_flowy/packages/flowy_infra_ui/example/android/build.gradle index 3dd86e4db3..cb243569ed 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/build.gradle +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.6.10' repositories { google() mavenCentral() diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/android/settings.gradle b/frontend/app_flowy/packages/flowy_infra_ui/example/android/settings.gradle index 44e62bcf06..e15f18b2ec 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/android/settings.gradle +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/android/settings.gradle @@ -9,3 +9,19 @@ localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } def flutterSdkPath = properties.getProperty("flutter.sdk") assert flutterSdkPath != null, "flutter.sdk not set in local.properties" apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" + + + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java b/frontend/app_flowy/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java new file mode 100644 index 0000000000..33c3ea5970 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/example/android/app/src/main/java/com/example/flowy_infra_ui_example/FlutterActivity.java @@ -0,0 +1,4 @@ +package example.android.app.src.main.java.com.example.flowy_infra_ui_example; + +public class FlutterActivity { +} diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart b/frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart index 28aa027b49..fde544365b 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/lib/keyboard/keyboard_screen.dart @@ -22,12 +22,13 @@ class KeyboardScreen extends StatefulWidget { const KeyboardScreen({Key? key}) : super(key: key); @override - _KeyboardScreenState createState() => _KeyboardScreenState(); + State createState() => _KeyboardScreenState(); } class _KeyboardScreenState extends State { bool _isKeyboardVisible = false; - final TextEditingController _controller = TextEditingController(text: 'Hello Flowy'); + final TextEditingController _controller = + TextEditingController(text: 'Hello Flowy'); @override Widget build(BuildContext context) { diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock b/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock index eaaa5db680..8ddcc99719 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.lock @@ -124,14 +124,14 @@ packages: name: flutter_lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.1" flutter_svg: 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 @@ -162,7 +162,7 @@ packages: name: lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "2.0.0" loading_indicator: dependency: transitive description: @@ -211,21 +211,21 @@ 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" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "5.0.0" plugin_platform_interface: dependency: transitive description: @@ -335,7 +335,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.3.1" + version: "6.1.0" sdks: - dart: ">=2.17.0-0 <3.0.0" - flutter: ">=2.0.0" + dart: ">=2.17.0 <3.0.0" + flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml index a747d1dc18..b9c8ec058e 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra_ui/example/pubspec.yaml @@ -14,12 +14,13 @@ dependencies: path: ../ cupertino_icons: ^1.0.2 + provider: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 flutter: uses-material-design: true diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml index 0b5f8eb74b..2f375be367 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_platform_interface/pubspec.yaml @@ -16,6 +16,6 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 flutter: \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml index 03600f2e5a..224d7bf47f 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra_ui/flowy_infra_ui_web/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 flutter: plugin: 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 5525a3569d..15797453e6 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 @@ -411,8 +411,8 @@ class FlowyOverlayState extends State { if (style.blur) { child = BackdropFilter( - child: child, filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + child: child, ); } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart index b4d084acc0..643ddd94b1 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/src/keyboard/keyboard_visibility_detector.dart @@ -14,10 +14,12 @@ class KeyboardVisibilityDetector extends StatefulWidget { final void Function(bool)? onKeyboardVisibilityChange; @override - _KeyboardVisibilityDetectorState createState() => _KeyboardVisibilityDetectorState(); + State createState() => + _KeyboardVisibilityDetectorState(); } -class _KeyboardVisibilityDetectorState extends State { +class _KeyboardVisibilityDetectorState + extends State { FlowyInfraUIPlatform get _platform => FlowyInfraUIPlatform.instance; bool isObserving = false; @@ -27,7 +29,8 @@ class _KeyboardVisibilityDetectorState extends State @override void initState() { super.initState(); - _keyboardSubscription = _platform.onKeyboardVisibilityChange.listen((newValue) { + _keyboardSubscription = + _platform.onKeyboardVisibilityChange.listen((newValue) { setState(() { isKeyboardVisible = newValue; if (widget.onKeyboardVisibilityChange != null) { @@ -62,7 +65,8 @@ class _KeyboardVisibilityDetectorInheritedWidget extends InheritedWidget { final bool isKeyboardVisible; @override - bool updateShouldNotify(_KeyboardVisibilityDetectorInheritedWidget oldWidget) { + bool updateShouldNotify( + _KeyboardVisibilityDetectorInheritedWidget oldWidget) { return isKeyboardVisible != oldWidget.isKeyboardVisible; } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart index 4e26d9bd2d..fc91998c1f 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/container.dart @@ -31,7 +31,6 @@ class FlowyContainer extends StatelessWidget { return AnimatedContainer( width: width, height: height, - child: child, margin: margin, alignment: align, duration: duration ?? Durations.medium, @@ -39,6 +38,7 @@ class FlowyContainer extends StatelessWidget { color: color, borderRadius: borderRadius, boxShadow: shadows, - border: border)); + border: border), + child: child); } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart index 6938a2f209..00e0bb1b3a 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/hover.dart @@ -63,7 +63,7 @@ class _FlowyHoverState extends State { child: child, ); } else { - return Container(child: child, color: widget.style.backgroundColor); + return Container(color: widget.style.backgroundColor, child: child); } } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index f3ecd23005..0f56541516 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -57,7 +57,7 @@ class FlowyIconButton extends StatelessWidget { onPressed: onPressed, child: Padding( padding: iconPadding, - child: SizedBox.fromSize(child: child, size: childSize), + child: SizedBox.fromSize(size: childSize, child: child), ), ), ), diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart index bf086f756e..b57b4059cf 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/scrolling/styled_scrollview.dart @@ -27,10 +27,12 @@ class StyledSingleChildScrollView extends StatefulWidget { }) : super(key: key); @override - _StyledSingleChildScrollViewState createState() => _StyledSingleChildScrollViewState(); + State createState() => + StyledSingleChildScrollViewState(); } -class _StyledSingleChildScrollViewState extends State { +class StyledSingleChildScrollViewState + extends State { late ScrollController scrollController; @override @@ -92,10 +94,10 @@ class StyledCustomScrollView extends StatefulWidget { }) : super(key: key); @override - _StyledCustomScrollViewState createState() => _StyledCustomScrollViewState(); + StyledCustomScrollViewState createState() => StyledCustomScrollViewState(); } -class _StyledCustomScrollViewState extends State { +class StyledCustomScrollViewState extends State { late ScrollController controller; @override diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart index 74cf7e4c31..4b285a9137 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/style_widget/text.dart @@ -4,15 +4,16 @@ import 'package:provider/provider.dart'; class FlowyText extends StatelessWidget { final String title; - final TextOverflow overflow; + final TextOverflow? overflow; final double fontSize; final FontWeight fontWeight; final TextAlign? textAlign; final Color? color; + const FlowyText( this.title, { Key? key, - this.overflow = TextOverflow.ellipsis, + this.overflow = TextOverflow.clip, this.fontSize = 16, this.fontWeight = FontWeight.w400, this.textAlign, @@ -20,34 +21,33 @@ class FlowyText extends StatelessWidget { }) : super(key: key); const FlowyText.semibold(this.title, - {Key? key, this.fontSize = 16, TextOverflow? overflow, this.color, this.textAlign}) + {Key? key, this.fontSize = 16, this.overflow, this.color, this.textAlign}) : fontWeight = FontWeight.w600, - overflow = overflow ?? TextOverflow.ellipsis, super(key: key); - const FlowyText.medium(this.title, {Key? key, this.fontSize = 16, TextOverflow? overflow, this.color, this.textAlign}) + const FlowyText.medium(this.title, + {Key? key, this.fontSize = 16, this.overflow, this.color, this.textAlign}) : fontWeight = FontWeight.w500, - overflow = overflow ?? TextOverflow.ellipsis, super(key: key); const FlowyText.regular(this.title, - {Key? key, this.fontSize = 16, TextOverflow? overflow, this.color, this.textAlign}) + {Key? key, this.fontSize = 16, this.overflow, this.color, this.textAlign}) : fontWeight = FontWeight.w400, - overflow = overflow ?? TextOverflow.ellipsis, super(key: key); @override Widget build(BuildContext context) { final theme = context.watch(); - return Text(title, - overflow: overflow, - softWrap: false, - textAlign: textAlign, - style: TextStyle( - color: color ?? theme.textColor, - fontWeight: fontWeight, - fontSize: fontSize, - fontFamily: 'Mulish', - )); + return Text( + title, + textAlign: textAlign, + overflow: overflow ?? TextOverflow.clip, + style: TextStyle( + color: color ?? theme.textColor, + fontWeight: fontWeight, + fontSize: fontSize, + fontFamily: 'Mulish', + ), + ); } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart index 0c257f3d3a..d251f993fd 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/base_styled_button.dart @@ -45,10 +45,10 @@ class BaseStyledButton extends StatefulWidget { }) : super(key: key); @override - _BaseStyledBtnState createState() => _BaseStyledBtnState(); + State createState() => BaseStyledBtnState(); } -class _BaseStyledBtnState extends State { +class BaseStyledBtnState extends State { late FocusNode _focusNode; bool _isFocused = false; @@ -79,9 +79,16 @@ class _BaseStyledBtnState extends State { borderRadius: widget.borderRadius ?? Corners.s10Border, boxShadow: _isFocused ? [ - BoxShadow(color: theme.shader6, offset: Offset.zero, blurRadius: 8.0, spreadRadius: 0.0), BoxShadow( - color: widget.bgColor ?? theme.surface, offset: Offset.zero, blurRadius: 8.0, spreadRadius: -4.0), + color: theme.shader6, + offset: Offset.zero, + blurRadius: 8.0, + spreadRadius: 0.0), + BoxShadow( + color: widget.bgColor ?? theme.surface, + offset: Offset.zero, + blurRadius: 8.0, + spreadRadius: -4.0), ] : [], ), @@ -112,20 +119,21 @@ class _BaseStyledBtnState extends State { hoverColor: widget.hoverColor ?? theme.hover, highlightColor: widget.downColor ?? theme.main1, focusColor: widget.focusColor ?? Colors.grey.withOpacity(0.35), - child: Opacity( - child: Padding( - padding: widget.contentPadding ?? EdgeInsets.all(Insets.m), - child: widget.child, - ), - opacity: widget.onPressed != null ? 1 : .7, - ), - constraints: BoxConstraints(minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), + constraints: BoxConstraints( + minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), onPressed: widget.onPressed, shape: widget.shape ?? RoundedRectangleBorder( side: BorderSide(color: widget.outlineColor, width: 1.5), borderRadius: widget.borderRadius ?? Corners.s10Border, ), + child: Opacity( + opacity: widget.onPressed != null ? 1 : .7, + child: Padding( + padding: widget.contentPadding ?? EdgeInsets.all(Insets.m), + child: widget.child, + ), + ), ), ); } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart index 3cd1cdf81e..2c0725288c 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/primary_button.dart @@ -38,8 +38,8 @@ class PrimaryButton extends StatelessWidget { hoverColor: theme.main1, downColor: theme.main1, borderRadius: bigMode ? Corners.s12Border : Corners.s8Border, - child: child, onPressed: onPressed, + child: child, ); } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart index dedef61295..9e6f7d331d 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/buttons/secondary_button.dart @@ -41,8 +41,8 @@ class SecondaryButton extends StatelessWidget { downColor: theme.main1, outlineColor: theme.main1, borderRadius: bigMode ? Corners.s12Border : Corners.s8Border, - child: child, onPressed: onPressed, + child: child, ); } } 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 5db6afacef..ecd8cfb4ae 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 @@ -151,9 +151,9 @@ class StyledDialogRoute extends PopupRoute { Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return Semantics( - child: _pageBuilder(context, animation, secondaryAnimation), scopesRoute: true, explicitChildNodes: true, + child: _pageBuilder(context, animation, secondaryAnimation), ); } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart index 207775a275..81529ba16c 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/mouse_hover_builder.dart @@ -5,12 +5,14 @@ typedef HoverBuilder = Widget Function(BuildContext context, bool onHover); class MouseHoverBuilder extends StatefulWidget { final bool isClickable; - const MouseHoverBuilder({Key? key, required this.builder, this.isClickable = false}) : super(key: key); + const MouseHoverBuilder( + {Key? key, required this.builder, this.isClickable = false}) + : super(key: key); final HoverBuilder builder; @override - _MouseHoverBuilderState createState() => _MouseHoverBuilderState(); + State createState() => _MouseHoverBuilderState(); } class _MouseHoverBuilderState extends State { @@ -19,7 +21,9 @@ class _MouseHoverBuilderState extends State { @override Widget build(BuildContext context) { return MouseRegion( - cursor: widget.isClickable ? SystemMouseCursors.click : SystemMouseCursors.basic, + cursor: widget.isClickable + ? SystemMouseCursors.click + : SystemMouseCursors.basic, onEnter: (p) => setOnHover(true), onExit: (p) => setOnHover(false), child: widget.builder(context, _onHover), diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart index 4d5a79fe79..33075f703c 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/rounded_button.dart @@ -42,11 +42,11 @@ class RoundedTextButton extends StatelessWidget { ), child: SizedBox.expand( child: TextButton( + onPressed: onPressed, child: Text( title ?? '', style: TextStyle(color: textColor, fontSize: fontSize), ), - onPressed: onPressed, ), ), ), diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart index b4f53008d3..e0f328afc9 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/route/animation.dart @@ -52,10 +52,10 @@ class PageRoutes { pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), transitionsBuilder: (context, animation, secondaryAnimation, child) { return SharedAxisTransition( - child: child, animation: animation, secondaryAnimation: secondaryAnimation, transitionType: type, + child: child, ); }, ); diff --git a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart index f25a74426e..7362f989e5 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart +++ b/frontend/app_flowy/packages/flowy_infra_ui/lib/widget/seperated_column.dart @@ -31,13 +31,13 @@ class SeparatedColumn extends StatelessWidget { if (i > 0 && separatorBuilder != null) c.insert(i, separatorBuilder!()); } return Column( - children: c, mainAxisAlignment: mainAxisAlignment, crossAxisAlignment: crossAxisAlignment, mainAxisSize: mainAxisSize, textBaseline: textBaseline, textDirection: textDirection, verticalDirection: verticalDirection, + children: c, ); } } diff --git a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock index c24e86d22d..143279950d 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.lock @@ -110,14 +110,14 @@ packages: name: flutter_lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.1" flutter_svg: 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 @@ -148,7 +148,7 @@ packages: name: lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "2.0.0" loading_indicator: dependency: "direct main" description: @@ -197,21 +197,21 @@ 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" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "5.0.0" plugin_platform_interface: dependency: transitive description: @@ -321,7 +321,7 @@ packages: name: xml url: "https://pub.dartlang.org" source: hosted - version: "5.3.1" + version: "6.1.0" sdks: - dart: ">=2.17.0-0 <3.0.0" - flutter: ">=2.0.0" + dart: ">=2.17.0 <3.0.0" + flutter: ">=2.11.0-0.1.pre" diff --git a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml index e9473070a0..c95ba1119e 100644 --- a/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_infra_ui/pubspec.yaml @@ -2,7 +2,7 @@ name: flowy_infra_ui description: A new flutter plugin project. version: 0.0.1 homepage: -publish_to: 'none' +publish_to: "none" environment: sdk: ">=2.12.0 <3.0.0" @@ -13,13 +13,14 @@ dependencies: sdk: flutter # Thirdparty packages - textstyle_extensions: '2.0.0-nullsafety' - dartz: '0.10.0-nullsafety.2' + textstyle_extensions: "2.0.0-nullsafety" + dartz: provider: ^6.0.1 - styled_widget: '^0.3.1' - equatable: '^2.0.3' + styled_widget: "^0.3.1" + equatable: "^2.0.3" animations: ^2.0.0 loading_indicator: ^3.0.1 + async: # Federated Platform Interface flowy_infra_ui_platform_interface: @@ -34,7 +35,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 flutter: plugin: @@ -52,4 +53,4 @@ flutter: linux: pluginClass: FlowyInfraUIPlugin web: - default_package: flowy_infra_ui_web \ No newline at end of file + default_package: flowy_infra_ui_web diff --git a/frontend/app_flowy/packages/flowy_sdk/android/build.gradle b/frontend/app_flowy/packages/flowy_sdk/android/build.gradle index 52a1508a46..f28d45b3a1 100644 --- a/frontend/app_flowy/packages/flowy_sdk/android/build.gradle +++ b/frontend/app_flowy/packages/flowy_sdk/android/build.gradle @@ -2,7 +2,7 @@ group 'com.plugin.flowy_sdk' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.6.10' repositories { google() jcenter() @@ -25,7 +25,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 30 + compileSdkVersion 31 sourceSets { main.java.srcDirs += 'src/main/kotlin' diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle b/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle index cccedb641b..aa5daf1e9d 100644 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle +++ b/frontend/app_flowy/packages/flowy_sdk/example/android/app/build.gradle @@ -36,7 +36,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.plugin.flowy_sdk_example" minSdkVersion 16 - targetSdkVersion 30 + targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/frontend/app_flowy/packages/flowy_sdk/example/android/build.gradle b/frontend/app_flowy/packages/flowy_sdk/example/android/build.gradle index c505a86352..714549c265 100644 --- a/frontend/app_flowy/packages/flowy_sdk/example/android/build.gradle +++ b/frontend/app_flowy/packages/flowy_sdk/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.6.10' repositories { google() jcenter() diff --git a/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml index dbee825038..954a50e831 100644 --- a/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_sdk/example/pubspec.yaml @@ -29,7 +29,7 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart b/frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart index 0ade770a23..5ced5b2e83 100644 --- a/frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart +++ b/frontend/app_flowy/packages/flowy_sdk/lib/ffi.dart @@ -14,11 +14,14 @@ final DynamicLibrary dl = _dl; DynamicLibrary _open() { if (Platform.environment.containsKey('FLUTTER_TEST')) { final prefix = "${Directory.current.path}/.sandbox"; - if (Platform.isLinux) return DynamicLibrary.open('${prefix}/libdart_ffi.so'); - if (Platform.isAndroid) return DynamicLibrary.open('${prefix}/libdart_ffi.so'); + if (Platform.isLinux) + return DynamicLibrary.open('${prefix}/libdart_ffi.so'); + if (Platform.isAndroid) + return DynamicLibrary.open('${prefix}/libdart_ffi.so'); if (Platform.isMacOS) return DynamicLibrary.open('${prefix}/libdart_ffi.a'); if (Platform.isIOS) return DynamicLibrary.open('${prefix}/libdart_ffi.a'); - if (Platform.isWindows) return DynamicLibrary.open('${prefix}/dart_ffi.dll'); + if (Platform.isWindows) + return DynamicLibrary.open('${prefix}/dart_ffi.dll'); } else { if (Platform.isLinux) return DynamicLibrary.open('libdart_ffi.so'); if (Platform.isAndroid) return DynamicLibrary.open('libdart_ffi.so'); @@ -39,7 +42,8 @@ void async_event( _invoke_async(port, input, len); } -final _invoke_async_Dart _invoke_async = _dl.lookupFunction<_invoke_async_C, _invoke_async_Dart>('async_event'); +final _invoke_async_Dart _invoke_async = + _dl.lookupFunction<_invoke_async_C, _invoke_async_Dart>('async_event'); typedef _invoke_async_C = Void Function( Int64 port, Pointer input, @@ -59,7 +63,8 @@ Pointer sync_event( return _invoke_sync(input, len); } -final _invoke_sync_Dart _invoke_sync = _dl.lookupFunction<_invoke_sync_C, _invoke_sync_Dart>('sync_event'); +final _invoke_sync_Dart _invoke_sync = + _dl.lookupFunction<_invoke_sync_C, _invoke_sync_Dart>('sync_event'); typedef _invoke_sync_C = Pointer Function( Pointer input, Uint64 len, @@ -76,7 +81,8 @@ int init_sdk( return _init_sdk(path); } -final _init_sdk_Dart _init_sdk = _dl.lookupFunction<_init_sdk_C, _init_sdk_Dart>('init_sdk'); +final _init_sdk_Dart _init_sdk = + _dl.lookupFunction<_init_sdk_C, _init_sdk_Dart>('init_sdk'); typedef _init_sdk_C = Int64 Function( Pointer path, ); @@ -90,7 +96,8 @@ int set_stream_port(int port) { } final _set_stream_port_Dart _set_stream_port = - _dl.lookupFunction<_set_stream_port_C, _set_stream_port_Dart>('set_stream_port'); + _dl.lookupFunction<_set_stream_port_C, _set_stream_port_Dart>( + 'set_stream_port'); typedef _set_stream_port_C = Int32 Function( Int64 port, @@ -104,8 +111,8 @@ void link_me_please() { _link_me_please(); } -final _link_me_please_Dart _link_me_please = - _dl.lookupFunction<_link_me_please_C, _link_me_please_Dart>('link_me_please'); +final _link_me_please_Dart _link_me_please = _dl + .lookupFunction<_link_me_please_C, _link_me_please_Dart>('link_me_please'); typedef _link_me_please_C = Void Function(); typedef _link_me_please_Dart = void Function(); @@ -116,8 +123,9 @@ void store_dart_post_cobject( _store_dart_post_cobject(ptr); } -final _store_dart_post_cobject_Dart _store_dart_post_cobject = - _dl.lookupFunction<_store_dart_post_cobject_C, _store_dart_post_cobject_Dart>('store_dart_post_cobject'); +final _store_dart_post_cobject_Dart _store_dart_post_cobject = _dl + .lookupFunction<_store_dart_post_cobject_C, _store_dart_post_cobject_Dart>( + 'store_dart_post_cobject'); typedef _store_dart_post_cobject_C = Void Function( Pointer)>> ptr, ); diff --git a/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml b/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml index 95ca835649..186381d848 100644 --- a/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_sdk/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ffi: ^1.0.0 isolates: ^3.0.3+8 protobuf: "2.0.0" - dartz: "0.10.0-nullsafety.2" + dartz: ^0.10.1 freezed_annotation: logger: ^1.0.0 @@ -23,7 +23,7 @@ dev_dependencies: sdk: flutter build_runner: freezed: - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/app_flowy/pubspec.lock b/frontend/app_flowy/pubspec.lock index 4717151e92..76d489c915 100644 --- a/frontend/app_flowy/pubspec.lock +++ b/frontend/app_flowy/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "44.0.0" + version: "46.0.0" analyzer: dependency: "direct overridden" description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "4.4.0" + version: "4.6.0" animations: dependency: transitive description: @@ -28,14 +28,14 @@ packages: path: "packages/appflowy_board" relative: true source: path - version: "0.0.5" + version: "0.0.6" appflowy_editor: dependency: "direct main" description: path: "packages/appflowy_editor" relative: true source: path - version: "0.0.2" + version: "0.0.3" appflowy_popover: dependency: "direct main" description: @@ -58,12 +58,12 @@ packages: source: hosted version: "2.8.2" bloc: - dependency: transitive + dependency: "direct main" description: name: bloc url: "https://pub.dartlang.org" source: hosted - version: "8.0.3" + version: "8.1.0" bloc_test: dependency: "direct dev" description: @@ -91,7 +91,7 @@ packages: name: build_config url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.1.0" build_daemon: dependency: transitive description: @@ -105,14 +105,14 @@ packages: name: build_resolvers url: "https://pub.dartlang.org" source: hosted - version: "2.0.8" + version: "2.0.9" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.1.11" + version: "2.2.0" build_runner_core: dependency: transitive description: @@ -142,7 +142,7 @@ packages: source: hosted version: "1.2.0" charcode: - dependency: transitive + dependency: "direct main" description: name: charcode url: "https://pub.dartlang.org" @@ -177,7 +177,7 @@ packages: source: hosted version: "4.1.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection url: "https://pub.dartlang.org" @@ -189,7 +189,7 @@ packages: name: connectivity_plus url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.3.6+1" connectivity_plus_linux: dependency: transitive description: @@ -203,7 +203,7 @@ packages: name: connectivity_plus_macos url: "https://pub.dartlang.org" source: hosted - version: "1.2.2" + version: "1.2.4" connectivity_plus_platform_interface: dependency: transitive description: @@ -217,14 +217,14 @@ packages: name: connectivity_plus_web url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.2.3" connectivity_plus_windows: dependency: transitive description: name: connectivity_plus_windows url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.2.2" convert: dependency: transitive description: @@ -275,12 +275,12 @@ packages: source: hosted version: "2.2.3" dartz: - dependency: transitive + dependency: "direct main" description: name: dartz url: "https://pub.dartlang.org" source: hosted - version: "0.10.0-nullsafety.2" + version: "0.10.1" dbus: dependency: transitive description: @@ -387,7 +387,7 @@ packages: source: hosted version: "6.1.2" fixnum: - dependency: transitive + dependency: "direct main" description: name: fixnum url: "https://pub.dartlang.org" @@ -481,7 +481,7 @@ packages: name: flutter_lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.1" flutter_localizations: dependency: "direct main" description: flutter @@ -533,14 +533,14 @@ packages: name: freezed url: "https://pub.dartlang.org" source: hosted - version: "2.0.3+1" + version: "2.1.0+1" freezed_annotation: dependency: "direct main" description: name: freezed_annotation url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.1.0" frontend_server_client: dependency: transitive description: @@ -576,6 +576,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + hotkey_manager: + dependency: "direct main" + description: + name: hotkey_manager + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.7" html: dependency: transitive description: @@ -701,7 +708,7 @@ packages: name: lints url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "2.0.0" loading_indicator: dependency: transitive description: @@ -948,7 +955,7 @@ packages: source: hosted version: "4.2.4" protobuf: - dependency: transitive + dependency: "direct main" description: name: protobuf url: "https://pub.dartlang.org" @@ -1046,7 +1053,7 @@ packages: source: hosted version: "1.0.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences url: "https://pub.dartlang.org" @@ -1268,7 +1275,7 @@ packages: source: hosted version: "2.0.0+1" textstyle_extensions: - dependency: transitive + dependency: "direct main" description: name: textstyle_extensions url: "https://pub.dartlang.org" @@ -1289,7 +1296,7 @@ packages: source: hosted version: "1.0.0" tuple: - dependency: transitive + dependency: "direct main" description: name: tuple url: "https://pub.dartlang.org" diff --git a/frontend/app_flowy/pubspec.yaml b/frontend/app_flowy/pubspec.yaml index 1b2129f306..cfa3e13872 100644 --- a/frontend/app_flowy/pubspec.yaml +++ b/frontend/app_flowy/pubspec.yaml @@ -52,9 +52,10 @@ dependencies: intl: ^0.17.0 time: "^2.0.0" equatable: "^2.0.3" - freezed_annotation: + freezed_annotation: ^2.1.0 get_it: "^7.1.3" flutter_bloc: "^8.0.1" + dartz: ^0.10.1 provider: ^6.0.1 path_provider: ^2.0.1 window_size: @@ -70,7 +71,7 @@ dependencies: url_launcher: ^6.0.2 # file_picker: ^4.2.1 clipboard: ^0.1.3 - connectivity_plus: 2.2.0 + connectivity_plus: ^2.3.6+1 easy_localization: ^3.0.0 textfield_tags: ^2.0.0 # The following adds the Cupertino Icons font to your application. @@ -81,13 +82,23 @@ dependencies: table_calendar: ^3.0.5 reorderables: ^0.5.0 linked_scroll_controller: ^0.2.0 + hotkey_manager: ^0.1.7 + fixnum: ^1.0.1 + tuple: ^2.0.0 + protobuf: "2.0.0" + charcode: ^1.3.1 + collection: ^1.16.0 + bloc: ^8.1.0 + textstyle_extensions: "2.0.0-nullsafety" + shared_preferences: ^2.0.15 dev_dependencies: - flutter_lints: ^1.0.0 + flutter_lints: ^2.0.1 + flutter_test: sdk: flutter - build_runner: - freezed: + build_runner: ^2.2.0 + freezed: ^2.1.0+1 bloc_test: ^9.0.2 dependency_overrides: diff --git a/frontend/rust-lib/.cargo/config.toml b/frontend/rust-lib/.cargo/config.toml index 06a85fae37..5be8246f16 100644 --- a/frontend/rust-lib/.cargo/config.toml +++ b/frontend/rust-lib/.cargo/config.toml @@ -5,4 +5,20 @@ rustflags=["-C", "link-arg=-mmacosx-version-min=10.11"] [target.aarch64-apple-darwin] -rustflags=["-C", "link-arg=-mmacosx-version-min=10.11"] \ No newline at end of file +rustflags=["-C", "link-arg=-mmacosx-version-min=10.11"] + +[target.aarch64-linux-android] +ar = "path-to-ndk/llvm-ar" +linker = "path-to-ndk/aarch64-linux-android29-clang" + +[target.armv7-linux-androideabi] +ar = "path-to-ndk/llvm-ar" +linker = "path-to-ndk/armv7a-linux-androideabi29-clang" + +[target.i686-linux-android] +ar = "path-to-ndk/llvm-ar" +linker = "path-to-ndk/i686-linux-android29-clang" + +[target.x86_64-linux-android] +ar = "path-to-ndk/llvm-ar" +linker = "path-to-ndk/x86_64-linux-android29-clang" \ No newline at end of file diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 4119cc1edf..c2d3fa7339 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1796,6 +1796,7 @@ dependencies = [ "lazy_static", "libsqlite3-sys", "log", + "openssl", "r2d2", "scheduled-thread-pool", ] @@ -2096,6 +2097,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-src" +version = "111.22.0+1.1.1q" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f31f0d509d1c1ae9cada2f9539ff8f37933831fd5098879e482aa687d659853" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.72" @@ -2105,6 +2115,7 @@ dependencies = [ "autocfg", "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] diff --git a/frontend/rust-lib/dart-ffi/Cargo.toml b/frontend/rust-lib/dart-ffi/Cargo.toml index 44fd254cbb..117d78c064 100644 --- a/frontend/rust-lib/dart-ffi/Cargo.toml +++ b/frontend/rust-lib/dart-ffi/Cargo.toml @@ -38,4 +38,4 @@ http_sync = ["flowy-sdk/http_sync", "flowy-sdk/use_bunyan"] #use_protobuf= ["protobuf"] [build-dependencies] -lib-infra = { path = "../../../shared-lib/lib-infra", features = ["protobuf_file_gen", "dart"] } \ No newline at end of file +lib-infra = { path = "../../../shared-lib/lib-infra", features = ["protobuf_file_gen", "dart"] } 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 bb7eec9032..6e271a0fb2 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/block_entities.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/block_entities.rs @@ -30,7 +30,7 @@ impl BlockPB { } /// [RowPB] Describes a row. Has the id of the parent Block. Has the metadata of the row. -#[derive(Debug, Default, Clone, ProtoBuf)] +#[derive(Debug, Default, Clone, ProtoBuf, Eq, PartialEq)] pub struct RowPB { #[pb(index = 1)] pub block_id: String, @@ -120,17 +120,28 @@ pub struct InsertedRowPB { #[pb(index = 2, one_of)] pub index: Option, + + #[pb(index = 3)] + pub is_new: bool, } impl InsertedRowPB { pub fn new(row: RowPB) -> Self { - Self { row, index: None } + Self { + row, + index: None, + is_new: false, + } } } impl std::convert::From for InsertedRowPB { fn from(row: RowPB) -> Self { - Self { row, index: None } + Self { + row, + index: None, + is_new: false, + } } } 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 index cb5503727b..19f5b27a9b 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/configuration.rs @@ -1,5 +1,5 @@ use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; -use flowy_grid_data_model::revision::{GroupRecordRevision, SelectOptionGroupConfigurationRevision}; +use flowy_grid_data_model::revision::{GroupRevision, SelectOptionGroupConfigurationRevision}; #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] pub struct UrlGroupConfigurationPB { @@ -36,10 +36,10 @@ pub struct GroupRecordPB { visible: bool, } -impl std::convert::From for GroupRecordPB { - fn from(rev: GroupRecordRevision) -> Self { +impl std::convert::From for GroupRecordPB { + fn from(rev: GroupRevision) -> Self { Self { - group_id: rev.group_id, + group_id: rev.id, visible: rev.visible, } } 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 index b769b18154..002cb73c6d 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group.rs @@ -1,4 +1,5 @@ use crate::entities::{CreateRowParams, FieldType, GridLayout, RowPB}; +use crate::services::group::Group; use flowy_derive::ProtoBuf; use flowy_error::ErrorCode; use flowy_grid_data_model::parser::NotEmptyStr; @@ -13,6 +14,9 @@ pub struct CreateBoardCardPayloadPB { #[pb(index = 2)] pub group_id: String, + + #[pb(index = 3, one_of)] + pub start_row_id: Option, } impl TryInto for CreateBoardCardPayloadPB { @@ -21,9 +25,13 @@ impl TryInto for CreateBoardCardPayloadPB { 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)?; + let start_row_id = match self.start_row_id { + None => None, + Some(start_row_id) => Some(NotEmptyStr::parse(start_row_id).map_err(|_| ErrorCode::RowIdIsEmpty)?.0), + }; Ok(CreateRowParams { grid_id: grid_id.0, - start_row_id: None, + start_row_id, group_id: Some(group_id.0), layout: GridLayout::Board, }) @@ -80,6 +88,21 @@ pub struct GroupPB { #[pb(index = 4)] pub rows: Vec, + + #[pb(index = 5)] + pub is_default: bool, +} + +impl std::convert::From for GroupPB { + fn from(group: Group) -> Self { + Self { + field_id: group.field_id, + group_id: group.id, + desc: group.name, + rows: group.rows, + is_default: group.is_default, + } + } } #[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] diff --git a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs index a3ebee9cb7..21f39775f6 100644 --- a/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs +++ b/frontend/rust-lib/flowy-grid/src/entities/group_entities/group_changeset.rs @@ -5,21 +5,24 @@ use flowy_grid_data_model::parser::NotEmptyStr; use std::fmt::Formatter; #[derive(Debug, Default, ProtoBuf)] -pub struct GroupRowsChangesetPB { +pub struct GroupChangesetPB { #[pb(index = 1)] pub group_id: String, - #[pb(index = 2)] - pub inserted_rows: Vec, + #[pb(index = 2, one_of)] + pub group_name: Option, #[pb(index = 3)] - pub deleted_rows: Vec, + pub inserted_rows: Vec, #[pb(index = 4)] + pub deleted_rows: Vec, + + #[pb(index = 5)] pub updated_rows: Vec, } -impl std::fmt::Display for GroupRowsChangesetPB { +impl std::fmt::Display for GroupChangesetPB { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { for inserted_row in &self.inserted_rows { let _ = f.write_fmt(format_args!( @@ -36,10 +39,29 @@ impl std::fmt::Display for GroupRowsChangesetPB { } } -impl GroupRowsChangesetPB { +impl GroupChangesetPB { pub fn is_empty(&self) -> bool { - self.inserted_rows.is_empty() && self.deleted_rows.is_empty() && self.updated_rows.is_empty() + self.group_name.is_none() + && self.inserted_rows.is_empty() + && self.deleted_rows.is_empty() + && self.updated_rows.is_empty() } + + pub fn new(group_id: String) -> Self { + Self { + group_id, + ..Default::default() + } + } + + pub fn name(group_id: String, name: &str) -> Self { + Self { + group_id, + group_name: Some(name.to_owned()), + ..Default::default() + } + } + pub fn insert(group_id: String, inserted_rows: Vec) -> Self { Self { group_id, @@ -113,9 +135,16 @@ pub struct GroupViewChangesetPB { #[pb(index = 3)] pub deleted_groups: Vec, + + #[pb(index = 4)] + pub update_groups: Vec, } -impl GroupViewChangesetPB {} +impl GroupViewChangesetPB { + pub fn is_empty(&self) -> bool { + self.inserted_groups.is_empty() && self.deleted_groups.is_empty() && self.update_groups.is_empty() + } +} #[derive(Debug, Default, ProtoBuf)] pub struct InsertedGroupPB { diff --git a/frontend/rust-lib/flowy-grid/src/event_handler.rs b/frontend/rust-lib/flowy-grid/src/event_handler.rs index a9060aa87e..4b525c233f 100644 --- a/frontend/rust-lib/flowy-grid/src/event_handler.rs +++ b/frontend/rust-lib/flowy-grid/src/event_handler.rs @@ -269,7 +269,7 @@ pub(crate) async fn create_table_row_handler( data_result(row) } -// #[tracing::instrument(level = "debug", skip_all, err)] +#[tracing::instrument(level = "trace", skip_all, err)] pub(crate) async fn get_cell_handler( data: Data, manager: AppData>, 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 1bc9b9dc42..9619bf52ab 100644 --- a/frontend/rust-lib/flowy-grid/src/services/block_manager.rs +++ b/frontend/rust-lib/flowy-grid/src/services/block_manager.rs @@ -164,6 +164,7 @@ impl GridBlockManager { let insert_row = InsertedRowPB { index: Some(to as i32), row: make_row_from_row_rev(row_rev), + is_new: false, }; let notified_changeset = GridBlockChangesetPB { 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 294aff9885..ecff3b951b 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 @@ -176,6 +176,12 @@ pub fn insert_select_option_cell(option_id: String, field_rev: &FieldRevision) - CellRevision::new(data) } +pub fn delete_select_option_cell(option_id: String, field_rev: &FieldRevision) -> CellRevision { + let cell_data = SelectOptionCellChangeset::from_delete(&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/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 13da2e8359..dcec7772a7 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 @@ -154,9 +154,14 @@ pub fn select_option_color_from_index(index: usize) -> SelectOptionColorPB { _ => SelectOptionColorPB::Purple, } } + +#[derive(Default)] pub struct SelectOptionIds(Vec); impl SelectOptionIds { + pub fn new() -> Self { + Self::default() + } pub fn into_inner(self) -> Vec { self.0 } @@ -181,6 +186,12 @@ impl std::convert::From for SelectOptionIds { } } +impl ToString for SelectOptionIds { + fn to_string(&self) -> String { + self.0.join(SELECTION_IDS_SEPARATOR) + } +} + impl std::convert::From> for SelectOptionIds { fn from(s: Option) -> Self { match s { 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 485bf6930c..9ae7918955 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_editor.rs @@ -18,7 +18,7 @@ use flowy_sync::client_grid::{GridRevisionChangeset, GridRevisionPad, JsonDeseri use flowy_sync::entities::revision::Revision; use flowy_sync::errors::CollaborateResult; use flowy_sync::util::make_text_delta_from_revisions; -use lib_infra::future::FutureResult; +use lib_infra::future::{wrap_future, FutureResult}; use std::collections::HashMap; use std::sync::Arc; @@ -188,8 +188,13 @@ impl GridRevisionEditor { pub async fn replace_field(&self, field_rev: Arc) -> FlowyResult<()> { let field_id = field_rev.id.clone(); let _ = self - .modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev)?)) + .modify(|grid_pad| Ok(grid_pad.replace_field_rev(field_rev.clone())?)) .await?; + + match self.view_manager.did_update_field(&field_rev.id).await { + Ok(_) => {} + Err(e) => tracing::error!("View manager update field failed: {:?}", e), + } let _ = self.notify_did_update_grid_field(&field_id).await?; Ok(()) } @@ -263,59 +268,65 @@ impl GridRevisionEditor { } async fn update_field_rev(&self, params: FieldChangesetParams, field_type: FieldType) -> FlowyResult<()> { - self.modify(|grid| { - let deserializer = TypeOptionJsonDeserializer(field_type); + let _ = self + .modify(|grid| { + let deserializer = TypeOptionJsonDeserializer(field_type); + let changeset = grid.modify_field(¶ms.field_id, |field| { + let mut is_changed = None; + if let Some(name) = params.name { + field.name = name; + is_changed = Some(()) + } - let changeset = grid.modify_field(¶ms.field_id, |field| { - let mut is_changed = None; - if let Some(name) = params.name { - field.name = name; - is_changed = Some(()) - } + if let Some(desc) = params.desc { + field.desc = desc; + is_changed = Some(()) + } - if let Some(desc) = params.desc { - field.desc = desc; - is_changed = Some(()) - } + if let Some(field_type) = params.field_type { + field.ty = field_type; + is_changed = Some(()) + } - if let Some(field_type) = params.field_type { - field.ty = field_type; - is_changed = Some(()) - } + if let Some(frozen) = params.frozen { + field.frozen = frozen; + is_changed = Some(()) + } - if let Some(frozen) = params.frozen { - field.frozen = frozen; - is_changed = Some(()) - } + if let Some(visibility) = params.visibility { + field.visibility = visibility; + is_changed = Some(()) + } - if let Some(visibility) = params.visibility { - field.visibility = visibility; - is_changed = Some(()) - } + if let Some(width) = params.width { + field.width = width; + is_changed = Some(()) + } - if let Some(width) = params.width { - field.width = width; - is_changed = Some(()) - } - - if let Some(type_option_data) = params.type_option_data { - match deserializer.deserialize(type_option_data) { - Ok(json_str) => { - let field_type = field.ty; - field.insert_type_option_str(&field_type, json_str); - is_changed = Some(()) - } - Err(err) => { - tracing::error!("Deserialize data to type option json failed: {}", err); + if let Some(type_option_data) = params.type_option_data { + match deserializer.deserialize(type_option_data) { + Ok(json_str) => { + let field_type = field.ty; + 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) - })?; - Ok(changeset) - }) - .await + Ok(is_changed) + })?; + Ok(changeset) + }) + .await?; + + match self.view_manager.did_update_field(¶ms.field_id).await { + Ok(_) => {} + Err(e) => tracing::error!("View manager update field failed: {:?}", e), + } + Ok(()) } pub async fn create_block(&self, block_meta_rev: GridBlockMetaRevision) -> FlowyResult<()> { @@ -571,7 +582,7 @@ impl GridRevisionEditor { pub async fn move_group_row(&self, params: MoveGroupRowParams) -> FlowyResult<()> { let MoveGroupRowParams { - view_id: _, + view_id, from_row_id, to_group_id, to_row_id, @@ -580,21 +591,33 @@ impl GridRevisionEditor { 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) => { - if let Some(row_changeset) = self - .view_manager - .move_group_row(row_rev, to_group_id, to_row_id.clone()) - .await - { - match self.block_manager.update_row(row_changeset).await { - Ok(_) => {} - Err(e) => { - tracing::error!("Apply row changeset error:{:?}", e); - } - } - } + let block_manager = self.block_manager.clone(); + self.view_manager + .move_group_row(row_rev, to_group_id, to_row_id.clone(), |row_changeset| { + wrap_future(async move { + tracing::trace!("Move group row cause row data changed: {:?}", row_changeset); + let cell_changesets = row_changeset + .cell_by_field_id + .into_iter() + .map(|(field_id, cell_rev)| CellChangesetPB { + grid_id: view_id.clone(), + row_id: row_changeset.row_id.clone(), + field_id, + content: cell_rev.data, + }) + .collect::>(); + + for cell_changeset in cell_changesets { + match block_manager.update_cell(cell_changeset).await { + Ok(_) => {} + Err(e) => tracing::error!("Apply cell changeset error:{:?}", e), + } + } + }) + }) + .await?; } } - Ok(()) } diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs b/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs index 4e23d9be19..688a844707 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_view_editor.rs @@ -1,8 +1,8 @@ use crate::dart_notification::{send_dart_notification, GridNotification}; use crate::entities::{ CreateFilterParams, CreateRowParams, DeleteFilterParams, GridFilterConfiguration, GridLayout, GridLayoutPB, - GridSettingPB, GroupPB, GroupRowsChangesetPB, GroupViewChangesetPB, InsertedGroupPB, InsertedRowPB, - MoveGroupParams, RepeatedGridConfigurationFilterPB, RepeatedGridGroupConfigurationPB, RowPB, + GridSettingPB, GroupChangesetPB, GroupPB, GroupViewChangesetPB, InsertedGroupPB, InsertedRowPB, MoveGroupParams, + RepeatedGridConfigurationFilterPB, RepeatedGridGroupConfigurationPB, RowPB, }; use crate::services::grid_editor_task::GridServiceTaskScheduler; use crate::services::grid_view_manager::{GridViewFieldDelegate, GridViewRowDelegate}; @@ -59,7 +59,7 @@ impl GridViewRevisionEditor { rev_manager: rev_manager.clone(), view_pad: pad.clone(), }; - let group_service = GroupService::new(configuration_reader, configuration_writer).await; + let group_service = GroupService::new(view_id.clone(), configuration_reader, configuration_writer).await; let user_id = user_id.to_owned(); let did_load_group = AtomicBool::new(false); Ok(Self { @@ -95,12 +95,17 @@ impl GridViewRevisionEditor { match params.group_id.as_ref() { None => {} Some(group_id) => { + let index = match params.start_row_id { + None => Some(0), + Some(_) => None, + }; let inserted_row = InsertedRowPB { row: row_pb.clone(), - index: None, + index, + is_new: true, }; - let changeset = GroupRowsChangesetPB::insert(group_id.clone(), vec![inserted_row]); - self.notify_did_update_group_rows(changeset).await; + let changeset = GroupChangesetPB::insert(group_id.clone(), vec![inserted_row]); + self.notify_did_update_group(changeset).await; } } } @@ -115,7 +120,7 @@ impl GridViewRevisionEditor { .await { for changeset in changesets { - self.notify_did_update_group_rows(changeset).await; + self.notify_did_update_group(changeset).await; } } } @@ -129,7 +134,7 @@ impl GridViewRevisionEditor { .await { for changeset in changesets { - self.notify_did_update_group_rows(changeset).await; + self.notify_did_update_group(changeset).await; } } } @@ -140,8 +145,8 @@ impl GridViewRevisionEditor { row_changeset: &mut RowChangeset, to_group_id: &str, to_row_id: Option, - ) { - if let Some(changesets) = self + ) -> Vec { + match self .group_service .write() .await @@ -150,12 +155,11 @@ impl GridViewRevisionEditor { }) .await { - for changeset in changesets { - self.notify_did_update_group_rows(changeset).await; - } + None => vec![], + Some(changesets) => changesets, } } - + /// Only call once after grid view editor initialized #[tracing::instrument(level = "trace", skip(self))] pub(crate) async fn load_groups(&self) -> FlowyResult> { let groups = if !self.did_load_group.load(Ordering::SeqCst) { @@ -198,9 +202,10 @@ impl GridViewRevisionEditor { }; let changeset = GroupViewChangesetPB { - view_id: "".to_string(), + view_id: self.view_id.clone(), inserted_groups: vec![inserted_group], deleted_groups: vec![params.from_group_id.clone()], + update_groups: vec![], }; self.notify_did_update_view(changeset).await; @@ -252,8 +257,20 @@ impl GridViewRevisionEditor { }) .await } + #[tracing::instrument(level = "trace", skip_all, err)] + pub(crate) async fn did_update_field(&self, field_id: &str) -> FlowyResult<()> { + if let Some(field_rev) = self.field_delegate.get_field_rev(field_id).await { + match self.group_service.write().await.did_update_field(&field_rev).await? { + None => {} + Some(changeset) => { + self.notify_did_update_view(changeset).await; + } + } + } + Ok(()) + } - async fn notify_did_update_group_rows(&self, changeset: GroupRowsChangesetPB) { + pub async fn notify_did_update_group(&self, changeset: GroupChangesetPB) { send_dart_notification(&changeset.group_id, GridNotification::DidUpdateGroup) .payload(changeset) .send(); @@ -265,7 +282,6 @@ impl GridViewRevisionEditor { .send(); } - #[allow(dead_code)] async fn modify(&self, f: F) -> FlowyResult<()> where F: for<'a> FnOnce(&'a mut GridViewRevisionPad) -> FlowyResult>, diff --git a/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs b/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs index b0e578f804..0444c52763 100644 --- a/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs +++ b/frontend/rust-lib/flowy-grid/src/services/grid_view_manager.rs @@ -134,19 +134,29 @@ impl GridViewManager { row_rev: Arc, to_group_id: String, to_row_id: Option, - ) -> Option { + recv_row_changeset: impl FnOnce(RowChangeset) -> AFFuture<()>, + ) -> FlowyResult<()> { let mut row_changeset = RowChangeset::new(row_rev.id.clone()); - for view_editor in self.view_editors.iter() { - view_editor - .move_group_row(&row_rev, &mut row_changeset, &to_group_id, to_row_id.clone()) - .await; + let view_editor = self.get_default_view_editor().await?; + let group_changesets = view_editor + .move_group_row(&row_rev, &mut row_changeset, &to_group_id, to_row_id.clone()) + .await; + + if !row_changeset.is_empty() { + recv_row_changeset(row_changeset).await; } - if row_changeset.has_changed() { - Some(row_changeset) - } else { - None + for group_changeset in group_changesets { + view_editor.notify_did_update_group(group_changeset).await; } + + Ok(()) + } + + pub(crate) async fn did_update_field(&self, field_id: &str) -> FlowyResult<()> { + let view_editor = self.get_default_view_editor().await?; + let _ = view_editor.did_update_field(field_id).await?; + Ok(()) } pub(crate) async fn get_view_editor(&self, view_id: &str) -> FlowyResult> { diff --git a/frontend/rust-lib/flowy-grid/src/services/group/action.rs b/frontend/rust-lib/flowy-grid/src/services/group/action.rs index 29dc51cc37..d19be8395e 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/action.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/action.rs @@ -1,4 +1,4 @@ -use crate::entities::GroupRowsChangesetPB; +use crate::entities::GroupChangesetPB; use crate::services::group::controller::MoveGroupRowContext; use flowy_grid_data_model::revision::RowRevision; @@ -6,12 +6,8 @@ use flowy_grid_data_model::revision::RowRevision; pub trait GroupAction: 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 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(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec; + fn move_row(&mut self, cell_data: &Self::CellDataType, context: MoveGroupRowContext) -> Vec; } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs b/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs index a462278d2b..3fb5cefaa3 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/configuration.rs @@ -1,12 +1,13 @@ +use crate::entities::{GroupPB, GroupViewChangesetPB, InsertedGroupPB}; use crate::services::group::{default_group_configuration, Group}; use flowy_error::{FlowyError, FlowyResult}; use flowy_grid_data_model::revision::{ - FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRecordRevision, + FieldRevision, FieldTypeRevision, GroupConfigurationContentSerde, GroupConfigurationRevision, GroupRevision, }; -use std::marker::PhantomData; - use indexmap::IndexMap; use lib_infra::future::AFFuture; +use std::fmt::Formatter; +use std::marker::PhantomData; use std::sync::Arc; pub trait GroupConfigurationReader: Send + Sync + 'static { @@ -25,11 +26,27 @@ pub trait GroupConfigurationWriter: Send + Sync + 'static { ) -> AFFuture>; } +impl std::fmt::Display for GenericGroupConfiguration { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.groups_map.iter().for_each(|(_, group)| { + let _ = f.write_fmt(format_args!("Group:{} has {} rows \n", group.id, group.rows.len())); + }); + let _ = f.write_fmt(format_args!( + "Default group has {} rows \n", + self.default_group.rows.len() + )); + Ok(()) + } +} + pub struct GenericGroupConfiguration { + view_id: String, pub configuration: Arc, configuration_content: PhantomData, field_rev: Arc, groups_map: IndexMap, + /// default_group is used to store the rows that don't belong to any groups. + default_group: Group, writer: Arc, } @@ -39,10 +56,20 @@ where { #[tracing::instrument(level = "trace", skip_all, err)] pub async fn new( + view_id: String, field_rev: Arc, reader: Arc, writer: Arc, ) -> FlowyResult { + let default_group_id = format!("{}_default_group", view_id); + let default_group = Group { + id: default_group_id, + field_id: field_rev.id.clone(), + name: format!("No {}", field_rev.name), + is_default: true, + rows: vec![], + content: "".to_string(), + }; let configuration = match reader.get_group_configuration(field_rev.clone()).await { None => { let default_group_configuration = default_group_configuration(&field_rev); @@ -56,59 +83,35 @@ where // let configuration = C::from_configuration_content(&configuration_rev.content)?; Ok(Self { + view_id, field_rev, groups_map: IndexMap::new(), + default_group, writer, configuration, configuration_content: PhantomData, }) } - pub(crate) fn groups(&self) -> Vec<&Group> { + /// Returns the groups without the default group + pub(crate) fn concrete_groups(&self) -> Vec<&Group> { self.groups_map.values().collect() } + /// Returns the all the groups that contain the default group. pub(crate) fn clone_groups(&self) -> Vec { - self.groups_map.values().cloned().collect() + let mut groups: Vec = self.groups_map.values().cloned().collect(); + groups.push(self.default_group.clone()); + groups } - pub(crate) async fn merge_groups(&mut self, groups: Vec) -> FlowyResult<()> { - let (group_revs, groups) = merge_groups(&self.configuration.groups, groups); - self.mut_configuration(move |configuration| { - configuration.groups = group_revs; - true - })?; - - groups.into_iter().for_each(|group| { - self.groups_map.insert(group.id.clone(), group); - }); - Ok(()) - } - - #[allow(dead_code)] - pub(crate) async fn hide_group(&mut self, group_id: &str) -> FlowyResult<()> { - self.mut_configuration_group(group_id, |group_rev| { - group_rev.visible = false; - })?; - Ok(()) - } - - #[allow(dead_code)] - pub(crate) async fn show_group(&mut self, group_id: &str) -> FlowyResult<()> { - self.mut_configuration_group(group_id, |group_rev| { - group_rev.visible = true; - })?; - Ok(()) - } - - pub(crate) fn with_mut_groups(&mut self, mut each: impl FnMut(&mut Group)) { + /// Iterate mut the groups. The default group will be the last one that get mutated. + pub(crate) fn iter_mut_groups(&mut self, mut each: impl FnMut(&mut Group)) { self.groups_map.iter_mut().for_each(|(_, group)| { each(group); - }) - } + }); - pub(crate) fn get_mut_group(&mut self, group_id: &str) -> Option<&mut Group> { - self.groups_map.get_mut(group_id) + each(&mut self.default_group); } pub(crate) fn move_group(&mut self, from_id: &str, to_id: &str) -> FlowyResult<()> { @@ -117,10 +120,9 @@ where match (from_index, to_index) { (Some(from_index), Some(to_index)) => { self.groups_map.swap_indices(from_index, to_index); - self.mut_configuration(|configuration| { - let from_index = configuration.groups.iter().position(|group| group.group_id == from_id); - let to_index = configuration.groups.iter().position(|group| group.group_id == to_id); + let from_index = configuration.groups.iter().position(|group| group.id == from_id); + let to_index = configuration.groups.iter().position(|group| group.id == to_id); if let (Some(from), Some(to)) = (from_index, to_index) { configuration.groups.swap(from, to); } @@ -132,6 +134,79 @@ where } } + pub(crate) fn merge_groups(&mut self, groups: Vec) -> FlowyResult> { + let MergeGroupResult { + groups, + inserted_groups, + updated_groups, + } = merge_groups(&self.configuration.groups, groups); + + let group_revs = groups + .iter() + .map(|group| GroupRevision::new(group.id.clone(), group.name.clone())) + .collect::>(); + + self.mut_configuration(move |configuration| { + let mut is_changed = false; + for new_group_rev in group_revs { + match configuration + .groups + .iter() + .position(|group_rev| group_rev.id == new_group_rev.id) + { + None => { + configuration.groups.push(new_group_rev); + is_changed = true; + } + Some(pos) => { + let removed_group = configuration.groups.remove(pos); + if removed_group != new_group_rev { + is_changed = true; + } + configuration.groups.insert(pos, new_group_rev); + } + } + } + is_changed + })?; + + groups.into_iter().for_each(|group| { + self.groups_map.insert(group.id.clone(), group); + }); + + let changeset = make_group_view_changeset(self.view_id.clone(), inserted_groups, updated_groups); + tracing::trace!("Group changeset: {:?}", changeset); + if changeset.is_empty() { + Ok(None) + } else { + Ok(Some(changeset)) + } + } + + #[allow(dead_code)] + pub(crate) async fn hide_group(&mut self, group_id: &str) -> FlowyResult<()> { + self.mut_group_rev(group_id, |group_rev| { + group_rev.visible = false; + })?; + Ok(()) + } + + #[allow(dead_code)] + pub(crate) async fn show_group(&mut self, group_id: &str) -> FlowyResult<()> { + self.mut_group_rev(group_id, |group_rev| { + group_rev.visible = true; + })?; + Ok(()) + } + + pub(crate) fn get_mut_default_group(&mut self) -> &mut Group { + &mut self.default_group + } + + pub(crate) fn get_mut_group(&mut self, group_id: &str) -> Option<&mut Group> { + self.groups_map.get_mut(group_id) + } + // Returns the index and group specified by the group_id pub(crate) fn get_group(&self, group_id: &str) -> Option<(usize, &Group)> { match (self.groups_map.get_index_of(group_id), self.groups_map.get(group_id)) { @@ -160,22 +235,6 @@ where Ok(()) } - fn mut_configuration_group( - &mut self, - group_id: &str, - mut_groups_fn: impl Fn(&mut GroupRecordRevision), - ) -> FlowyResult<()> { - self.mut_configuration(|configuration| { - match configuration.groups.iter_mut().find(|group| group.group_id == group_id) { - None => false, - Some(group_rev) => { - mut_groups_fn(group_rev); - true - } - } - }) - } - fn mut_configuration( &mut self, mut_configuration_fn: impl FnOnce(&mut GroupConfigurationRevision) -> bool, @@ -187,35 +246,96 @@ where } Ok(()) } + + fn mut_group_rev(&mut self, group_id: &str, mut_groups_fn: impl Fn(&mut GroupRevision)) -> FlowyResult<()> { + self.mut_configuration(|configuration| { + match configuration.groups.iter_mut().find(|group| group.id == group_id) { + None => false, + Some(group_rev) => { + mut_groups_fn(group_rev); + true + } + } + }) + } } -fn merge_groups(old_group_revs: &[GroupRecordRevision], groups: Vec) -> (Vec, Vec) { - if old_group_revs.is_empty() { - let new_groups = groups - .iter() - .map(|group| GroupRecordRevision::new(group.id.clone())) - .collect(); - return (new_groups, groups); +fn merge_groups(old_groups: &[GroupRevision], groups: Vec) -> MergeGroupResult { + let mut merge_result = MergeGroupResult::new(); + if old_groups.is_empty() { + merge_result.groups = groups; + return merge_result; } + // group_map is a helper map is used to filter out the new groups. let mut group_map: IndexMap = IndexMap::new(); groups.into_iter().for_each(|group| { group_map.insert(group.id.clone(), group); }); - // Inert - let mut sorted_groups: Vec = vec![]; - for group_rev in old_group_revs { - if let Some(group) = group_map.remove(&group_rev.group_id) { - sorted_groups.push(group); + // The group is ordered in old groups. Add them before adding the new groups + for group_rev in old_groups { + if let Some(group) = group_map.remove(&group_rev.id) { + if group.name == group_rev.name { + merge_result.add_group(group); + } else { + merge_result.add_updated_group(group); + } } } - sorted_groups.extend(group_map.into_values().collect::>()); - let new_group_revs = sorted_groups - .iter() - .map(|group| GroupRecordRevision::new(group.id.clone())) - .collect::>(); - tracing::trace!("group revs: {}, groups: {}", new_group_revs.len(), sorted_groups.len()); - (new_group_revs, sorted_groups) + // Find out the new groups + group_map + .into_values() + .enumerate() + .for_each(|(index, group)| merge_result.add_insert_group(index, group)); + + merge_result +} + +struct MergeGroupResult { + groups: Vec, + inserted_groups: Vec, + updated_groups: Vec, +} + +impl MergeGroupResult { + fn new() -> Self { + Self { + groups: vec![], + inserted_groups: vec![], + updated_groups: vec![], + } + } + + fn add_updated_group(&mut self, group: Group) { + self.groups.push(group.clone()); + self.updated_groups.push(group); + } + + fn add_group(&mut self, group: Group) { + self.groups.push(group); + } + + fn add_insert_group(&mut self, index: usize, group: Group) { + self.groups.push(group.clone()); + let inserted_group = InsertedGroupPB { + group: GroupPB::from(group), + index: index as i32, + }; + self.inserted_groups.push(inserted_group); + } +} + +fn make_group_view_changeset( + view_id: String, + inserted_groups: Vec, + updated_group: Vec, +) -> GroupViewChangesetPB { + GroupViewChangesetPB { + view_id, + inserted_groups, + deleted_groups: vec![], + update_groups: updated_group.into_iter().map(GroupPB::from).collect(), + } } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/controller.rs b/frontend/rust-lib/flowy-grid/src/services/group/controller.rs index 1a5ee23694..62647d44b8 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/controller.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/controller.rs @@ -1,4 +1,4 @@ -use crate::entities::{GroupRowsChangesetPB, RowPB}; +use crate::entities::{GroupChangesetPB, GroupViewChangesetPB, InsertedRowPB, RowPB}; use crate::services::cell::{decode_any_cell_data, CellBytesParser}; use crate::services::group::action::GroupAction; use crate::services::group::configuration::GenericGroupConfiguration; @@ -11,8 +11,6 @@ use flowy_grid_data_model::revision::{ use std::marker::PhantomData; use std::sync::Arc; -const DEFAULT_GROUP_ID: &str = "default_group"; - // Each kind of group must implement this trait to provide custom group // operations. For example, insert cell data to the row_rev when creating // a new row. @@ -51,15 +49,17 @@ pub trait GroupControllerSharedOperation: Send + Sync { &mut self, row_rev: &RowRevision, field_rev: &FieldRevision, - ) -> FlowyResult>; + ) -> FlowyResult>; fn did_delete_row( &mut self, row_rev: &RowRevision, field_rev: &FieldRevision, - ) -> FlowyResult>; + ) -> FlowyResult>; - fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult>; + fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult>; + + fn did_update_field(&mut self, field_rev: &FieldRevision) -> FlowyResult>; } /// C: represents the group configuration that impl [GroupConfigurationSerde] @@ -70,8 +70,6 @@ pub struct GenericGroupController { pub field_id: String, pub type_option: Option, pub configuration: GenericGroupConfiguration, - /// default_group is used to store the rows that don't belong to any groups. - default_group: Group, group_action_phantom: PhantomData, cell_parser_phantom: PhantomData

, } @@ -89,29 +87,92 @@ where 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 _ = configuration.merge_groups(groups).await?; - let default_group = Group::new( - DEFAULT_GROUP_ID.to_owned(), - field_rev.id.clone(), - format!("No {}", field_rev.name), - "".to_string(), - ); + let _ = configuration.merge_groups(groups)?; Ok(Self { field_id: field_rev.id.clone(), - default_group, type_option, configuration, group_action_phantom: PhantomData, cell_parser_phantom: PhantomData, }) } + + // https://stackoverflow.com/questions/69413164/how-to-fix-this-clippy-warning-needless-collect + #[allow(clippy::needless_collect)] + fn update_default_group( + &mut self, + row_rev: &RowRevision, + other_group_changesets: &[GroupChangesetPB], + ) -> GroupChangesetPB { + let default_group = self.configuration.get_mut_default_group(); + + // [other_group_inserted_row] contains all the inserted rows except the default group. + let other_group_inserted_row = other_group_changesets + .iter() + .flat_map(|changeset| &changeset.inserted_rows) + .collect::>(); + + // Calculate the inserted_rows of the default_group + let default_group_inserted_row = other_group_changesets + .iter() + .flat_map(|changeset| &changeset.deleted_rows) + .cloned() + .filter(|row_id| { + // if the [other_group_inserted_row] contains the row_id of the row + // which means the row should not move to the default group. + !other_group_inserted_row + .iter() + .any(|inserted_row| &inserted_row.row.id == row_id) + }) + .collect::>(); + + let mut changeset = GroupChangesetPB::new(default_group.id.clone()); + if !default_group_inserted_row.is_empty() { + changeset.inserted_rows.push(InsertedRowPB::new(row_rev.into())); + default_group.add_row(row_rev.into()); + } + + // [other_group_delete_rows] contains all the deleted rows except the default group. + let other_group_delete_rows: Vec = other_group_changesets + .iter() + .flat_map(|changeset| &changeset.deleted_rows) + .cloned() + .collect(); + + let default_group_deleted_rows = other_group_changesets + .iter() + .flat_map(|changeset| &changeset.inserted_rows) + .filter(|inserted_row| { + // if the [other_group_delete_rows] contain the inserted_row, which means this row should move + // out from the default_group. + let inserted_row_id = &inserted_row.row.id; + !other_group_delete_rows.iter().any(|row_id| inserted_row_id == row_id) + }) + .collect::>(); + + let mut deleted_row_ids = vec![]; + for row in &default_group.rows { + if default_group_deleted_rows + .iter() + .any(|deleted_row| deleted_row.row.id == row.id) + { + deleted_row_ids.push(row.id.clone()); + } + } + default_group.rows.retain(|row| !deleted_row_ids.contains(&row.id)); + changeset.deleted_rows.extend(deleted_row_ids); + changeset + } } impl GroupControllerSharedOperation for GenericGroupController where P: CellBytesParser, C: GroupConfigurationContentSerde, + T: TypeOptionDataDeserializer, + G: GroupGenerator, TypeOptionType = T>, + Self: GroupAction, { fn field_id(&self) -> &str { @@ -127,42 +188,38 @@ where Some((group.0, group.1.clone())) } + #[tracing::instrument(level = "trace", skip_all, fields(row_count=%row_revs.len(), group_result))] fn fill_groups(&mut self, row_revs: &[Arc], field_rev: &FieldRevision) -> FlowyResult> { for row_rev in row_revs { if let Some(cell_rev) = row_rev.cells.get(&self.field_id) { - let mut group_rows: Vec = vec![]; + let mut grouped_rows: 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.configuration.groups() { + for group in self.configuration.concrete_groups() { if self.can_group(&group.content, &cell_data) { - group_rows.push(GroupRow { + grouped_rows.push(GroupedRow { row: row_rev.into(), group_id: group.id.clone(), }); } } - if group_rows.is_empty() { - self.default_group.add_row(row_rev.into()); + if grouped_rows.is_empty() { + self.configuration.get_mut_default_group().add_row(row_rev.into()); } else { - for group_row in group_rows { + for group_row in grouped_rows { if let Some(group) = self.configuration.get_mut_group(&group_row.group_id) { group.add_row(group_row.row); } } } } else { - self.default_group.add_row(row_rev.into()); + self.configuration.get_mut_default_group().add_row(row_rev.into()); } } - let default_group = self.default_group.clone(); - let mut groups: Vec = self.configuration.clone_groups(); - if !default_group.number_of_row() == 0 { - groups.push(default_group); - } - - Ok(groups) + tracing::Span::current().record("group_result", &format!("{},", self.configuration,).as_str()); + Ok(self.groups()) } fn move_group(&mut self, from_group_id: &str, to_group_id: &str) -> FlowyResult<()> { @@ -173,11 +230,17 @@ where &mut self, row_rev: &RowRevision, field_rev: &FieldRevision, - ) -> FlowyResult> { + ) -> 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)) + let mut changesets = self.add_row_if_match(row_rev, &cell_data); + let default_group_changeset = self.update_default_group(row_rev, &changesets); + tracing::info!("default_group_changeset: {}", default_group_changeset); + if !default_group_changeset.is_empty() { + changesets.push(default_group_changeset); + } + Ok(changesets) } else { Ok(vec![]) } @@ -187,7 +250,7 @@ where &mut self, row_rev: &RowRevision, field_rev: &FieldRevision, - ) -> FlowyResult> { + ) -> 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::

()?; @@ -197,7 +260,7 @@ where } } - fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult> { + fn move_group_row(&mut self, context: MoveGroupRowContext) -> FlowyResult> { if let Some(cell_rev) = context.row_rev.cells.get(&self.field_id) { let cell_bytes = decode_any_cell_data(cell_rev.data.clone(), context.field_rev); let cell_data = cell_bytes.parser::

()?; @@ -206,9 +269,17 @@ where Ok(vec![]) } } + + fn did_update_field(&mut self, field_rev: &FieldRevision) -> FlowyResult> { + 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, &self.configuration, &type_option); + let changeset = self.configuration.merge_groups(groups)?; + Ok(changeset) + } } -struct GroupRow { +struct GroupedRow { row: RowPB, group_id: String, } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/checkbox_controller.rs b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/checkbox_controller.rs index ffcbf117fe..4c06ba63ce 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/checkbox_controller.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/checkbox_controller.rs @@ -1,4 +1,4 @@ -use crate::entities::GroupRowsChangesetPB; +use crate::entities::GroupChangesetPB; use crate::services::field::{CheckboxCellData, CheckboxCellDataParser, CheckboxTypeOptionPB, CHECK, UNCHECK}; use crate::services::group::action::GroupAction; use crate::services::group::configuration::GenericGroupConfiguration; @@ -24,11 +24,7 @@ impl GroupAction for CheckboxGroupController { false } - fn add_row_if_match( - &mut self, - _row_rev: &RowRevision, - _cell_data: &Self::CellDataType, - ) -> Vec { + fn add_row_if_match(&mut self, _row_rev: &RowRevision, _cell_data: &Self::CellDataType) -> Vec { todo!() } @@ -36,15 +32,11 @@ impl GroupAction for CheckboxGroupController { &mut self, _row_rev: &RowRevision, _cell_data: &Self::CellDataType, - ) -> Vec { + ) -> Vec { todo!() } - fn move_row( - &mut self, - _cell_data: &Self::CellDataType, - _context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, _cell_data: &Self::CellDataType, _context: MoveGroupRowContext) -> Vec { todo!() } } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs index cce2698158..fe90e1b462 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/multi_select_controller.rs @@ -1,4 +1,4 @@ -use crate::entities::GroupRowsChangesetPB; +use crate::entities::GroupChangesetPB; use crate::services::cell::insert_select_option_cell; use crate::services::field::{MultiSelectTypeOptionPB, SelectOptionCellDataPB, SelectOptionCellDataParser}; use crate::services::group::action::GroupAction; @@ -25,34 +25,32 @@ impl GroupAction for MultiSelectGroupController { 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 { + fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec { let mut changesets = vec![]; - self.configuration.with_mut_groups(|group| { - add_row(group, &mut changesets, cell_data, row_rev); + self.configuration.iter_mut_groups(|group| { + if let Some(changeset) = add_row(group, cell_data, row_rev) { + changesets.push(changeset); + } }); changesets } - fn remove_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 { let mut changesets = vec![]; - self.configuration.with_mut_groups(|group| { - remove_row(group, &mut changesets, cell_data, row_rev); + self.configuration.iter_mut_groups(|group| { + if let Some(changeset) = remove_row(group, cell_data, row_rev) { + changesets.push(changeset); + } }); changesets } - fn move_row( - &mut self, - cell_data: &Self::CellDataType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; - self.configuration.with_mut_groups(|group| { - move_select_option_row(group, &mut group_changeset, cell_data, &mut context); + self.configuration.iter_mut_groups(|group| { + if let Some(changeset) = move_select_option_row(group, cell_data, &mut context) { + group_changeset.push(changeset); + } }); group_changeset } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/single_select_controller.rs b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/single_select_controller.rs index d48cdd8ee7..d774ab083f 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/single_select_controller.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/single_select_controller.rs @@ -1,4 +1,4 @@ -use crate::entities::{GroupRowsChangesetPB, RowPB}; +use crate::entities::{GroupChangesetPB, RowPB}; use crate::services::cell::insert_select_option_cell; use crate::services::field::{SelectOptionCellDataPB, SelectOptionCellDataParser, SingleSelectTypeOptionPB}; use crate::services::group::action::GroupAction; @@ -25,34 +25,32 @@ impl GroupAction for SingleSelectGroupController { 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 { + fn add_row_if_match(&mut self, row_rev: &RowRevision, cell_data: &Self::CellDataType) -> Vec { let mut changesets = vec![]; - self.configuration.with_mut_groups(|group| { - add_row(group, &mut changesets, cell_data, row_rev); + self.configuration.iter_mut_groups(|group| { + if let Some(changeset) = add_row(group, cell_data, row_rev) { + changesets.push(changeset); + } }); changesets } - fn remove_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 { let mut changesets = vec![]; - self.configuration.with_mut_groups(|group| { - remove_row(group, &mut changesets, cell_data, row_rev); + self.configuration.iter_mut_groups(|group| { + if let Some(changeset) = remove_row(group, cell_data, row_rev) { + changesets.push(changeset); + } }); changesets } - fn move_row( - &mut self, - cell_data: &Self::CellDataType, - mut context: MoveGroupRowContext, - ) -> Vec { + fn move_row(&mut self, cell_data: &Self::CellDataType, mut context: MoveGroupRowContext) -> Vec { let mut group_changeset = vec![]; - self.configuration.with_mut_groups(|group| { - move_select_option_row(group, &mut group_changeset, cell_data, &mut context); + self.configuration.iter_mut_groups(|group| { + if let Some(changeset) = move_select_option_row(group, cell_data, &mut context) { + group_changeset.push(changeset); + } }); group_changeset } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/util.rs b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/util.rs index bdca688531..39f581d97d 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/util.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/controller_impls/select_option_controller/util.rs @@ -1,4 +1,4 @@ -use crate::entities::{GroupRowsChangesetPB, InsertedRowPB, RowPB}; +use crate::entities::{GroupChangesetPB, InsertedRowPB, RowPB}; use crate::services::cell::insert_select_option_cell; use crate::services::field::SelectOptionCellDataPB; use crate::services::group::configuration::GenericGroupConfiguration; @@ -11,47 +11,63 @@ pub type SelectOptionGroupConfiguration = GenericGroupConfiguration, 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()])); +) -> Option { + let mut changeset = GroupChangesetPB::new(group.id.clone()); + if cell_data.select_options.is_empty() { + if group.contains_row(&row_rev.id) { + changeset.deleted_rows.push(row_rev.id.clone()); group.remove_row(&row_rev.id); } - }); + } else { + 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); + changeset.inserted_rows.push(InsertedRowPB::new(row_pb.clone())); + group.add_row(row_pb); + } + } else if group.contains_row(&row_rev.id) { + changeset.deleted_rows.push(row_rev.id.clone()); + group.remove_row(&row_rev.id); + } + }); + } + + if changeset.is_empty() { + None + } else { + Some(changeset) + } } pub fn remove_row( group: &mut Group, - changesets: &mut Vec, cell_data: &SelectOptionCellDataPB, row_rev: &RowRevision, -) { +) -> Option { + let mut changeset = GroupChangesetPB::new(group.id.clone()); 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()])); + changeset.deleted_rows.push(row_rev.id.clone()); group.remove_row(&row_rev.id); } }); + + if changeset.is_empty() { + None + } else { + Some(changeset) + } } pub fn move_select_option_row( group: &mut Group, - group_changeset: &mut Vec, _cell_data: &SelectOptionCellDataPB, context: &mut MoveGroupRowContext, -) { +) -> Option { + let mut changeset = GroupChangesetPB::new(group.id.clone()); let MoveGroupRowContext { row_rev, row_changeset, @@ -68,7 +84,7 @@ pub fn move_select_option_row( // Remove the row in which group contains it if from_index.is_some() { - group_changeset.push(GroupRowsChangesetPB::delete(group.id.clone(), vec![row_rev.id.clone()])); + changeset.deleted_rows.push(row_rev.id.clone()); tracing::debug!("Group:{} remove row:{}", group.id, row_rev.id); group.remove_row(&row_rev.id); } @@ -78,7 +94,7 @@ pub fn move_select_option_row( let mut inserted_row = InsertedRowPB::new(row_pb.clone()); match to_index { None => { - group_changeset.push(GroupRowsChangesetPB::insert(group.id.clone(), vec![inserted_row])); + changeset.inserted_rows.push(inserted_row); tracing::debug!("Group:{} append row:{}", group.id, row_rev.id); group.add_row(row_pb); } @@ -91,7 +107,7 @@ pub fn move_select_option_row( tracing::debug!("Group:{} append row:{}", group.id, row_rev.id); group.add_row(row_pb); } - group_changeset.push(GroupRowsChangesetPB::insert(group.id.clone(), vec![inserted_row])); + changeset.inserted_rows.push(inserted_row); } } @@ -100,6 +116,12 @@ pub fn move_select_option_row( tracing::debug!("Mark row:{} belong to group:{}", row_rev.id, group.id); 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); + changeset.updated_rows.push(RowPB::from(*row_rev)); } } + if changeset.is_empty() { + None + } else { + Some(changeset) + } } diff --git a/frontend/rust-lib/flowy-grid/src/services/group/entities.rs b/frontend/rust-lib/flowy-grid/src/services/group/entities.rs index dd4171afb0..9afe7f54e9 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/entities.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/entities.rs @@ -1,33 +1,24 @@ -use crate::entities::{GroupPB, RowPB}; +use crate::entities::RowPB; -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq)] pub struct Group { pub id: String, pub field_id: String, - pub desc: String, - rows: Vec, + pub name: String, + pub is_default: bool, + pub(crate) rows: Vec, /// [content] is used to determine which group the cell belongs to. 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 { + pub fn new(id: String, field_id: String, name: String, content: String) -> Self { Self { id, field_id, - desc, + is_default: false, + name, rows: vec![], content, } @@ -70,4 +61,8 @@ impl Group { pub fn number_of_row(&self) -> usize { self.rows.len() } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } } 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 index 6afec9dd82..c7d67f65d6 100644 --- a/frontend/rust-lib/flowy-grid/src/services/group/group_service.rs +++ b/frontend/rust-lib/flowy-grid/src/services/group/group_service.rs @@ -1,4 +1,4 @@ -use crate::entities::{FieldType, GroupRowsChangesetPB}; +use crate::entities::{FieldType, GroupChangesetPB, GroupViewChangesetPB}; use crate::services::group::configuration::GroupConfigurationReader; use crate::services::group::controller::{GroupController, MoveGroupRowContext}; use crate::services::group::{ @@ -15,18 +15,20 @@ use std::future::Future; use std::sync::Arc; pub(crate) struct GroupService { + view_id: String, configuration_reader: Arc, configuration_writer: Arc, group_controller: Option>, } impl GroupService { - pub(crate) async fn new(configuration_reader: R, configuration_writer: W) -> Self + pub(crate) async fn new(view_id: String, configuration_reader: R, configuration_writer: W) -> Self where R: GroupConfigurationReader, W: GroupConfigurationWriter, { Self { + view_id, configuration_reader: Arc::new(configuration_reader), configuration_writer: Arc::new(configuration_writer), group_controller: None, @@ -36,8 +38,8 @@ impl GroupService { pub(crate) async fn groups(&self) -> Vec { self.group_controller .as_ref() - .and_then(|group_controller| Some(group_controller.groups())) - .unwrap_or(vec![]) + .map(|group_controller| group_controller.groups()) + .unwrap_or_default() } pub(crate) async fn get_group(&self, group_id: &str) -> Option<(usize, Group)> { @@ -86,7 +88,7 @@ impl GroupService { &mut self, row_rev: &RowRevision, get_field_fn: F, - ) -> Option> + ) -> Option> where F: FnOnce(String) -> O, O: Future>> + Send + Sync + 'static, @@ -111,7 +113,7 @@ impl GroupService { to_group_id: &str, to_row_id: Option, get_field_fn: F, - ) -> Option> + ) -> Option> where F: FnOnce(String) -> O, O: Future>> + Send + Sync + 'static, @@ -141,7 +143,7 @@ impl GroupService { &mut self, row_rev: &RowRevision, get_field_fn: F, - ) -> Option> + ) -> Option> where F: FnOnce(String) -> O, O: Future>> + Send + Sync + 'static, @@ -170,6 +172,17 @@ impl GroupService { } } + #[tracing::instrument(level = "trace", name = "group_did_update_field", skip(self, field_rev), err)] + pub(crate) async fn did_update_field( + &mut self, + field_rev: &FieldRevision, + ) -> FlowyResult> { + match self.group_controller.as_mut() { + None => Ok(None), + Some(group_controller) => group_controller.did_update_field(field_rev), + } + } + #[tracing::instrument(level = "trace", skip(self, field_rev), err)] async fn make_group_controller( &self, @@ -189,6 +202,7 @@ impl GroupService { } FieldType::SingleSelect => { let configuration = SelectOptionGroupConfiguration::new( + self.view_id.clone(), field_rev.clone(), self.configuration_reader.clone(), self.configuration_writer.clone(), @@ -199,6 +213,7 @@ impl GroupService { } FieldType::MultiSelect => { let configuration = SelectOptionGroupConfiguration::new( + self.view_id.clone(), field_rev.clone(), self.configuration_reader.clone(), self.configuration_writer.clone(), @@ -209,6 +224,7 @@ impl GroupService { } FieldType::Checkbox => { let configuration = CheckboxGroupConfiguration::new( + self.view_id.clone(), field_rev.clone(), self.configuration_reader.clone(), self.configuration_writer.clone(), diff --git a/frontend/rust-lib/flowy-grid/src/util.rs b/frontend/rust-lib/flowy-grid/src/util.rs index 90bf2f2a26..65f6440c1f 100644 --- a/frontend/rust-lib/flowy-grid/src/util.rs +++ b/frontend/rust-lib/flowy-grid/src/util.rs @@ -34,6 +34,127 @@ pub fn make_default_grid() -> BuildGridContext { } pub fn make_default_board() -> BuildGridContext { + let mut grid_builder = GridBuilder::new(); + // text + let text_field = FieldBuilder::new(RichTextTypeOptionBuilder::default()) + .name("Description") + .visibility(true) + .primary(true) + .build(); + let text_field_id = text_field.id.clone(); + grid_builder.add_field(text_field); + + // single select + let to_do_option = SelectOptionPB::with_color("To Do", SelectOptionColorPB::Purple); + let doing_option = SelectOptionPB::with_color("Doing", SelectOptionColorPB::Orange); + let done_option = SelectOptionPB::with_color("Done", SelectOptionColorPB::Yellow); + let single_select_type_option = SingleSelectTypeOptionBuilder::default() + .add_option(to_do_option.clone()) + .add_option(doing_option.clone()) + .add_option(done_option.clone()); + 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 work_option = SelectOptionPB::with_color("Work", SelectOptionColorPB::Aqua); + let travel_option = SelectOptionPB::with_color("Travel", SelectOptionColorPB::Green); + let fun_option = SelectOptionPB::with_color("Fun", SelectOptionColorPB::Lime); + let health_option = SelectOptionPB::with_color("Health", SelectOptionColorPB::Pink); + let multi_select_type_option = MultiSelectTypeOptionBuilder::default() + .add_option(travel_option.clone()) + .add_option(work_option.clone()) + .add_option(fun_option.clone()) + .add_option(health_option.clone()); + let multi_select_field = FieldBuilder::new(multi_select_type_option) + .name("Tags") + .visibility(true) + .build(); + let multi_select_field_id = multi_select_field.id.clone(); + grid_builder.add_field(multi_select_field); + + for i in 0..3 { + let mut row_builder = RowRevisionBuilder::new(grid_builder.block_id(), grid_builder.field_revs()); + row_builder.insert_select_option_cell(&single_select_field_id, to_do_option.id.clone()); + match i { + 0 => { + row_builder.insert_text_cell(&text_field_id, "Update AppFlowy Website".to_string()); + row_builder.insert_select_option_cell(&multi_select_field_id, work_option.id.clone()); + } + 1 => { + row_builder.insert_text_cell(&text_field_id, "Learn French".to_string()); + let mut options = SelectOptionIds::new(); + options.push(fun_option.id.clone()); + options.push(travel_option.id.clone()); + row_builder.insert_select_option_cell(&multi_select_field_id, options.to_string()); + } + + 2 => { + row_builder.insert_text_cell(&text_field_id, "Exercise 4x/week".to_string()); + row_builder.insert_select_option_cell(&multi_select_field_id, fun_option.id.clone()); + } + _ => {} + } + let row = row_builder.build(); + grid_builder.add_row(row); + } + + for i in 0..3 { + let mut row_builder = RowRevisionBuilder::new(grid_builder.block_id(), grid_builder.field_revs()); + row_builder.insert_select_option_cell(&single_select_field_id, doing_option.id.clone()); + match i { + 0 => { + row_builder.insert_text_cell(&text_field_id, "Learn how to swim".to_string()); + row_builder.insert_select_option_cell(&multi_select_field_id, fun_option.id.clone()); + } + 1 => { + row_builder.insert_text_cell(&text_field_id, "Meditate 10 mins each day".to_string()); + row_builder.insert_select_option_cell(&multi_select_field_id, health_option.id.clone()); + } + + 2 => { + row_builder.insert_text_cell(&text_field_id, "Write atomic essays ".to_string()); + let mut options = SelectOptionIds::new(); + options.push(fun_option.id.clone()); + options.push(work_option.id.clone()); + row_builder.insert_select_option_cell(&multi_select_field_id, options.to_string()); + } + _ => {} + } + let row = row_builder.build(); + grid_builder.add_row(row); + } + + for i in 0..2 { + let mut row_builder = RowRevisionBuilder::new(grid_builder.block_id(), grid_builder.field_revs()); + row_builder.insert_select_option_cell(&single_select_field_id, done_option.id.clone()); + match i { + 0 => { + row_builder.insert_text_cell(&text_field_id, "Publish an article".to_string()); + row_builder.insert_select_option_cell(&multi_select_field_id, work_option.id.clone()); + } + 1 => { + row_builder.insert_text_cell(&text_field_id, "Visit Chicago".to_string()); + let mut options = SelectOptionIds::new(); + options.push(travel_option.id.clone()); + options.push(fun_option.id.clone()); + row_builder.insert_select_option_cell(&multi_select_field_id, options.to_string()); + } + + _ => {} + } + let row = row_builder.build(); + grid_builder.add_row(row); + } + + grid_builder.build() +} + +#[allow(dead_code)] +pub fn make_default_board2() -> BuildGridContext { let mut grid_builder = GridBuilder::new(); // text let text_field = FieldBuilder::new(RichTextTypeOptionBuilder::default()) 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 index 94ef4dff1b..6afeda6d4b 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/group_test/script.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/group_test/script.rs @@ -1,9 +1,11 @@ use crate::grid::grid_editor::GridEditorTest; use flowy_grid::entities::{ - CreateRowParams, FieldType, GridLayout, GroupPB, MoveGroupParams, MoveGroupRowParams, RowPB, + CreateRowParams, FieldChangesetParams, FieldType, GridLayout, GroupPB, MoveGroupParams, MoveGroupRowParams, RowPB, }; -use flowy_grid::services::cell::insert_select_option_cell; +use flowy_grid::services::cell::{delete_select_option_cell, insert_select_option_cell}; use flowy_grid_data_model::revision::RowChangeset; +use std::time::Duration; +use tokio::time::interval; pub enum GroupScript { AssertGroupRowCount { @@ -42,6 +44,9 @@ pub enum GroupScript { from_group_index: usize, to_group_index: usize, }, + UpdateField { + changeset: FieldChangesetParams, + }, } pub struct GridGroupTest { @@ -123,11 +128,22 @@ impl GridGroupTest { 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 cell_rev = if to_group.is_default { + match field_type { + FieldType::SingleSelect => delete_select_option_cell(to_group.group_id.clone(), &field_rev), + FieldType::MultiSelect => delete_select_option_cell(to_group.group_id.clone(), &field_rev), + _ => { + panic!("Unsupported group field type"); + } + } + } else { + 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"); + } } }; @@ -156,6 +172,12 @@ impl GridGroupTest { } => { let group = self.group_at_index(group_index).await; assert_eq!(group.group_id, group_pb.group_id); + assert_eq!(group.desc, group_pb.desc); + } + GroupScript::UpdateField { changeset } => { + self.editor.update_field(changeset).await.unwrap(); + let mut interval = interval(Duration::from_millis(130)); + interval.tick().await; } } } diff --git a/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs b/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs index bebf499773..a52a088f51 100644 --- a/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs +++ b/frontend/rust-lib/flowy-grid/tests/grid/group_test/test.rs @@ -1,11 +1,12 @@ use crate::grid::group_test::script::GridGroupTest; use crate::grid::group_test::script::GroupScript::*; +use flowy_grid::entities::FieldChangesetParams; #[tokio::test] async fn group_init_test() { let mut test = GridGroupTest::new().await; let scripts = vec![ - AssertGroupCount(3), + AssertGroupCount(4), AssertGroupRowCount { group_index: 0, row_count: 2, @@ -18,6 +19,10 @@ async fn group_init_test() { group_index: 2, row_count: 1, }, + AssertGroupRowCount { + group_index: 3, + row_count: 0, + }, ]; test.run_scripts(scripts).await; } @@ -293,6 +298,55 @@ async fn group_reorder_group_test() { test.run_scripts(scripts).await; } +#[tokio::test] +async fn group_move_to_default_group_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![ + UpdateRow { + from_group_index: 0, + row_index: 0, + to_group_index: 3, + }, + AssertGroupRowCount { + group_index: 0, + row_count: 1, + }, + AssertGroupRowCount { + group_index: 3, + row_count: 1, + }, + ]; + test.run_scripts(scripts).await; +} + +#[tokio::test] +async fn group_move_from_default_group_test() { + let mut test = GridGroupTest::new().await; + let scripts = vec![UpdateRow { + from_group_index: 0, + row_index: 0, + to_group_index: 3, + }]; + test.run_scripts(scripts).await; + + let scripts = vec![ + UpdateRow { + from_group_index: 3, + row_index: 0, + to_group_index: 0, + }, + AssertGroupRowCount { + group_index: 0, + row_count: 2, + }, + AssertGroupRowCount { + group_index: 3, + row_count: 0, + }, + ]; + test.run_scripts(scripts).await; +} + #[tokio::test] async fn group_move_group_test() { let mut test = GridGroupTest::new().await; @@ -314,3 +368,25 @@ async fn group_move_group_test() { ]; test.run_scripts(scripts).await; } + +#[tokio::test] +async fn group_update_field_test() { + let mut test = GridGroupTest::new().await; + let group = test.group_at_index(0).await; + let changeset = FieldChangesetParams { + field_id: group.field_id.clone(), + grid_id: test.grid_id.clone(), + name: Some("ABC".to_string()), + ..Default::default() + }; + + // group.desc = "ABC".to_string(); + let scripts = vec![ + UpdateField { changeset }, + AssertGroup { + group_index: 0, + expected_group: group, + }, + ]; + test.run_scripts(scripts).await; +} diff --git a/frontend/rust-lib/lib-sqlite/Cargo.toml b/frontend/rust-lib/lib-sqlite/Cargo.toml index 6536a19a56..ea45b7d6ae 100644 --- a/frontend/rust-lib/lib-sqlite/Cargo.toml +++ b/frontend/rust-lib/lib-sqlite/Cargo.toml @@ -15,6 +15,6 @@ lazy_static = "1.4.0" scheduled-thread-pool = "0.2.5" error-chain = "=0.12.0" log = "0.4.11" - +openssl = { version = "0.10.38", features = ["vendored"] } #[features] #windows = ["libsqlite3-sys/bundled-windows"] \ No newline at end of file diff --git a/frontend/scripts/docker-buildfiles/Dockerfile b/frontend/scripts/docker-buildfiles/Dockerfile index f050087977..a15a31d2e3 100644 --- a/frontend/scripts/docker-buildfiles/Dockerfile +++ b/frontend/scripts/docker-buildfiles/Dockerfile @@ -26,6 +26,7 @@ RUN flutter channel stable RUN flutter config --enable-linux-desktop RUN flutter doctor RUN dart pub global activate protoc_plugin +RUN sudo pacman -Syu --needed --noconfirm git xdg-user-dirs libkeybinder3 RUN git clone https://github.com/AppFlowy-IO/appflowy.git && \ cd appflowy/frontend && \ diff --git a/frontend/scripts/install_dev_env/install_linux.sh b/frontend/scripts/install_dev_env/install_linux.sh index 396a66cd63..94a8234a26 100755 --- a/frontend/scripts/install_dev_env/install_linux.sh +++ b/frontend/scripts/install_dev_env/install_linux.sh @@ -46,6 +46,9 @@ flutter config --enable-linux-desktop # Fix any problems reported by flutter doctor flutter doctor +# install keybinder-3.0 +apt-get install keybinder-3.0 + # Add the githooks directory to your git configuration printMessage "Setting up githooks." git config core.hooksPath .githooks diff --git a/frontend/scripts/makefile/desktop.toml b/frontend/scripts/makefile/desktop.toml index 458801ee26..09b1680f3f 100644 --- a/frontend/scripts/makefile/desktop.toml +++ b/frontend/scripts/makefile/desktop.toml @@ -13,6 +13,11 @@ mac_alias = "flowy-sdk-dev-macos" windows_alias = "flowy-sdk-dev-windows" linux_alias = "flowy-sdk-dev-linux" +[tasks.flowy-sdk-dev-android] +category = "Build" +dependencies = ["env_check"] +run_task = { name = ["setup-crate-type","sdk-build-android", "restore-crate-type"] } + [tasks.flowy-sdk-dev-macos] category = "Build" dependencies = ["env_check"] @@ -42,6 +47,32 @@ script = [ ] script_runner = "@shell" +[tasks.sdk-build-android] +private = true +script = [ + """ + cd rust-lib/ + rustup show + rustup target add aarch64-linux-android \ + armv7-linux-androideabi \ + i686-linux-android \ + x86_64-linux-android + DEST=${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/app_flowy/android/app/src/main/jniLibs + rm -rf $DEST/arm64-v8a \ + $DEST/armeabi-v7a \ + $DEST/x86 \ + $DEST/x86_64 + cargo ndk \ + -t arm64-v8a \ + -t armeabi-v7a \ + -t x86 \ + -t x86_64 \ + -o $DEST build + cd ../ + """, +] +script_runner = "@shell" + [tasks.sdk-build.windows] private = true script = [ 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 index 5464d83877..ba113810f6 100644 --- a/shared-lib/flowy-grid-data-model/src/revision/grid_block.rs +++ b/shared-lib/flowy-grid-data-model/src/revision/grid_block.rs @@ -59,8 +59,8 @@ impl RowChangeset { } } - pub fn has_changed(&self) -> bool { - self.height.is_some() || self.visibility.is_some() || !self.cell_by_field_id.is_empty() + pub fn is_empty(&self) -> bool { + self.height.is_none() && self.visibility.is_none() && self.cell_by_field_id.is_empty() } } diff --git a/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs b/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs index 97d45295fe..04b571fd76 100644 --- a/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs +++ b/shared-lib/flowy-grid-data-model/src/revision/group_rev.rs @@ -14,7 +14,7 @@ pub struct GroupConfigurationRevision { pub id: String, pub field_id: String, pub field_type_rev: FieldTypeRevision, - pub groups: Vec, + pub groups: Vec, pub content: String, } @@ -106,20 +106,38 @@ impl GroupConfigurationContentSerde for SelectOptionGroupConfigurationRevision { } } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct GroupRecordRevision { - pub group_id: String, +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GroupRevision { + pub id: String, - #[serde(default = "DEFAULT_GROUP_RECORD_VISIBILITY")] + #[serde(default)] + pub name: String, + + #[serde(skip, default = "IS_DEFAULT_GROUP")] + pub is_default: bool, + + #[serde(default = "GROUP_REV_VISIBILITY")] pub visible: bool, } -const DEFAULT_GROUP_RECORD_VISIBILITY: fn() -> bool = || true; +const GROUP_REV_VISIBILITY: fn() -> bool = || true; +const IS_DEFAULT_GROUP: fn() -> bool = || false; -impl GroupRecordRevision { - pub fn new(group_id: String) -> Self { +impl GroupRevision { + pub fn new(id: String, group_name: String) -> Self { Self { - group_id, + id, + name: group_name, + is_default: false, + visible: true, + } + } + + pub fn default_group(id: String, group_name: String) -> Self { + Self { + id, + name: group_name, + is_default: true, visible: true, } } diff --git a/shared-lib/lib-ot/src/core/document/attributes.rs b/shared-lib/lib-ot/src/core/document/attributes.rs index 52b33dce08..d9242c67cc 100644 --- a/shared-lib/lib-ot/src/core/document/attributes.rs +++ b/shared-lib/lib-ot/src/core/document/attributes.rs @@ -3,6 +3,12 @@ use std::collections::HashMap; #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct NodeAttributes(pub HashMap>); +impl Default for NodeAttributes { + fn default() -> Self { + Self::new() + } +} + impl NodeAttributes { pub fn new() -> NodeAttributes { NodeAttributes(HashMap::new()) diff --git a/shared-lib/lib-ot/src/core/document/document.rs b/shared-lib/lib-ot/src/core/document/document.rs index 73ff03fe64..204a920ad8 100644 --- a/shared-lib/lib-ot/src/core/document/document.rs +++ b/shared-lib/lib-ot/src/core/document/document.rs @@ -10,10 +10,16 @@ pub struct DocumentTree { pub root: NodeId, } +impl Default for DocumentTree { + fn default() -> Self { + Self::new() + } +} + impl DocumentTree { pub fn new() -> DocumentTree { let mut arena = Arena::new(); - let root = arena.new_node(NodeData::new("root".into())); + let root = arena.new_node(NodeData::new("root")); DocumentTree { arena, root } } @@ -25,7 +31,7 @@ impl DocumentTree { let mut iterate_node = self.root; for id in &position.0 { - let child = self.child_at_index_of_path(iterate_node, id.clone()); + let child = self.child_at_index_of_path(iterate_node, *id); iterate_node = match child { Some(node) => node, None => return None, @@ -74,13 +80,10 @@ impl DocumentTree { fn child_at_index_of_path(&self, at_node: NodeId, index: usize) -> Option { let children = at_node.children(&self.arena); - let mut counter = 0; - for child in children { + for (counter, child) in children.enumerate() { if counter == index { return Some(child); } - - counter += 1; } None @@ -107,7 +110,7 @@ impl DocumentTree { let last_index = path.0[path.0.len() - 1]; let parent_node = self .node_at_path(&Position(parent_path.to_vec())) - .ok_or(ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; + .ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; self.insert_child_at_index(parent_node, last_index, nodes.as_ref()) } @@ -132,7 +135,7 @@ impl DocumentTree { let node_to_insert = self .child_at_index_of_path(parent, index) - .ok_or(ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; + .ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; self.insert_subtree_before(&node_to_insert, insert_children); Ok(()) @@ -160,11 +163,11 @@ impl DocumentTree { fn apply_update(&mut self, path: &Position, attributes: &NodeAttributes) -> Result<(), OTError> { let update_node = self .node_at_path(path) - .ok_or(ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; + .ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; let node_data = self.arena.get_mut(update_node).unwrap(); let new_node = { let old_attributes = &node_data.get().attributes; - let new_attributes = NodeAttributes::compose(&old_attributes, attributes); + let new_attributes = NodeAttributes::compose(old_attributes, attributes); NodeData { attributes: new_attributes, ..node_data.get().clone() @@ -177,7 +180,7 @@ impl DocumentTree { fn apply_delete(&mut self, path: &Position, len: usize) -> Result<(), OTError> { let mut update_node = self .node_at_path(path) - .ok_or(ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; + .ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; for _ in 0..len { let next = update_node.following_siblings(&self.arena).next(); update_node.remove_subtree(&mut self.arena); @@ -193,7 +196,7 @@ impl DocumentTree { fn apply_text_edit(&mut self, path: &Position, delta: &TextDelta) -> Result<(), OTError> { let edit_node = self .node_at_path(path) - .ok_or(ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; + .ok_or_else(|| ErrorBuilder::new(OTErrorCode::PathNotFound).build())?; let node_data = self.arena.get_mut(edit_node).unwrap(); let new_delta = if let Some(old_delta) = &node_data.get().delta { Some(old_delta.compose(delta)?) diff --git a/shared-lib/lib-ot/src/core/document/position.rs b/shared-lib/lib-ot/src/core/document/position.rs index b98edd97f4..541ff98fb2 100644 --- a/shared-lib/lib-ot/src/core/document/position.rs +++ b/shared-lib/lib-ot/src/core/document/position.rs @@ -35,7 +35,8 @@ impl Position { prefix.push(b_at_index); } prefix.append(&mut suffix); - return Position(prefix); + + Position(prefix) } } diff --git a/shared-lib/lib-ot/src/core/document/transaction.rs b/shared-lib/lib-ot/src/core/document/transaction.rs index 73fce7d8ad..c5b00523da 100644 --- a/shared-lib/lib-ot/src/core/document/transaction.rs +++ b/shared-lib/lib-ot/src/core/document/transaction.rs @@ -63,7 +63,7 @@ impl<'a> TransactionBuilder<'a> { let mut deleted_nodes: Vec> = Vec::new(); for _ in 0..length { - deleted_nodes.push(self.get_deleted_nodes(node.clone())); + deleted_nodes.push(self.get_deleted_nodes(node)); node = node.following_siblings(&self.document.arena).next().unwrap(); } @@ -74,19 +74,12 @@ impl<'a> TransactionBuilder<'a> { } fn get_deleted_nodes(&self, node_id: NodeId) -> Box { - let node = self.document.arena.get(node_id.clone()).unwrap(); - let node_data = node.get(); - let mut children: Vec> = vec![]; + let node_data = self.document.arena.get(node_id).unwrap().get(); - let mut children_iterators = node_id.children(&self.document.arena); - loop { - let next_child = children_iterators.next(); - if let Some(child_id) = next_child { - children.push(self.get_deleted_nodes(child_id)); - } else { - break; - } - } + let mut children: Vec> = vec![]; + node_id.children(&self.document.arena).for_each(|child_id| { + children.push(self.get_deleted_nodes(child_id)); + }); Box::new(NodeSubTree { node_type: node_data.node_type.clone(),