mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-03 10:40:44 +08:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e036a2f38 | |||
| 57c5bac456 | |||
| 11db127772 | |||
| c41d613b6e | |||
| c1c19be271 | |||
| 7e100d1c62 | |||
| f44a7978d9 | |||
| fa488aee64 | |||
| bb8454e7e1 | |||
| 7ea8800c99 | |||
| 16196c6ca1 | |||
| 9a9f243966 | |||
| e2624d93c7 | |||
| 766219d4e1 | |||
| 01d7ef88e3 | |||
| 154d9e975d | |||
| 24e07f73bb | |||
| d500e472f0 | |||
| 13d9fe506e | |||
| 1256d66938 | |||
| da7781a751 | |||
| a77d89bb1a | |||
| c51bdb74de | |||
| ac3c93ccfa | |||
| 6a2b73e76f | |||
| 07a08e6d4d | |||
| 6faebcabd3 | |||
| d10dd12663 | |||
| edc87e38df | |||
| 65c3271beb | |||
| 489702eb66 | |||
| e3349b458c | |||
| eb32a5894e | |||
| f98688f6c7 | |||
| 37ffef76a4 | |||
| 81760fd45c | |||
| 8c0e1ba04e | |||
| aca47445aa | |||
| 69c2f09eba | |||
| 75f4c0eede | |||
| 38110de134 | |||
| 7123595831 | |||
| 78cf402141 | |||
| ebd3e62ed9 | |||
| ce9841df9d | |||
| 5b9d51b41b | |||
| 18471ef9b2 | |||
| 7a575a4a5b | |||
| f5fc7c8c00 | |||
| 7d3e38d652 | |||
| b05c387f96 | |||
| 2bd920fea6 | |||
| b3b9c54a89 | |||
| 1d08e1d8c0 | |||
| 66a6a5fffc | |||
| 4f14e8840c | |||
| 95dd8d03be | |||
| 6d1172ba44 | |||
| 2aa56cbccd | |||
| adfa51a372 | |||
| 4f0d9aff30 | |||
| eecd0a2169 | |||
| f2980503b4 |
@@ -175,6 +175,11 @@
|
||||
"description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true",
|
||||
"default": true
|
||||
},
|
||||
"newAccountShareActionDelay": {
|
||||
"type": "number",
|
||||
"description": "Minimum account age in seconds before new accounts can invite members or create share links.\n@default 86400",
|
||||
"default": 86400
|
||||
},
|
||||
"passwordRequirements": {
|
||||
"type": "object",
|
||||
"description": "The password strength requirements when set new password.\n@default {\"min\":8,\"max\":32}",
|
||||
@@ -1405,22 +1410,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"customerIo": {
|
||||
"type": "object",
|
||||
"description": "Configuration for customerIo module",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable customer.io integration\n@default false",
|
||||
"default": false
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Customer.io token\n@default \"\"",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"type": "object",
|
||||
"description": "Configuration for oauth module",
|
||||
@@ -1519,16 +1508,6 @@
|
||||
"description": "Whether enable lifetime price and allow user to pay for it.\n@default true",
|
||||
"default": true
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.\n@default \"\"\n@environment `STRIPE_API_KEY`",
|
||||
"default": ""
|
||||
},
|
||||
"webhookKey": {
|
||||
"type": "string",
|
||||
"description": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
|
||||
"default": ""
|
||||
},
|
||||
"stripe": {
|
||||
"type": "object",
|
||||
"description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\"}\n@link https://docs.stripe.com/api",
|
||||
|
||||
@@ -59,13 +59,20 @@ runs:
|
||||
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare cache key
|
||||
id: cache-key
|
||||
shell: bash
|
||||
run: |
|
||||
shared_key="$(printf '%s' "${{ inputs.target }}-${{ inputs.package }}" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||
echo "shared-key=$shared_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
with:
|
||||
workspaces: ${{ env.DEV_DRIVE_WORKSPACE }}
|
||||
save-if: ${{ github.ref_name == 'canary' }}
|
||||
shared-key: ${{ inputs.target }}-${{ inputs.package }}
|
||||
shared-key: ${{ steps.cache-key.outputs.shared-key }}
|
||||
env:
|
||||
CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo
|
||||
RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup
|
||||
@@ -75,7 +82,7 @@ runs:
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
with:
|
||||
save-if: ${{ github.ref_name == 'canary' }}
|
||||
shared-key: ${{ inputs.target }}-${{ inputs.package }}
|
||||
shared-key: ${{ steps.cache-key.outputs.shared-key }}
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
|
||||
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.26.3"
|
||||
appVersion: "0.27.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: doc
|
||||
description: AFFiNE doc server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.26.3"
|
||||
appVersion: "0.27.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: front
|
||||
description: AFFiNE front server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.26.3"
|
||||
appVersion: "0.27.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.26.3"
|
||||
appVersion: "0.27.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": ["*", "!/^@blocksuite//", "!/oxlint/"]
|
||||
"excludePackagePatterns": ["^@blocksuite/", "^oxlint$"]
|
||||
},
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
|
||||
@@ -135,6 +135,159 @@ jobs:
|
||||
echo "All changes are submitted"
|
||||
fi
|
||||
|
||||
mobile-native-build-filter:
|
||||
name: Mobile native build filter
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run-android: ${{ steps.mobile-native-filter.outputs.android }}
|
||||
run-ios: ${{ steps.mobile-native-filter.outputs.ios }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: mobile-native-filter
|
||||
with:
|
||||
filters: |
|
||||
android:
|
||||
- '.github/workflows/build-test.yml'
|
||||
- 'packages/frontend/apps/android/**'
|
||||
- 'packages/frontend/mobile-native/**'
|
||||
- '.cargo/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'rust-toolchain*'
|
||||
ios:
|
||||
- '.github/workflows/build-test.yml'
|
||||
- 'packages/frontend/apps/ios/**'
|
||||
- 'packages/frontend/mobile-native/**'
|
||||
- '.cargo/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'rust-toolchain*'
|
||||
|
||||
build-android-app:
|
||||
name: Build Android app
|
||||
if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- mobile-native-build-filter
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android
|
||||
electron-install: false
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: 'aarch64-linux-android'
|
||||
package: 'affine_mobile_native'
|
||||
no-build: 'true'
|
||||
|
||||
- name: Build Android web assets
|
||||
run: yarn affine @affine/android build
|
||||
env:
|
||||
PUBLIC_PATH: '/'
|
||||
|
||||
- name: Write CI Firebase config
|
||||
run: |
|
||||
cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON'
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "1",
|
||||
"project_id": "affine-ci",
|
||||
"storage_bucket": "affine-ci.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "app.affine.pro"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "ci-placeholder"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
JSON
|
||||
|
||||
- name: Cap sync
|
||||
run: yarn workspace @affine/android cap sync
|
||||
|
||||
- name: Build Android debug app
|
||||
working-directory: packages/frontend/apps/android/App
|
||||
run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace
|
||||
|
||||
build-ios-app:
|
||||
name: Build iOS app
|
||||
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
|
||||
runs-on: macos-15
|
||||
needs:
|
||||
- mobile-native-build-filter
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/ios
|
||||
electron-install: false
|
||||
hard-link-nm: false
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 26.2
|
||||
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: 'aarch64-apple-ios-sim'
|
||||
package: 'affine_mobile_native'
|
||||
no-build: 'true'
|
||||
|
||||
- name: Build iOS web assets
|
||||
run: yarn affine @affine/ios build
|
||||
env:
|
||||
PUBLIC_PATH: '/'
|
||||
|
||||
- name: Cap sync
|
||||
run: yarn workspace @affine/ios sync
|
||||
|
||||
- name: Build iOS simulator app
|
||||
run: |
|
||||
xcodebuild \
|
||||
-workspace packages/frontend/apps/ios/App/App.xcworkspace \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
ARCHS=arm64 \
|
||||
ONLY_ACTIVE_ARCH=YES \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
build
|
||||
|
||||
rust-test-filter:
|
||||
name: Rust test filter
|
||||
runs-on: ubuntu-latest
|
||||
@@ -795,99 +948,6 @@ jobs:
|
||||
name: affine
|
||||
fail_ci_if_error: false
|
||||
|
||||
miri:
|
||||
name: miri code check
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
RUST_BACKTRACE: full
|
||||
CARGO_TERM_COLOR: always
|
||||
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: miri
|
||||
- name: Install latest nextest release
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: nextest@0.9.98
|
||||
|
||||
- name: Miri Code Check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cargo +nightly miri nextest run -p y-octo -j4
|
||||
|
||||
loom:
|
||||
name: loom thread test
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
RUSTFLAGS: --cfg loom
|
||||
RUST_BACKTRACE: full
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Install latest nextest release
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: nextest@0.9.98
|
||||
|
||||
- name: Loom Thread Test
|
||||
run: |
|
||||
cargo nextest run -p y-octo --lib
|
||||
|
||||
fuzzing:
|
||||
name: fuzzing
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
|
||||
- name: fuzzing
|
||||
working-directory: ./packages/common/y-octo/utils
|
||||
run: |
|
||||
cargo install cargo-fuzz
|
||||
cargo +nightly fuzz run apply_update -- -max_total_time=30
|
||||
cargo +nightly fuzz run codec_doc_any_struct -- -max_total_time=30
|
||||
cargo +nightly fuzz run codec_doc_any -- -max_total_time=30
|
||||
cargo +nightly fuzz run decode_bytes -- -max_total_time=30
|
||||
cargo +nightly fuzz run i32_decode -- -max_total_time=30
|
||||
cargo +nightly fuzz run i32_encode -- -max_total_time=30
|
||||
cargo +nightly fuzz run ins_del_text -- -max_total_time=30
|
||||
cargo +nightly fuzz run sync_message -- -max_total_time=30
|
||||
cargo +nightly fuzz run u64_decode -- -max_total_time=30
|
||||
cargo +nightly fuzz run u64_encode -- -max_total_time=30
|
||||
cargo +nightly fuzz run apply_update -- -max_total_time=30
|
||||
|
||||
- name: upload fuzz artifacts
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-artifact
|
||||
path: packages/common/y-octo/utils/fuzz/artifacts/**/*
|
||||
|
||||
rust-test:
|
||||
name: Run native tests
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
@@ -1328,6 +1388,9 @@ jobs:
|
||||
- analyze
|
||||
- lint
|
||||
- typecheck
|
||||
- mobile-native-build-filter
|
||||
- build-android-app
|
||||
- build-ios-app
|
||||
- lint-rust
|
||||
- check-git-status
|
||||
- check-yarn-binary
|
||||
@@ -1342,9 +1405,6 @@ jobs:
|
||||
- build-server-native
|
||||
- build-electron-renderer
|
||||
- native-unit-test
|
||||
- miri
|
||||
- loom
|
||||
- fuzzing
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- rust-test
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: Signing By Apple Developer ID
|
||||
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
uses: apple-actions/import-codesign-certs@v7
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
||||
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
||||
|
||||
@@ -424,12 +424,10 @@ jobs:
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
inputs.desktop_macos &&
|
||||
inputs.desktop_linux &&
|
||||
inputs.desktop_windows &&
|
||||
(inputs.desktop_macos || inputs.desktop_linux || inputs.desktop_windows) &&
|
||||
needs.before-make.result == 'success' &&
|
||||
needs.make-distribution-macos.result == 'success' &&
|
||||
needs.make-distribution-linux.result == 'success' &&
|
||||
(!inputs.desktop_macos || needs.make-distribution-macos.result == 'success') &&
|
||||
(!inputs.desktop_linux || needs.make-distribution-linux.result == 'success') &&
|
||||
(
|
||||
!inputs.require-windows-signing ||
|
||||
(
|
||||
@@ -457,11 +455,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download Artifacts (macos-x64)
|
||||
if: ${{ inputs.desktop_macos }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: affine-darwin-x64-builds
|
||||
path: ./release
|
||||
- name: Download Artifacts (macos-arm64)
|
||||
if: ${{ inputs.desktop_macos }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: affine-darwin-arm64-builds
|
||||
@@ -479,6 +479,7 @@ jobs:
|
||||
name: affine-win32-arm64-builds
|
||||
path: ./release
|
||||
- name: Download Artifacts (linux-x64)
|
||||
if: ${{ inputs.desktop_linux }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: affine-linux-x64-builds
|
||||
|
||||
@@ -109,12 +109,15 @@ jobs:
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 26.2
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
- name: Install Swiftformat
|
||||
run: brew install swiftformat
|
||||
- name: Cap sync
|
||||
run: yarn workspace @affine/ios sync
|
||||
- name: Signing By Apple Developer ID
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
uses: apple-actions/import-codesign-certs@v7
|
||||
id: import-codesign-certs
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
|
||||
@@ -131,8 +134,10 @@ jobs:
|
||||
printf '%s' "$BUILD_PROVISION_PROFILE" | base64 --decode -o "$PP_PATH"
|
||||
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
cp "$PP_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
fastlane beta
|
||||
bundle install
|
||||
bundle exec fastlane beta
|
||||
env:
|
||||
BUNDLE_PATH: vendor/bundle
|
||||
BUILD_TARGET: distribution
|
||||
BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }}
|
||||
PP_PATH: ${{ runner.temp }}/build_pp.mobileprovision
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
steps:
|
||||
- name: Decide whether to release
|
||||
id: decide
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
desktop_macos: ${{ github.event_name != 'workflow_dispatch' || inputs.desktop_macos }}
|
||||
desktop_windows: ${{ github.event_name != 'workflow_dispatch' || inputs.desktop_windows }}
|
||||
desktop_linux: ${{ github.event_name != 'workflow_dispatch' || inputs.desktop_linux }}
|
||||
require-windows-signing: ${{ needs.prepare.outputs.BUILD_TYPE == 'beta' || needs.prepare.outputs.BUILD_TYPE == 'stable' || (github.event_name == 'workflow_dispatch' && inputs.desktop_windows) }}
|
||||
require-windows-signing: ${{ needs.prepare.outputs.BUILD_TYPE == 'stable' || (github.event_name == 'workflow_dispatch' && inputs.desktop_windows) }}
|
||||
|
||||
mobile:
|
||||
name: Release Mobile
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
.yarn/versions
|
||||
.corepack-bin
|
||||
|
||||
# compiled output
|
||||
*dist
|
||||
@@ -49,6 +50,8 @@ testem.log
|
||||
tsconfig.tsbuildinfo
|
||||
.context
|
||||
/*.md
|
||||
.codex
|
||||
.cursor
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
@@ -93,3 +96,9 @@ af.cmd
|
||||
|
||||
# playwright
|
||||
storageState.json
|
||||
/.understand-anything
|
||||
|
||||
# local test/browser artifacts
|
||||
/.playwright-browsers/
|
||||
**/.vitest-attachments/
|
||||
/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
".git",
|
||||
".vscode",
|
||||
".context",
|
||||
".codex",
|
||||
".yarnrc.yml",
|
||||
".docker",
|
||||
"**/.storybook",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.git
|
||||
.vscode
|
||||
.context
|
||||
.codex
|
||||
.yarnrc.yml
|
||||
.docker
|
||||
**/.storybook
|
||||
|
||||
Generated
+1400
-575
File diff suppressed because it is too large
Load Diff
+4
-23
@@ -2,8 +2,6 @@
|
||||
members = [
|
||||
"./packages/backend/native",
|
||||
"./packages/common/native",
|
||||
"./packages/common/y-octo/core",
|
||||
"./packages/common/y-octo/utils",
|
||||
"./packages/frontend/mobile-native",
|
||||
"./packages/frontend/native",
|
||||
"./packages/frontend/native/nbstore",
|
||||
@@ -23,7 +21,7 @@ resolver = "3"
|
||||
anyhow = "1"
|
||||
arbitrary = { version = "1.3", features = ["derive"] }
|
||||
assert-json-diff = "2.0"
|
||||
async-lock = { version = "3.4.0", features = ["loom"] }
|
||||
base64 = "0.22.1"
|
||||
base64-simd = "0.8"
|
||||
bitvec = "1.0"
|
||||
block2 = "0.6"
|
||||
@@ -37,7 +35,7 @@ resolver = "3"
|
||||
criterion2 = { version = "3", default-features = false }
|
||||
crossbeam-channel = "0.5"
|
||||
dispatch2 = "0.3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||
doc_extractor = "0.1.0"
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
hex = "0.4"
|
||||
@@ -58,7 +56,6 @@ resolver = "3"
|
||||
llm_adapter = { version = "0.2", default-features = false }
|
||||
llm_runtime = { version = "0.2", default-features = false }
|
||||
log = "0.4"
|
||||
loom = { version = "0.7", features = ["checkpoint"] }
|
||||
lru = "0.16"
|
||||
matroska = "0.30"
|
||||
memory-indexer = "0.3.1"
|
||||
@@ -84,8 +81,6 @@ resolver = "3"
|
||||
ordered-float = "5"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
parking_lot = "0.12"
|
||||
path-ext = "0.1.2"
|
||||
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
|
||||
phf = { version = "0.11", features = ["macros"] }
|
||||
proptest = "1.3"
|
||||
proptest-derive = "0.5"
|
||||
@@ -94,9 +89,9 @@ resolver = "3"
|
||||
rand_chacha = "0.9"
|
||||
rand_distr = "0.5"
|
||||
rayon = "1.10"
|
||||
readability = { version = "0.3.0", default-features = false }
|
||||
regex = "1.10"
|
||||
rubato = "0.16"
|
||||
safefetch = "0.1.0"
|
||||
schemars = "0.8"
|
||||
screencapturekit = "0.3"
|
||||
serde = "1"
|
||||
@@ -111,24 +106,10 @@ resolver = "3"
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
] }
|
||||
strum_macros = "0.27.0"
|
||||
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
|
||||
text-splitter = "0.27"
|
||||
thiserror = "2"
|
||||
tiktoken-rs = "0.7"
|
||||
tokio = "1.45"
|
||||
tree-sitter = { version = "0.25" }
|
||||
tree-sitter-c = { version = "0.24" }
|
||||
tree-sitter-c-sharp = { version = "0.23" }
|
||||
tree-sitter-cpp = { version = "0.23" }
|
||||
tree-sitter-go = { version = "0.23" }
|
||||
tree-sitter-java = { version = "0.23" }
|
||||
tree-sitter-javascript = { version = "0.23" }
|
||||
tree-sitter-kotlin-ng = { version = "1.1" }
|
||||
tree-sitter-python = { version = "0.23" }
|
||||
tree-sitter-rust = { version = "0.24" }
|
||||
tree-sitter-scala = { version = "0.24" }
|
||||
tree-sitter-typescript = { version = "0.23" }
|
||||
typst = "0.14.2"
|
||||
typst-as-lib = { version = "0.15.4", default-features = false, features = [
|
||||
"packages",
|
||||
@@ -154,7 +135,7 @@ resolver = "3"
|
||||
"Win32_UI_Shell_PropertiesSystem",
|
||||
] }
|
||||
windows-core = { version = "0.61" }
|
||||
y-octo = { path = "./packages/common/y-octo/core" }
|
||||
y-octo = "0.0.3"
|
||||
y-sync = { version = "0.4" }
|
||||
yrs = "0.23.0"
|
||||
|
||||
|
||||
@@ -295,10 +295,10 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3",
|
||||
"version": "0.27.0",
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"msw": "^2.13.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,54 @@ Hello world
|
||||
expect(meta?.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('preserves list text inside blockquotes without list blocks', async () => {
|
||||
const markdown = `> **Shopping List:**
|
||||
> - Apples
|
||||
> - Bananas
|
||||
> - Oranges
|
||||
`;
|
||||
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||
const snapshot = await mdAdapter.toDocSnapshot({
|
||||
file: markdown,
|
||||
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
|
||||
});
|
||||
|
||||
expect(simplifyBlockForSnapshot(snapshot.blocks, new Map())).toMatchObject({
|
||||
children: [
|
||||
{
|
||||
flavour: 'affine:note',
|
||||
children: [
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'quote',
|
||||
delta: [
|
||||
{ insert: 'Shopping List:' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Apples' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Bananas' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Oranges' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const exported = await mdAdapter.fromDocSnapshot({
|
||||
snapshot,
|
||||
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
|
||||
});
|
||||
expect(exported.file).toContain('> **Shopping List:**');
|
||||
expect(exported.file).toContain('> \\- Apples');
|
||||
expect(exported.file).toContain('> \\- Bananas');
|
||||
expect(exported.file).toContain('> \\- Oranges');
|
||||
});
|
||||
|
||||
test('imports obsidian vault fixtures', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
|
||||
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
|
||||
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
|
||||
import {
|
||||
paintPlaceholder,
|
||||
syncCanvasSize,
|
||||
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
|
||||
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
|
||||
|
||||
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
|
||||
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
|
||||
window,
|
||||
'devicePixelRatio'
|
||||
);
|
||||
|
||||
function setDevicePixelRatio(value: number) {
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function createRect(width: number, height: number): DOMRect {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function createFakeBlockModel(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w = 10,
|
||||
h = 10
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
elementBound: new Bound(x, y, w, h),
|
||||
};
|
||||
}
|
||||
|
||||
type PaintPlaceholderForTest = (
|
||||
canvas: HTMLCanvasElement,
|
||||
layout: ViewportLayoutTree,
|
||||
viewport: {
|
||||
zoom: number;
|
||||
toViewCoord: (x: number, y: number) => [number, number];
|
||||
}
|
||||
) => void;
|
||||
|
||||
afterEach(() => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
|
||||
|
||||
if (originalDevicePixelRatio) {
|
||||
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('edgeless canvas budget', () => {
|
||||
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
|
||||
expect(
|
||||
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
|
||||
).toBe(true);
|
||||
|
||||
const shouldSyncCanvasBudgetOnViewportUpdate = (
|
||||
canvasRendererModule as {
|
||||
shouldSyncCanvasBudgetOnViewportUpdate: (
|
||||
previousZoom: number,
|
||||
nextZoom: number,
|
||||
rawDpr?: number
|
||||
) => boolean;
|
||||
}
|
||||
).shouldSyncCanvasBudgetOnViewportUpdate;
|
||||
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
|
||||
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
|
||||
});
|
||||
|
||||
test('enables low-zoom survival mode only for active iOS gestures', () => {
|
||||
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
|
||||
|
||||
const shouldUseLowZoomSurvivalMode = (
|
||||
canvasRendererModule as {
|
||||
shouldUseLowZoomSurvivalMode: (
|
||||
isIOS: boolean,
|
||||
zoom: number,
|
||||
gestureActive: boolean
|
||||
) => boolean;
|
||||
}
|
||||
).shouldUseLowZoomSurvivalMode;
|
||||
|
||||
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
|
||||
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
|
||||
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
|
||||
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
|
||||
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
|
||||
|
||||
const shouldRenderCanvasPlaceholders = (
|
||||
canvasRendererModule as {
|
||||
shouldRenderCanvasPlaceholders: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
turboEnabled: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldRenderCanvasPlaceholders;
|
||||
|
||||
expect(
|
||||
shouldRenderCanvasPlaceholders({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
isPanning: true,
|
||||
isZooming: false,
|
||||
skipRefreshDuringGesture: true,
|
||||
turboEnabled: true,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldRenderCanvasPlaceholders({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
isPanning: false,
|
||||
isZooming: true,
|
||||
skipRefreshDuringGesture: true,
|
||||
turboEnabled: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
|
||||
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
|
||||
expect(
|
||||
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
|
||||
).toBe(true);
|
||||
|
||||
const getStackingCanvasBypassState = (
|
||||
canvasRendererModule as {
|
||||
getStackingCanvasBypassState: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).getStackingCanvasBypassState;
|
||||
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
|
||||
canvasRendererModule as {
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldBypassStackingCanvasesDuringLowZoomGesture;
|
||||
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: false,
|
||||
recoveryActive: true,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: false,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: false,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 430,
|
||||
viewportHeight: 932,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: true,
|
||||
zoom: 0.6,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
getStackingCanvasBypassState({
|
||||
isIOS: false,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
|
||||
expect(
|
||||
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
|
||||
).toBe(true);
|
||||
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
|
||||
canvasRendererModule as {
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldBypassStackingCanvasesDuringLowZoomGesture;
|
||||
const getStackingCanvasAttachmentDiff = (
|
||||
canvasRendererModule as {
|
||||
getStackingCanvasAttachmentDiff: (params: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
wasAttached: boolean;
|
||||
shouldAttach: boolean;
|
||||
}) => {
|
||||
added: HTMLCanvasElement[];
|
||||
removed: HTMLCanvasElement[];
|
||||
};
|
||||
}
|
||||
).getStackingCanvasAttachmentDiff;
|
||||
|
||||
const canvases = [document.createElement('canvas')];
|
||||
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||
isIOS: true,
|
||||
zoom: 0.4,
|
||||
gestureActive: true,
|
||||
recoveryActive: false,
|
||||
viewportWidth: 932,
|
||||
viewportHeight: 430,
|
||||
});
|
||||
|
||||
expect(shouldBypass).toBe(true);
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: true,
|
||||
shouldAttach: !shouldBypass,
|
||||
})
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: canvases,
|
||||
});
|
||||
});
|
||||
|
||||
test('uses overscan for main-canvas fallback culling and render origin', () => {
|
||||
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
|
||||
|
||||
const getMainCanvasFallbackBounds = (
|
||||
canvasRendererModule as {
|
||||
getMainCanvasFallbackBounds: (params: {
|
||||
viewportBounds: Bound;
|
||||
overscanViewportBounds: Bound;
|
||||
}) => {
|
||||
cullBound: Bound;
|
||||
renderBound: Bound;
|
||||
};
|
||||
}
|
||||
).getMainCanvasFallbackBounds;
|
||||
|
||||
const viewportBounds = new Bound(100, 200, 300, 150);
|
||||
const overscanViewportBounds = new Bound(40, 170, 420, 210);
|
||||
|
||||
expect(
|
||||
getMainCanvasFallbackBounds({
|
||||
viewportBounds,
|
||||
overscanViewportBounds,
|
||||
})
|
||||
).toEqual({
|
||||
cullBound: overscanViewportBounds,
|
||||
renderBound: overscanViewportBounds,
|
||||
});
|
||||
});
|
||||
|
||||
test('lays out overscan canvases relative to the exact viewport', () => {
|
||||
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
|
||||
|
||||
const getCanvasViewportLayout = (
|
||||
canvasRendererModule as {
|
||||
getCanvasViewportLayout: (params: {
|
||||
bound: Bound;
|
||||
viewportBounds: Bound;
|
||||
zoom: number;
|
||||
viewScale: number;
|
||||
dpr: number;
|
||||
}) => {
|
||||
actualHeight: number;
|
||||
actualWidth: number;
|
||||
height: number;
|
||||
transform: string;
|
||||
width: number;
|
||||
};
|
||||
}
|
||||
).getCanvasViewportLayout;
|
||||
|
||||
expect(
|
||||
getCanvasViewportLayout({
|
||||
bound: new Bound(40, 170, 420, 210),
|
||||
viewportBounds: new Bound(100, 200, 300, 150),
|
||||
zoom: 1,
|
||||
viewScale: 1,
|
||||
dpr: 2,
|
||||
})
|
||||
).toEqual({
|
||||
actualHeight: 420,
|
||||
actualWidth: 840,
|
||||
height: 210,
|
||||
transform: 'translate(-60px, -30px) scale(1)',
|
||||
width: 420,
|
||||
});
|
||||
});
|
||||
|
||||
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
|
||||
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const getStackingCanvasAttachmentDiff = (
|
||||
canvasRendererModule as {
|
||||
getStackingCanvasAttachmentDiff: (params: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
wasAttached: boolean;
|
||||
shouldAttach: boolean;
|
||||
}) => {
|
||||
added: HTMLCanvasElement[];
|
||||
removed: HTMLCanvasElement[];
|
||||
};
|
||||
}
|
||||
).getStackingCanvasAttachmentDiff;
|
||||
|
||||
const canvasA = document.createElement('canvas');
|
||||
const canvasB = document.createElement('canvas');
|
||||
const canvases = [canvasA, canvasB];
|
||||
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: true,
|
||||
shouldAttach: false,
|
||||
})
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: canvases,
|
||||
});
|
||||
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: false,
|
||||
shouldAttach: true,
|
||||
})
|
||||
).toEqual({
|
||||
added: canvases,
|
||||
removed: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
getStackingCanvasAttachmentDiff({
|
||||
canvases,
|
||||
wasAttached: true,
|
||||
shouldAttach: true,
|
||||
})
|
||||
).toEqual({
|
||||
added: [],
|
||||
removed: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
|
||||
const viewport = new Viewport();
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const viewportUpdated = vi.fn();
|
||||
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
|
||||
let lastCanvasBudgetZoom = viewport.zoom;
|
||||
let budgetSyncCount = 0;
|
||||
|
||||
viewport.viewportUpdated.subscribe(viewportUpdated);
|
||||
|
||||
expect('zoomUpdated' in viewport).toBe(true);
|
||||
const zoomUpdated = (
|
||||
viewport as unknown as {
|
||||
zoomUpdated: {
|
||||
subscribe: (
|
||||
callback: (update: { previousZoom: number; zoom: number }) => void
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
).zoomUpdated;
|
||||
|
||||
zoomUpdated.subscribe(update => {
|
||||
zoomUpdates.push(update);
|
||||
if (
|
||||
(
|
||||
canvasRendererModule as {
|
||||
shouldSyncCanvasBudgetOnViewportUpdate: (
|
||||
previousZoom: number,
|
||||
nextZoom: number,
|
||||
rawDpr?: number
|
||||
) => boolean;
|
||||
}
|
||||
).shouldSyncCanvasBudgetOnViewportUpdate(
|
||||
lastCanvasBudgetZoom,
|
||||
update.zoom,
|
||||
2
|
||||
)
|
||||
) {
|
||||
budgetSyncCount += 1;
|
||||
}
|
||||
lastCanvasBudgetZoom = update.zoom;
|
||||
});
|
||||
|
||||
viewport.panning$.next(true);
|
||||
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
|
||||
|
||||
expect(viewportUpdated).not.toHaveBeenCalled();
|
||||
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
|
||||
expect(budgetSyncCount).toBe(1);
|
||||
|
||||
viewport.dispose();
|
||||
});
|
||||
|
||||
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
|
||||
const viewport = new Viewport();
|
||||
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||
|
||||
const viewportUpdated = vi.fn();
|
||||
const zoomUpdated = vi.fn();
|
||||
|
||||
viewport.viewportUpdated.subscribe(viewportUpdated);
|
||||
viewport.zoomUpdated.subscribe(zoomUpdated);
|
||||
|
||||
viewport.setZoom(0.4, { x: 0, y: 0 });
|
||||
|
||||
expect(viewportUpdated).toHaveBeenCalledTimes(1);
|
||||
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
|
||||
expect(viewport.panning$.value).toBe(false);
|
||||
expect(viewport.zooming$.value).toBe(false);
|
||||
|
||||
viewport.dispose();
|
||||
});
|
||||
|
||||
test('enables low-zoom block survival only while the gesture is still active', () => {
|
||||
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const shouldUseLowZoomBlockSurvivalMode = (
|
||||
viewportElementModule as {
|
||||
shouldUseLowZoomBlockSurvivalMode: (params: {
|
||||
zoom: number;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
gestureActive: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldUseLowZoomBlockSurvivalMode;
|
||||
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: true,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseLowZoomBlockSurvivalMode({
|
||||
zoom: 0.4,
|
||||
skipRefreshDuringGesture: true,
|
||||
gestureActive: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
|
||||
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
|
||||
|
||||
const getLowZoomGestureActiveModels = (
|
||||
viewportElementModule as {
|
||||
getLowZoomGestureActiveModels: (params: {
|
||||
selectedModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportBounds: Bound;
|
||||
nearbyActiveBlockLimit: number;
|
||||
nearbyDistanceRatio: number;
|
||||
}) => Set<{ id: string; elementBound: Bound }>;
|
||||
}
|
||||
).getLowZoomGestureActiveModels;
|
||||
|
||||
const selected = createFakeBlockModel('selected', 10, 10);
|
||||
const nearby = createFakeBlockModel('nearby', 28, 12);
|
||||
const far = createFakeBlockModel('far', 78, 78);
|
||||
|
||||
const activeModels = getLowZoomGestureActiveModels({
|
||||
selectedModels: new Set([selected]),
|
||||
viewportModels: new Set([selected, nearby, far]),
|
||||
viewportBounds: new Bound(0, 0, 100, 100),
|
||||
nearbyActiveBlockLimit: 1,
|
||||
nearbyDistanceRatio: 0.35,
|
||||
});
|
||||
|
||||
expect([...activeModels].map(model => model.id).sort()).toEqual([
|
||||
'nearby',
|
||||
'selected',
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to the nearest viewport block when nothing is selected', () => {
|
||||
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
|
||||
|
||||
const getLowZoomGestureActiveModels = (
|
||||
viewportElementModule as {
|
||||
getLowZoomGestureActiveModels: (params: {
|
||||
selectedModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportModels: Set<{ id: string; elementBound: Bound }>;
|
||||
viewportBounds: Bound;
|
||||
nearbyActiveBlockLimit: number;
|
||||
nearbyDistanceRatio: number;
|
||||
}) => Set<{ id: string; elementBound: Bound }>;
|
||||
}
|
||||
).getLowZoomGestureActiveModels;
|
||||
|
||||
const nearest = createFakeBlockModel('nearest', 46, 46);
|
||||
const farther = createFakeBlockModel('farther', 78, 78);
|
||||
|
||||
const activeModels = getLowZoomGestureActiveModels({
|
||||
selectedModels: new Set(),
|
||||
viewportModels: new Set([nearest, farther]),
|
||||
viewportBounds: new Bound(0, 0, 100, 100),
|
||||
nearbyActiveBlockLimit: 1,
|
||||
nearbyDistanceRatio: 0.35,
|
||||
});
|
||||
|
||||
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
|
||||
});
|
||||
|
||||
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
|
||||
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
|
||||
|
||||
const getPostGestureRecoveryDelay = (
|
||||
viewportModule as {
|
||||
getPostGestureRecoveryDelay: (params: {
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
fallbackDelayMs: number;
|
||||
}) => number;
|
||||
}
|
||||
).getPostGestureRecoveryDelay;
|
||||
|
||||
expect(
|
||||
getPostGestureRecoveryDelay({
|
||||
isPanning: false,
|
||||
isZooming: false,
|
||||
fallbackDelayMs: 220,
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
|
||||
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
|
||||
|
||||
const getPostGestureRecoveryDelay = (
|
||||
viewportModule as {
|
||||
getPostGestureRecoveryDelay: (params: {
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
fallbackDelayMs: number;
|
||||
}) => number;
|
||||
}
|
||||
).getPostGestureRecoveryDelay;
|
||||
|
||||
expect(
|
||||
getPostGestureRecoveryDelay({
|
||||
isPanning: true,
|
||||
isZooming: false,
|
||||
fallbackDelayMs: 220,
|
||||
})
|
||||
).toBe(220);
|
||||
expect(
|
||||
getPostGestureRecoveryDelay({
|
||||
isPanning: false,
|
||||
isZooming: true,
|
||||
fallbackDelayMs: 220,
|
||||
})
|
||||
).toBe(220);
|
||||
});
|
||||
|
||||
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
setDevicePixelRatio(2);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const host = document.createElement('div');
|
||||
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
|
||||
createRect(200, 100)
|
||||
);
|
||||
|
||||
(
|
||||
syncCanvasSize as unknown as (
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom: number
|
||||
) => void
|
||||
)(canvas, host, 0.4);
|
||||
|
||||
expect(canvas.width).toBe(200);
|
||||
expect(canvas.height).toBe(100);
|
||||
|
||||
(
|
||||
syncCanvasSize as unknown as (
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom: number
|
||||
) => void
|
||||
)(canvas, host, 0.95);
|
||||
|
||||
expect(canvas.width).toBe(400);
|
||||
expect(canvas.height).toBe(200);
|
||||
});
|
||||
|
||||
test('paints turbo placeholders with effective dpr at low zoom', () => {
|
||||
const previousTheme = document.documentElement.dataset.theme;
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
|
||||
try {
|
||||
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||
[0.5, 1],
|
||||
[0.8, 2],
|
||||
];
|
||||
setDevicePixelRatio(2);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const fillRect = vi.fn();
|
||||
const strokeRect = vi.fn();
|
||||
let fillStyle = '';
|
||||
let strokeStyle = '';
|
||||
vi.spyOn(canvas, 'getContext').mockReturnValue({
|
||||
get fillStyle() {
|
||||
return fillStyle;
|
||||
},
|
||||
set fillStyle(value: string) {
|
||||
fillStyle = value;
|
||||
},
|
||||
get strokeStyle() {
|
||||
return strokeStyle;
|
||||
},
|
||||
set strokeStyle(value: string) {
|
||||
strokeStyle = value;
|
||||
},
|
||||
fillRect,
|
||||
strokeRect,
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
|
||||
const layout: ViewportLayoutTree = {
|
||||
roots: [
|
||||
{
|
||||
blockId: 'root',
|
||||
type: 'affine:page',
|
||||
layout: {
|
||||
blockId: 'root',
|
||||
type: 'affine:page',
|
||||
rect: { x: 0, y: 0, w: 50, h: 20 },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
overallRect: { x: 0, y: 0, w: 50, h: 20 },
|
||||
};
|
||||
|
||||
const paintPlaceholderForTest =
|
||||
paintPlaceholder as unknown as PaintPlaceholderForTest;
|
||||
|
||||
paintPlaceholderForTest(canvas, layout, {
|
||||
zoom: 0.4,
|
||||
toViewCoord: () => [0, 0],
|
||||
});
|
||||
|
||||
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
|
||||
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
|
||||
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
|
||||
|
||||
paintPlaceholderForTest(canvas, layout, {
|
||||
zoom: 0.95,
|
||||
toViewCoord: () => [0, 0],
|
||||
});
|
||||
|
||||
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
|
||||
} finally {
|
||||
document.documentElement.dataset.theme = previousTheme;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAffinePlaceholderFillColor,
|
||||
getAffinePlaceholderStrokeColor,
|
||||
inferColorSchemeFromThemeMode,
|
||||
} from '../../../../shared/src/theme/placeholder-style.js';
|
||||
|
||||
describe('affine placeholder style', () => {
|
||||
it('returns subtle light placeholder colors', () => {
|
||||
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
|
||||
'rgba(0, 0, 0, 0.04)'
|
||||
);
|
||||
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
|
||||
'rgba(0, 0, 0, 0.02)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns subtle dark placeholder colors', () => {
|
||||
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
|
||||
'rgba(255, 255, 255, 0.08)'
|
||||
);
|
||||
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
|
||||
'rgba(255, 255, 255, 0.04)'
|
||||
);
|
||||
});
|
||||
|
||||
it('infers color scheme from theme mode', () => {
|
||||
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
|
||||
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
|
||||
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
|
||||
|
||||
describe('viewport turbo renderer policy', () => {
|
||||
test.each([
|
||||
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
|
||||
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
|
||||
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
|
||||
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
|
||||
])(
|
||||
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
|
||||
({ isIOS, zoom, hasBitmap, expected }) => {
|
||||
expect(
|
||||
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
|
||||
).toBe(true);
|
||||
|
||||
const shouldPreferBitmapCacheDuringLowZoomGesture = (
|
||||
turboRendererModule as {
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
hasBitmap: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldPreferBitmapCacheDuringLowZoomGesture;
|
||||
|
||||
expect(
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture({
|
||||
isIOS,
|
||||
zoom,
|
||||
hasBitmap,
|
||||
})
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ isIOS: true, zoom: 0.4, expected: false },
|
||||
{ isIOS: true, zoom: 0.8, expected: true },
|
||||
{ isIOS: false, zoom: 0.4, expected: true },
|
||||
])(
|
||||
'idles turbo blocks outside iOS low-zoom survival mode %#',
|
||||
({ isIOS, zoom, expected }) => {
|
||||
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
const shouldIdleTurboBlocksDuringZooming = (
|
||||
turboRendererModule as {
|
||||
shouldIdleTurboBlocksDuringZooming: (params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldIdleTurboBlocksDuringZooming;
|
||||
|
||||
expect(
|
||||
shouldIdleTurboBlocksDuringZooming({
|
||||
isIOS,
|
||||
zoom,
|
||||
})
|
||||
).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
@@ -7,7 +9,9 @@ export default defineConfig({
|
||||
},
|
||||
plugins: [vanillaExtractPlugin()],
|
||||
test: {
|
||||
globalSetup: '../../../scripts/vitest-global.js',
|
||||
globalSetup: fileURLToPath(
|
||||
new URL('../../../scripts/vitest-global.js', import.meta.url)
|
||||
),
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
HtmlAdapter,
|
||||
pasteMiddleware,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
LifeCycleWatcher,
|
||||
LifeCycleWatcherIdentifier,
|
||||
StdIdentifier,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
@@ -103,6 +105,30 @@ export class CodeBlockClipboardController extends LifeCycleWatcher {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
const textSelection = this.std.selection.find(TextSelection);
|
||||
const plainText = e.clipboardData
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
const selectedBlockId = textSelection?.from.blockId;
|
||||
const codeBlock = selectedBlockId
|
||||
? this.std.store.getBlock(selectedBlockId)?.model
|
||||
: null;
|
||||
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
|
||||
const richText = this.std.view
|
||||
.getBlock(selectedBlockId)
|
||||
?.querySelector<RichText>('rich-text');
|
||||
const inlineEditor = richText?.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (inlineEditor && inlineRange) {
|
||||
inlineEditor.insertText(inlineRange, plainText);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + plainText.length,
|
||||
length: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this.std.store.captureSync();
|
||||
this.std.command
|
||||
.chain()
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"date-fns": "^4.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lit": "^3.2.0",
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^3.25.76"
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ type Cell = {
|
||||
value: string | { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: ColumnDataType[],
|
||||
children: BlockSnapshot[],
|
||||
cells: SerializedCells
|
||||
columns: ColumnDataType[] = [],
|
||||
children: BlockSnapshot[] = [],
|
||||
cells: SerializedCells = {}
|
||||
): Table => {
|
||||
const table: Table = {
|
||||
headers: columns,
|
||||
@@ -90,13 +90,17 @@ export const processTable = (
|
||||
return;
|
||||
}
|
||||
let value: string | { delta: DeltaInsert[] };
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
try {
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
value = '';
|
||||
}
|
||||
row.cells.push({
|
||||
value,
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export class EmbedGithubBlockComponent extends EmbedBlockComponent<
|
||||
|
||||
override renderBlock() {
|
||||
const {
|
||||
title = 'GitHub',
|
||||
title,
|
||||
githubType,
|
||||
status,
|
||||
statusReason,
|
||||
@@ -139,7 +139,7 @@ export class EmbedGithubBlockComponent extends EmbedBlockComponent<
|
||||
? getGithubStatusIcon(githubType, status, statusReason)
|
||||
: nothing;
|
||||
const statusText = loading ? '' : status;
|
||||
const titleText = loading ? 'Loading...' : title;
|
||||
const titleText = loading ? 'Loading...' : title || 'GitHub';
|
||||
const descriptionText = loading ? '' : description;
|
||||
const bannerImage =
|
||||
!loading && image
|
||||
|
||||
@@ -89,14 +89,14 @@ export class EmbedLoomBlockComponent extends EmbedBlockComponent<
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const { image, title = 'Loom', description, videoId } = this.model.props;
|
||||
const { image, title, description, videoId } = this.model.props;
|
||||
|
||||
const loading = this.loading;
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const imageProxyService = this.store.get(ImageProxyService);
|
||||
const { EmbedCardBannerIcon } = getEmbedCardIcons(theme);
|
||||
const titleIcon = loading ? LoadingIcon() : LoomIcon;
|
||||
const titleText = loading ? 'Loading...' : title;
|
||||
const titleText = loading ? 'Loading...' : title || 'Loom';
|
||||
const descriptionText = loading ? '' : description;
|
||||
const bannerImage =
|
||||
!loading && image
|
||||
|
||||
@@ -96,21 +96,15 @@ export class EmbedYoutubeBlockComponent extends EmbedBlockComponent<
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const {
|
||||
image,
|
||||
title = 'YouTube',
|
||||
description,
|
||||
creator,
|
||||
creatorImage,
|
||||
videoId,
|
||||
} = this.model.props;
|
||||
const { image, title, description, creator, creatorImage, videoId } =
|
||||
this.model.props;
|
||||
|
||||
const loading = this.loading;
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const imageProxyService = this.store.get(ImageProxyService);
|
||||
const { EmbedCardBannerIcon } = getEmbedCardIcons(theme);
|
||||
const titleIcon = loading ? LoadingIcon() : YoutubeIcon;
|
||||
const titleText = loading ? 'Loading...' : title;
|
||||
const titleText = loading ? 'Loading...' : title || 'YouTube';
|
||||
const descriptionText = loading ? null : description;
|
||||
const bannerImage =
|
||||
!loading && image
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -276,7 +276,8 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
|
||||
override renderGfxBlock() {
|
||||
const blobUrl = this.blobUrl;
|
||||
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
|
||||
const { rotate, size: rawSize, caption = 'Image' } = this.model.props;
|
||||
const size = rawSize ?? 0;
|
||||
this._resetLodSource(blobUrl);
|
||||
|
||||
const containerStyleMap = styleMap({
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -47,5 +47,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||
isCalloutNode,
|
||||
type MarkdownAST,
|
||||
type MarkdownDeltaConverter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Heading } from 'mdast';
|
||||
import type { Blockquote, Heading, List, ListItem } from 'mdast';
|
||||
|
||||
/**
|
||||
* Extend the HeadingData type to include the collapsed property
|
||||
@@ -24,6 +25,131 @@ const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
|
||||
const isParagraphMDASTType = (node: MarkdownAST) =>
|
||||
PARAGRAPH_MDAST_TYPE.has(node.type);
|
||||
|
||||
const joinDeltaLines = (
|
||||
lines: DeltaInsert[][],
|
||||
prefix?: string
|
||||
): DeltaInsert[] => {
|
||||
const deltas: DeltaInsert[] = [];
|
||||
lines.forEach(line => {
|
||||
if (deltas.length) deltas.push({ insert: '\n' });
|
||||
if (prefix) deltas.push({ insert: prefix });
|
||||
deltas.push(...line);
|
||||
});
|
||||
return deltas;
|
||||
};
|
||||
|
||||
const flattenListItemToDelta = (
|
||||
node: ListItem,
|
||||
deltaConverter: MarkdownDeltaConverter,
|
||||
prefix: string,
|
||||
depth: number
|
||||
): DeltaInsert[] => {
|
||||
const firstParagraph = node.children[0];
|
||||
const lines: DeltaInsert[][] = [];
|
||||
if (firstParagraph?.type === 'paragraph') {
|
||||
lines.push([
|
||||
{ insert: prefix },
|
||||
...deltaConverter.astToDelta(firstParagraph),
|
||||
]);
|
||||
} else {
|
||||
lines.push([{ insert: prefix.trimEnd() }]);
|
||||
}
|
||||
node.children
|
||||
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
|
||||
.forEach(child => {
|
||||
const delta = flattenMarkdownBlockToDelta(
|
||||
child as MarkdownAST,
|
||||
deltaConverter,
|
||||
depth + 1
|
||||
);
|
||||
if (delta.length) {
|
||||
lines.push(delta);
|
||||
}
|
||||
});
|
||||
return joinDeltaLines(lines);
|
||||
};
|
||||
|
||||
const flattenMarkdownBlockToDelta = (
|
||||
node: MarkdownAST,
|
||||
deltaConverter: MarkdownDeltaConverter,
|
||||
depth = 0
|
||||
): DeltaInsert[] => {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
return deltaConverter.astToDelta(node);
|
||||
case 'list': {
|
||||
const list = node as List;
|
||||
return joinDeltaLines(
|
||||
list.children.map((item, index) => {
|
||||
const order = (list.start ?? 1) + index;
|
||||
const prefix =
|
||||
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
|
||||
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
|
||||
})
|
||||
);
|
||||
}
|
||||
case 'blockquote':
|
||||
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
|
||||
default:
|
||||
return 'children' in node
|
||||
? joinDeltaLines(
|
||||
(node.children as MarkdownAST[]).map(child =>
|
||||
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
};
|
||||
|
||||
const flattenBlockquoteToDelta = (
|
||||
node: Blockquote,
|
||||
deltaConverter: MarkdownDeltaConverter
|
||||
) =>
|
||||
joinDeltaLines(
|
||||
node.children.map(child =>
|
||||
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
|
||||
)
|
||||
);
|
||||
|
||||
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
|
||||
const text = (node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
return text.delta;
|
||||
};
|
||||
|
||||
const flattenSnapshotBlockToDelta = (
|
||||
node: BlockSnapshot,
|
||||
depth = 0
|
||||
): DeltaInsert[] => {
|
||||
if (node.flavour === 'affine:list') {
|
||||
const type = node.props.type;
|
||||
const order = (node.props.order as number | undefined) ?? 1;
|
||||
const prefix =
|
||||
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
|
||||
return joinDeltaLines([
|
||||
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
|
||||
...node.children.map(child =>
|
||||
flattenSnapshotBlockToDelta(child, depth + 1)
|
||||
),
|
||||
]);
|
||||
}
|
||||
return joinDeltaLines([
|
||||
getSnapshotTextDelta(node),
|
||||
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
|
||||
]);
|
||||
};
|
||||
|
||||
const flattenQuoteSnapshotToDelta = (
|
||||
text: DeltaInsert[],
|
||||
children: BlockSnapshot[]
|
||||
) =>
|
||||
joinDeltaLines([
|
||||
text,
|
||||
...children.map(child => flattenSnapshotBlockToDelta(child)),
|
||||
]);
|
||||
|
||||
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
@@ -93,7 +219,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
type: 'quote',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
delta: flattenBlockquoteToDelta(
|
||||
o.node as Blockquote,
|
||||
deltaConverter
|
||||
),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
@@ -160,6 +289,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
break;
|
||||
}
|
||||
case 'quote': {
|
||||
const quoteDelta = flattenQuoteSnapshotToDelta(
|
||||
text.delta,
|
||||
o.node.children
|
||||
);
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
@@ -171,12 +304,13 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: deltaConverter.deltaToAST(text.delta),
|
||||
children: deltaConverter.deltaToAST(quoteDelta),
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ParagraphHeadingIcon extends SignalWatcher(
|
||||
margin-top: 0.3em;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
transform: translateX(-64px);
|
||||
transform: translateX(-80px);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -101,6 +101,9 @@ export const ParagraphKeymapExtension = KeymapExtension(
|
||||
return true;
|
||||
},
|
||||
Enter: ctx => {
|
||||
const raw = ctx.get('keyboardState').raw;
|
||||
if (raw.isComposing) return;
|
||||
|
||||
const { store } = std;
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return;
|
||||
@@ -115,7 +118,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
|
||||
const raw = ctx.get('keyboardState').raw;
|
||||
const isEnd = model.props.text.length === inlineRange.index;
|
||||
|
||||
if (model.props.type === 'quote') {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
@@ -61,5 +61,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
currentCenter.y
|
||||
);
|
||||
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY));
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), false, true, true);
|
||||
|
||||
return false;
|
||||
})
|
||||
@@ -351,7 +351,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
);
|
||||
|
||||
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), true);
|
||||
viewport.setZoom(zoom, new Point(baseX, baseY), true, true, true);
|
||||
e.stopPropagation();
|
||||
}
|
||||
// pan
|
||||
@@ -484,7 +484,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
||||
.viewport=${this.gfx.viewport}
|
||||
.getModelsInViewport=${() => {
|
||||
const blocks = this.gfx.grid.search(
|
||||
this.gfx.viewport.viewportBounds,
|
||||
this.gfx.viewport.overscanBlockBounds,
|
||||
{
|
||||
useSet: true,
|
||||
filter: ['block'],
|
||||
|
||||
@@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockM
|
||||
.viewport=${this._gfx.viewport}
|
||||
.getModelsInViewport=${() => {
|
||||
const blocks = this._gfx.grid.search(
|
||||
this._gfx.viewport.viewportBounds,
|
||||
this._gfx.viewport.overscanBlockBounds,
|
||||
{
|
||||
useSet: true,
|
||||
filter: ['block'],
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_IOS } from '@blocksuite/global/env';
|
||||
import {
|
||||
Bound,
|
||||
getBoundWithRotation,
|
||||
@@ -18,7 +19,12 @@ import type {
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
getEffectiveDpr,
|
||||
getPostGestureRecoveryDelay,
|
||||
GfxControllerIdentifier,
|
||||
viewportRuntimeConfig,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import last from 'lodash-es/last';
|
||||
import { Subject } from 'rxjs';
|
||||
@@ -28,6 +34,7 @@ import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
|
||||
import { RoughCanvas } from '../utils/rough/canvas.js';
|
||||
import type { ElementRenderer } from './elements/index.js';
|
||||
import type { Overlay } from './overlay.js';
|
||||
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||
|
||||
type EnvProvider = {
|
||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||
@@ -116,6 +123,181 @@ type RefreshTarget =
|
||||
};
|
||||
|
||||
const STACKING_CANVAS_PADDING = 32;
|
||||
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
|
||||
|
||||
export function shouldSyncCanvasBudgetOnViewportUpdate(
|
||||
previousZoom: number,
|
||||
nextZoom: number,
|
||||
rawDpr = window.devicePixelRatio
|
||||
) {
|
||||
if (rawDpr <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
getEffectiveDpr(previousZoom, rawDpr) !== getEffectiveDpr(nextZoom, rawDpr)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldUseLowZoomSurvivalMode(
|
||||
isIOS: boolean,
|
||||
zoom: number,
|
||||
gestureActive: boolean
|
||||
) {
|
||||
return isIOS && gestureActive && zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD;
|
||||
}
|
||||
|
||||
export function getStackingCanvasBypassState(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) {
|
||||
const {
|
||||
isIOS,
|
||||
zoom,
|
||||
gestureActive,
|
||||
recoveryActive,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
} = params;
|
||||
|
||||
return (
|
||||
isIOS &&
|
||||
zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
(gestureActive || recoveryActive) &&
|
||||
viewportWidth > viewportHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldBypassStackingCanvasesDuringLowZoomGesture(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
gestureActive: boolean;
|
||||
recoveryActive: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}) {
|
||||
return getStackingCanvasBypassState(params);
|
||||
}
|
||||
|
||||
export function getStackingCanvasAttachmentDiff(params: {
|
||||
canvases: HTMLCanvasElement[];
|
||||
wasAttached: boolean;
|
||||
shouldAttach: boolean;
|
||||
}) {
|
||||
const { canvases, wasAttached, shouldAttach } = params;
|
||||
|
||||
if (wasAttached === shouldAttach) {
|
||||
return {
|
||||
added: [],
|
||||
removed: [],
|
||||
};
|
||||
}
|
||||
|
||||
return shouldAttach
|
||||
? {
|
||||
added: canvases,
|
||||
removed: [],
|
||||
}
|
||||
: {
|
||||
added: [],
|
||||
removed: canvases,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMainCanvasFallbackBounds(params: {
|
||||
viewportBounds: Bound;
|
||||
overscanViewportBounds: Bound;
|
||||
}) {
|
||||
const { overscanViewportBounds } = params;
|
||||
|
||||
return {
|
||||
cullBound: overscanViewportBounds,
|
||||
renderBound: overscanViewportBounds,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCanvasViewportLayout(params: {
|
||||
bound: Bound;
|
||||
viewportBounds: Bound;
|
||||
zoom: number;
|
||||
viewScale: number;
|
||||
dpr: number;
|
||||
}) {
|
||||
const { bound, viewportBounds, zoom, viewScale, dpr } = params;
|
||||
const width = bound.w * zoom;
|
||||
const height = bound.h * zoom;
|
||||
const left = (bound.x - viewportBounds.x) * zoom;
|
||||
const top = (bound.y - viewportBounds.y) * zoom;
|
||||
|
||||
return {
|
||||
actualHeight: Math.max(0, Math.ceil(height * dpr)),
|
||||
actualWidth: Math.max(0, Math.ceil(width * dpr)),
|
||||
height,
|
||||
transform: `translate(${left}px, ${top}px) scale(${1 / viewScale})`,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCanvasViewportLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
layout: ReturnType<typeof getCanvasViewportLayout>
|
||||
) {
|
||||
const width = `${layout.width}px`;
|
||||
const height = `${layout.height}px`;
|
||||
|
||||
if (canvas.style.left !== '0px') {
|
||||
canvas.style.left = '0px';
|
||||
}
|
||||
if (canvas.style.top !== '0px') {
|
||||
canvas.style.top = '0px';
|
||||
}
|
||||
if (canvas.style.width !== width) {
|
||||
canvas.style.width = width;
|
||||
}
|
||||
if (canvas.style.height !== height) {
|
||||
canvas.style.height = height;
|
||||
}
|
||||
if (canvas.style.transform !== layout.transform) {
|
||||
canvas.style.transform = layout.transform;
|
||||
}
|
||||
if (canvas.style.transformOrigin !== 'top left') {
|
||||
canvas.style.transformOrigin = 'top left';
|
||||
}
|
||||
if (canvas.width !== layout.actualWidth) {
|
||||
canvas.width = layout.actualWidth;
|
||||
}
|
||||
if (canvas.height !== layout.actualHeight) {
|
||||
canvas.height = layout.actualHeight;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderCanvasPlaceholders(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
isPanning: boolean;
|
||||
isZooming: boolean;
|
||||
skipRefreshDuringGesture: boolean;
|
||||
turboEnabled: boolean;
|
||||
}) {
|
||||
const {
|
||||
isIOS,
|
||||
zoom,
|
||||
isPanning,
|
||||
isZooming,
|
||||
skipRefreshDuringGesture,
|
||||
turboEnabled,
|
||||
} = params;
|
||||
|
||||
if (shouldUseLowZoomSurvivalMode(isIOS, zoom, isZooming)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !skipRefreshDuringGesture && turboEnabled && isZooming && !isPanning;
|
||||
}
|
||||
|
||||
export class CanvasRenderer {
|
||||
private _container!: HTMLElement;
|
||||
@@ -145,6 +327,19 @@ export class CanvasRenderer {
|
||||
|
||||
private _needsFullRender = true;
|
||||
|
||||
private _lastCanvasBudgetZoom = 1;
|
||||
|
||||
private _lastLowZoomSurvivalMode = false;
|
||||
|
||||
private _lastBypassStackingCanvases = false;
|
||||
|
||||
private _stackingCanvasesAttached = true;
|
||||
|
||||
private _stackingCanvasRecoveryUntil = 0;
|
||||
|
||||
private _stackingCanvasRecoveryTimerId: ReturnType<typeof setTimeout> | null =
|
||||
null;
|
||||
|
||||
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
|
||||
refreshCount: 0,
|
||||
coalescedRefreshCount: 0,
|
||||
@@ -189,6 +384,10 @@ export class CanvasRenderer {
|
||||
return this._stackingCanvas;
|
||||
}
|
||||
|
||||
get stackingCanvasesAttached() {
|
||||
return this._stackingCanvasesAttached;
|
||||
}
|
||||
|
||||
constructor(options: RendererOptions) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
@@ -196,6 +395,7 @@ export class CanvasRenderer {
|
||||
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
this.std = options.std;
|
||||
this.viewport = options.viewport;
|
||||
this._lastCanvasBudgetZoom = this.viewport.zoom;
|
||||
this.layerManager = options.layerManager;
|
||||
this.grid = options.gridManager;
|
||||
this.provider = options.provider ?? {};
|
||||
@@ -223,22 +423,28 @@ export class CanvasRenderer {
|
||||
*
|
||||
* It is not recommended to set width and height to 100%.
|
||||
*/
|
||||
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
|
||||
const { width, height, viewScale } = this.viewport;
|
||||
const actualWidth = Math.ceil(width * dpr);
|
||||
const actualHeight = Math.ceil(height * dpr);
|
||||
private _canvasSizeUpdater(
|
||||
bound = this.viewport.overscanViewportBounds,
|
||||
dpr = getEffectiveDpr(this.viewport.zoom)
|
||||
) {
|
||||
const layout = getCanvasViewportLayout({
|
||||
bound,
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
zoom: this.viewport.zoom,
|
||||
viewScale: this.viewport.viewScale,
|
||||
dpr,
|
||||
});
|
||||
|
||||
return {
|
||||
filter({ width, height }: HTMLCanvasElement) {
|
||||
return width !== actualWidth || height !== actualHeight;
|
||||
filter(canvas: HTMLCanvasElement) {
|
||||
return (
|
||||
canvas.width !== layout.actualWidth ||
|
||||
canvas.height !== layout.actualHeight ||
|
||||
canvas.style.transform !== layout.transform
|
||||
);
|
||||
},
|
||||
update(canvas: HTMLCanvasElement) {
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
canvas.style.transform = `scale(${1 / viewScale})`;
|
||||
canvas.style.transformOrigin = `top left`;
|
||||
canvas.width = actualWidth;
|
||||
canvas.height = actualHeight;
|
||||
applyCanvasViewportLayout(canvas, layout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -246,7 +452,7 @@ export class CanvasRenderer {
|
||||
private _applyStackingCanvasLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
bound: Bound | null,
|
||||
dpr = window.devicePixelRatio
|
||||
dpr = getEffectiveDpr(this.viewport.zoom)
|
||||
) {
|
||||
const state =
|
||||
this._stackingCanvasState.get(canvas) ??
|
||||
@@ -270,44 +476,18 @@ export class CanvasRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewportBounds, zoom, viewScale } = this.viewport;
|
||||
const width = bound.w * zoom;
|
||||
const height = bound.h * zoom;
|
||||
const left = (bound.x - viewportBounds.x) * zoom;
|
||||
const top = (bound.y - viewportBounds.y) * zoom;
|
||||
const actualWidth = Math.max(1, Math.ceil(width * dpr));
|
||||
const actualHeight = Math.max(1, Math.ceil(height * dpr));
|
||||
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
|
||||
const layout = getCanvasViewportLayout({
|
||||
bound,
|
||||
viewportBounds: this.viewport.viewportBounds,
|
||||
zoom: this.viewport.zoom,
|
||||
viewScale: this.viewport.viewScale,
|
||||
dpr,
|
||||
});
|
||||
|
||||
if (canvas.style.display !== 'block') {
|
||||
canvas.style.display = 'block';
|
||||
}
|
||||
if (canvas.style.left !== '0px') {
|
||||
canvas.style.left = '0px';
|
||||
}
|
||||
if (canvas.style.top !== '0px') {
|
||||
canvas.style.top = '0px';
|
||||
}
|
||||
if (canvas.style.width !== `${width}px`) {
|
||||
canvas.style.width = `${width}px`;
|
||||
}
|
||||
if (canvas.style.height !== `${height}px`) {
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
if (canvas.style.transform !== transform) {
|
||||
canvas.style.transform = transform;
|
||||
}
|
||||
if (canvas.style.transformOrigin !== 'top left') {
|
||||
canvas.style.transformOrigin = 'top left';
|
||||
}
|
||||
|
||||
if (canvas.width !== actualWidth) {
|
||||
canvas.width = actualWidth;
|
||||
}
|
||||
|
||||
if (canvas.height !== actualHeight) {
|
||||
canvas.height = actualHeight;
|
||||
}
|
||||
applyCanvasViewportLayout(canvas, layout);
|
||||
|
||||
state.bound = bound;
|
||||
state.layerId = canvas.dataset.layerId ?? null;
|
||||
@@ -434,6 +614,125 @@ export class CanvasRenderer {
|
||||
this._applyStackingCanvasLayout(canvas, null);
|
||||
}
|
||||
|
||||
private _syncStackingCanvasAttachment(shouldAttach: boolean) {
|
||||
const payloadDiff = getStackingCanvasAttachmentDiff({
|
||||
canvases: this._stackingCanvas,
|
||||
wasAttached: this._stackingCanvasesAttached,
|
||||
shouldAttach,
|
||||
});
|
||||
|
||||
this._stackingCanvasesAttached = shouldAttach;
|
||||
|
||||
if (!payloadDiff.added.length && !payloadDiff.removed.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stackingCanvasUpdated.next({
|
||||
canvases: this._stackingCanvas,
|
||||
...payloadDiff,
|
||||
});
|
||||
}
|
||||
|
||||
private _isStackingCanvasRecoveryActive() {
|
||||
return this._stackingCanvasRecoveryUntil > performance.now();
|
||||
}
|
||||
|
||||
private _clearStackingCanvasRecoveryTimer() {
|
||||
if (this._stackingCanvasRecoveryTimerId !== null) {
|
||||
clearTimeout(this._stackingCanvasRecoveryTimerId);
|
||||
this._stackingCanvasRecoveryTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleStackingCanvasRecoveryWindow(
|
||||
delayMs = viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY
|
||||
) {
|
||||
this._clearStackingCanvasRecoveryTimer();
|
||||
this._stackingCanvasRecoveryUntil = performance.now() + delayMs;
|
||||
this._stackingCanvasRecoveryTimerId = setTimeout(() => {
|
||||
this._stackingCanvasRecoveryTimerId = null;
|
||||
this._stackingCanvasRecoveryUntil = 0;
|
||||
if (this._container) {
|
||||
this._updatePlaceholderMode();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private _syncCanvasBudgetForViewportZoom() {
|
||||
const nextZoom = this.viewport.zoom;
|
||||
|
||||
if (
|
||||
!shouldSyncCanvasBudgetOnViewportUpdate(
|
||||
this._lastCanvasBudgetZoom,
|
||||
nextZoom
|
||||
)
|
||||
) {
|
||||
this._lastCanvasBudgetZoom = nextZoom;
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastCanvasBudgetZoom = nextZoom;
|
||||
this._resetSize();
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _updatePlaceholderMode() {
|
||||
const gestureActive =
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value;
|
||||
const recoveryActive = this._isStackingCanvasRecoveryActive();
|
||||
const lowZoomSurvivalMode = shouldUseLowZoomSurvivalMode(
|
||||
IS_IOS,
|
||||
this.viewport.zoom,
|
||||
gestureActive
|
||||
);
|
||||
const shouldBypassStackingCanvases =
|
||||
shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
gestureActive,
|
||||
recoveryActive,
|
||||
viewportWidth: this.viewport.width,
|
||||
viewportHeight: this.viewport.height,
|
||||
});
|
||||
const shouldRenderPlaceholders = shouldRenderCanvasPlaceholders({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
isPanning: this.viewport.panning$.value,
|
||||
isZooming: this.viewport.zooming$.value,
|
||||
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||
turboEnabled: this._turboEnabled(),
|
||||
});
|
||||
|
||||
const bypassModeChanged =
|
||||
this._lastBypassStackingCanvases !== shouldBypassStackingCanvases;
|
||||
|
||||
this._syncStackingCanvasAttachment(!shouldBypassStackingCanvases);
|
||||
|
||||
if (this.usePlaceholder === shouldRenderPlaceholders) {
|
||||
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
|
||||
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
|
||||
if (bypassModeChanged) {
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.usePlaceholder = shouldRenderPlaceholders;
|
||||
const survivalModeChanged =
|
||||
this._lastLowZoomSurvivalMode !== lowZoomSurvivalMode;
|
||||
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
|
||||
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
|
||||
|
||||
if (
|
||||
survivalModeChanged ||
|
||||
bypassModeChanged ||
|
||||
!this.viewport.SKIP_REFRESH_DURING_GESTURE ||
|
||||
!gestureActive
|
||||
) {
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
}
|
||||
|
||||
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
|
||||
const layer = this.layerManager;
|
||||
const updateStackingCanvas = () => {
|
||||
@@ -476,7 +775,9 @@ export class CanvasRenderer {
|
||||
};
|
||||
|
||||
if (diff > 0) {
|
||||
payload.added = canvases.slice(-diff);
|
||||
if (this._stackingCanvasesAttached) {
|
||||
payload.added = canvases.slice(-diff);
|
||||
}
|
||||
} else {
|
||||
payload.removed = currentCanvases.slice(diff);
|
||||
payload.removed.forEach(canvas => {
|
||||
@@ -485,7 +786,9 @@ export class CanvasRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
this.stackingCanvasUpdated.next(payload);
|
||||
if (payload.added.length || payload.removed.length) {
|
||||
this.stackingCanvasUpdated.next(payload);
|
||||
}
|
||||
}
|
||||
|
||||
this.refresh({ type: 'all' });
|
||||
@@ -503,41 +806,131 @@ export class CanvasRenderer {
|
||||
private _initViewport() {
|
||||
let sizeUpdatedRafId: number | null = null;
|
||||
|
||||
this._disposables.add({
|
||||
dispose: () => this._clearStackingCanvasRecoveryTimer(),
|
||||
});
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zoomUpdated.subscribe(() => {
|
||||
this._syncCanvasBudgetForViewportZoom();
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
this._updatePlaceholderMode();
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.refresh({ type: 'all' });
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => {
|
||||
if (
|
||||
IS_IOS &&
|
||||
this.viewport.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
this.viewport.width > this.viewport.height
|
||||
) {
|
||||
this._scheduleStackingCanvasRecoveryWindow();
|
||||
if (this._container) {
|
||||
this._updatePlaceholderMode();
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeUpdatedRafId) return;
|
||||
sizeUpdatedRafId = requestConnectedFrame(() => {
|
||||
sizeUpdatedRafId = null;
|
||||
this._resetSize();
|
||||
this._render();
|
||||
// When SKIP_REFRESH_DURING_GESTURE is active, schedule the render
|
||||
// after a short delay to let the layout settle on orientation change,
|
||||
// avoiding a white-flash from resizing + rendering in the same frame.
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
setTimeout(() => this._render(), 16);
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
}, this._container);
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(isZooming => {
|
||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||
|
||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||
this.usePlaceholder = shouldRenderPlaceholders;
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
this.viewport.zooming$.subscribe(() => {
|
||||
this._updatePlaceholderMode();
|
||||
})
|
||||
);
|
||||
|
||||
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer heavy canvas work
|
||||
// while the gesture is still in-flight, but start the first recovery frame
|
||||
// immediately once both gesture signals have fully settled.
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingCanvasTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelPendingCanvasRefresh = () => {
|
||||
if (pendingCanvasTimerId !== null) {
|
||||
clearTimeout(pendingCanvasTimerId);
|
||||
pendingCanvasTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleCanvasRefresh = () => {
|
||||
cancelPendingCanvasRefresh();
|
||||
const delayMs = getPostGestureRecoveryDelay({
|
||||
isPanning: this.viewport.panning$.value,
|
||||
isZooming: this.viewport.zooming$.value,
|
||||
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
|
||||
});
|
||||
pendingCanvasTimerId = setTimeout(() => {
|
||||
pendingCanvasTimerId = null;
|
||||
// If a gesture is still in-flight when the timer fires, reschedule
|
||||
// instead of dropping. Dropping here left connectors blank until a
|
||||
// tap forced a synchronous refresh.
|
||||
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||
scheduleCanvasRefresh();
|
||||
return;
|
||||
}
|
||||
this.refresh({ type: 'all' });
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
this._updatePlaceholderMode();
|
||||
if (panning) {
|
||||
cancelPendingCanvasRefresh();
|
||||
} else {
|
||||
scheduleCanvasRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
this._updatePlaceholderMode();
|
||||
if (zooming) {
|
||||
cancelPendingCanvasRefresh();
|
||||
} else {
|
||||
scheduleCanvasRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add({ dispose: cancelPendingCanvasRefresh });
|
||||
}
|
||||
|
||||
let wasDragging = false;
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
const isDragging = this._gfx.tool.dragging$.value;
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
this.refresh({ type: 'all' });
|
||||
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||
// Deferred refresh will handle it after gesture ends
|
||||
} else {
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
}
|
||||
|
||||
wasDragging = isDragging;
|
||||
@@ -572,16 +965,34 @@ export class CanvasRenderer {
|
||||
|
||||
private _render() {
|
||||
const renderStart = performance.now();
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
const { overscanViewportBounds, viewportBounds, zoom } = this.viewport;
|
||||
const {
|
||||
cullBound: mainCanvasCullBound,
|
||||
renderBound: mainCanvasRenderBound,
|
||||
} = getMainCanvasFallbackBounds({
|
||||
viewportBounds,
|
||||
overscanViewportBounds,
|
||||
});
|
||||
const { ctx } = this;
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(zoom);
|
||||
const scale = zoom * dpr;
|
||||
const matrix = new DOMMatrix().scaleSelf(scale);
|
||||
const renderStats = this._createRenderPassStats();
|
||||
const fullRender = this._needsFullRender;
|
||||
const stackingIndexesToRender = fullRender
|
||||
? this._stackingCanvas.map((_, idx) => idx)
|
||||
: [...this._dirtyStackingCanvasIndexes];
|
||||
const bypassStackingCanvases = getStackingCanvasBypassState({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
gestureActive:
|
||||
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||
recoveryActive: this._isStackingCanvasRecoveryActive(),
|
||||
viewportWidth: this.viewport.width,
|
||||
viewportHeight: this.viewport.height,
|
||||
});
|
||||
const stackingIndexesToRender = bypassStackingCanvases
|
||||
? []
|
||||
: fullRender
|
||||
? this._stackingCanvas.map((_, idx) => idx)
|
||||
: [...this._dirtyStackingCanvasIndexes];
|
||||
/**
|
||||
* if a layer does not have a corresponding canvas
|
||||
* its element will be add to this array and drawing on the
|
||||
@@ -589,7 +1000,15 @@ export class CanvasRenderer {
|
||||
*/
|
||||
let fallbackElement: SurfaceElementModel[] = [];
|
||||
const allCanvasLayers = this.layerManager.getCanvasLayers();
|
||||
const viewportBound = Bound.from(viewportBounds);
|
||||
const stackingViewportBound = Bound.from(overscanViewportBounds);
|
||||
|
||||
this._canvasSizeUpdater(mainCanvasRenderBound, dpr).update(this.canvas);
|
||||
|
||||
if (bypassStackingCanvases) {
|
||||
this._stackingCanvas.forEach(canvas => {
|
||||
this._applyStackingCanvasLayout(canvas, null, dpr);
|
||||
});
|
||||
}
|
||||
|
||||
for (const idx of stackingIndexesToRender) {
|
||||
const layer = allCanvasLayers[idx];
|
||||
@@ -601,7 +1020,7 @@ export class CanvasRenderer {
|
||||
|
||||
const layerRenderBound = this._getLayerRenderBound(
|
||||
layer.elements,
|
||||
viewportBound
|
||||
stackingViewportBound
|
||||
);
|
||||
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
|
||||
canvas,
|
||||
@@ -638,7 +1057,12 @@ export class CanvasRenderer {
|
||||
|
||||
if (fullRender || this._mainCanvasDirty) {
|
||||
allCanvasLayers.forEach((layer, idx) => {
|
||||
if (!this._stackingCanvas[idx]) {
|
||||
if (
|
||||
bypassStackingCanvases ||
|
||||
!this._stackingCanvas[idx] ||
|
||||
this._stackingCanvas[idx].width === 0 ||
|
||||
this._stackingCanvas[idx].height === 0
|
||||
) {
|
||||
fallbackElement = fallbackElement.concat(layer.elements);
|
||||
}
|
||||
});
|
||||
@@ -651,10 +1075,11 @@ export class CanvasRenderer {
|
||||
ctx,
|
||||
matrix,
|
||||
new RoughCanvas(ctx.canvas),
|
||||
viewportBounds,
|
||||
mainCanvasRenderBound,
|
||||
fallbackElement,
|
||||
true,
|
||||
renderStats
|
||||
renderStats,
|
||||
mainCanvasCullBound
|
||||
);
|
||||
}
|
||||
|
||||
@@ -726,7 +1151,8 @@ export class CanvasRenderer {
|
||||
bound: IBound,
|
||||
surfaceElements?: SurfaceElementModel[],
|
||||
overLay: boolean = false,
|
||||
renderStats?: RenderPassStats
|
||||
renderStats?: RenderPassStats,
|
||||
cullBound: IBound = bound
|
||||
) {
|
||||
if (!ctx) return;
|
||||
|
||||
@@ -734,13 +1160,13 @@ export class CanvasRenderer {
|
||||
|
||||
const elements =
|
||||
surfaceElements ??
|
||||
(this.grid.search(bound, {
|
||||
(this.grid.search(cullBound, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[]);
|
||||
|
||||
for (const element of elements) {
|
||||
const display = (element.display ?? true) && !element.hidden;
|
||||
if (display && intersects(getBoundWithRotation(element), bound)) {
|
||||
if (display && intersects(getBoundWithRotation(element), cullBound)) {
|
||||
renderStats && (renderStats.visibleElementCount += 1);
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
@@ -748,7 +1174,7 @@ export class CanvasRenderer {
|
||||
) {
|
||||
renderStats && (renderStats.placeholderElementCount += 1);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||
ctx.fillStyle = resolveSurfacePlaceholderColor(this.getColorScheme());
|
||||
const drawX = element.x - bound.x;
|
||||
const drawY = element.y - bound.y;
|
||||
ctx.fillRect(drawX, drawY, element.w, element.h);
|
||||
@@ -785,9 +1211,12 @@ export class CanvasRenderer {
|
||||
}
|
||||
|
||||
private _resetSize() {
|
||||
const sizeUpdater = this._canvasSizeUpdater();
|
||||
const sizeUpdater = this._canvasSizeUpdater(
|
||||
this.viewport.overscanViewportBounds
|
||||
);
|
||||
|
||||
sizeUpdater.update(this.canvas);
|
||||
this._lastCanvasBudgetZoom = this.viewport.zoom;
|
||||
this._invalidate({ type: 'all' });
|
||||
}
|
||||
|
||||
@@ -838,6 +1267,7 @@ export class CanvasRenderer {
|
||||
this._container = container;
|
||||
container.append(this.canvas);
|
||||
|
||||
this._updatePlaceholderMode();
|
||||
this._resetSize();
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
@@ -864,8 +1294,11 @@ export class CanvasRenderer {
|
||||
canvas = canvas || document.createElement('canvas');
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
|
||||
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
|
||||
const actualWidth = Math.ceil(bound.w * dpr);
|
||||
const actualHeight = Math.ceil(bound.h * dpr);
|
||||
|
||||
if (canvas.width !== actualWidth) canvas.width = actualWidth;
|
||||
if (canvas.height !== actualHeight) canvas.height = actualHeight;
|
||||
|
||||
canvas.style.width = `${bound.w}px`;
|
||||
canvas.style.height = `${bound.h}px`;
|
||||
|
||||
@@ -19,12 +19,14 @@ import type {
|
||||
SurfaceBlockModel,
|
||||
Viewport,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { SurfaceElementModel } from '../element-model/base.js';
|
||||
import type { DomElementRenderer } from './dom-elements/index.js';
|
||||
import { DomElementRendererIdentifier } from './dom-elements/index.js';
|
||||
import type { Overlay } from './overlay.js';
|
||||
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||
|
||||
type EnvProvider = {
|
||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||
@@ -222,6 +224,12 @@ export class DomRenderer {
|
||||
private _initViewport() {
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
@@ -242,6 +250,9 @@ export class DomRenderer {
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(isZooming => {
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
return;
|
||||
}
|
||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||
|
||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||
@@ -252,6 +263,43 @@ export class DomRenderer {
|
||||
})
|
||||
);
|
||||
|
||||
// Post-gesture refresh for SKIP mode
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
cancelRefresh();
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
}
|
||||
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) cancelRefresh();
|
||||
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) cancelRefresh();
|
||||
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this._disposables.add({ dispose: cancelRefresh });
|
||||
}
|
||||
|
||||
this.usePlaceholder = false;
|
||||
}
|
||||
|
||||
@@ -292,12 +340,15 @@ export class DomRenderer {
|
||||
domElement = document.createElement('div');
|
||||
domElement.dataset.elementId = elementModel.id;
|
||||
domElement.style.position = 'absolute';
|
||||
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
|
||||
this._elementsMap.set(elementModel.id, domElement);
|
||||
this.rootElement.append(domElement);
|
||||
addedElements.push(domElement);
|
||||
}
|
||||
|
||||
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
|
||||
this.getColorScheme()
|
||||
);
|
||||
|
||||
const geometricStyles = calculatePlaceholderRect(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ColorScheme } from '@blocksuite/affine-model';
|
||||
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||
|
||||
export function getSurfacePlaceholderFallback(colorScheme: ColorScheme) {
|
||||
return getAffinePlaceholderFillColor(colorScheme);
|
||||
}
|
||||
|
||||
export function resolveSurfacePlaceholderColor(colorScheme: ColorScheme) {
|
||||
return getSurfacePlaceholderFallback(colorScheme);
|
||||
}
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -527,6 +527,9 @@ export class SelectionController implements ReactiveController {
|
||||
removeNativeSelection = true
|
||||
) {
|
||||
if (selection) {
|
||||
if (this.hasExternalNativeSelection()) {
|
||||
return;
|
||||
}
|
||||
const previous = this.getSelected();
|
||||
if (TableSelectionData.equals(previous, selection)) {
|
||||
return;
|
||||
@@ -551,4 +554,24 @@ export class SelectionController implements ReactiveController {
|
||||
);
|
||||
return selection?.is(TableSelection) ? selection.data : undefined;
|
||||
}
|
||||
|
||||
private hasExternalNativeSelection() {
|
||||
const selection = getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.intersectsNode(this.host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchorNode;
|
||||
const focusNode = selection.focusNode;
|
||||
return (
|
||||
!!anchorNode &&
|
||||
!!focusNode &&
|
||||
(!this.host.contains(anchorNode) || !this.host.contains(focusNode))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
const externalRangeSelectionSelector =
|
||||
'affine-table[data-external-range-selection]';
|
||||
const hiddenSelectionBackground = '#fff';
|
||||
|
||||
export const tableContainer = css({
|
||||
display: 'block',
|
||||
padding: '10px 0 18px 10px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'visible',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
'& *': {
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
},
|
||||
[`${externalRangeSelectionSelector} &::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
[`${externalRangeSelectionSelector} & *::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
[`${externalRangeSelectionSelector} & rich-text::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
[`${externalRangeSelectionSelector} & rich-text *::selection`]: {
|
||||
backgroundColor: hiddenSelectionBackground,
|
||||
},
|
||||
'::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
|
||||
@@ -5,7 +5,10 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import type { BlockComponent } from '@blocksuite/std';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import {
|
||||
RANGE_QUERY_EXCLUDE_ATTR,
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
} from '@blocksuite/std/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
@@ -37,7 +40,80 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true');
|
||||
this.style.position = 'relative';
|
||||
const doc = this.ownerDocument;
|
||||
this.disposables.addFromEvent(doc, 'selectionchange', () => {
|
||||
const hasExternalNativeSelection = this.hasExternalNativeSelection();
|
||||
this.toggleAttribute(
|
||||
'data-external-range-selection',
|
||||
hasExternalNativeSelection
|
||||
);
|
||||
if (hasExternalNativeSelection) {
|
||||
delete this.dataset.internalRangeSelection;
|
||||
}
|
||||
this.setInternalEditablesEnabled(!hasExternalNativeSelection);
|
||||
});
|
||||
this.disposables.addFromEvent(
|
||||
doc,
|
||||
'pointerdown',
|
||||
event => {
|
||||
const target = event.target;
|
||||
const NodeConstructor = this.ownerDocument.defaultView?.Node;
|
||||
if (
|
||||
NodeConstructor &&
|
||||
target instanceof NodeConstructor &&
|
||||
this.contains(target)
|
||||
) {
|
||||
this.setInternalEditablesEnabled(true);
|
||||
if (this.hasExternalNativeSelection()) {
|
||||
this.ownerDocument.getSelection()?.removeAllRanges();
|
||||
}
|
||||
delete this.dataset.externalRangeSelection;
|
||||
this.dataset.internalRangeSelection = 'true';
|
||||
} else {
|
||||
delete this.dataset.internalRangeSelection;
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
|
||||
private setInternalEditablesEnabled(enabled: boolean) {
|
||||
this.querySelectorAll<HTMLElement>('.inline-editor').forEach(editor => {
|
||||
if (enabled) {
|
||||
if (editor.dataset.tableExternalSelectionDisabled === 'true') {
|
||||
editor.contentEditable = 'true';
|
||||
delete editor.dataset.tableExternalSelectionDisabled;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.contentEditable === 'true') {
|
||||
editor.contentEditable = 'false';
|
||||
editor.dataset.tableExternalSelectionDisabled = 'true';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasExternalNativeSelection() {
|
||||
const selection = this.ownerDocument.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.intersectsNode(this)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorNode = selection.anchorNode;
|
||||
const focusNode = selection.focusNode;
|
||||
return (
|
||||
!!anchorNode &&
|
||||
!!focusNode &&
|
||||
(!this.contains(anchorNode) || !this.contains(focusNode))
|
||||
);
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
|
||||
@@ -10,6 +10,18 @@ export const cellContainerStyle = css({
|
||||
isolation: 'auto',
|
||||
textAlign: 'start',
|
||||
verticalAlign: 'top',
|
||||
'affine-table[data-internal-range-selection="true"] &': {
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
'affine-table[data-internal-range-selection="true"] & rich-text': {
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
'affine-table[data-internal-range-selection="true"] & rich-text *': {
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
export const columnOptionsCellStyle = css({
|
||||
|
||||
@@ -649,12 +649,9 @@ export class TableCell extends SignalWatcher(
|
||||
}
|
||||
|
||||
private readonly _handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
if (e.key !== 'Escape' && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
||||
import { TableBlockSchema } from '@blocksuite/affine-model';
|
||||
import { KeymapExtension } from '@blocksuite/std';
|
||||
|
||||
export const TableKeymapExtension = KeymapExtension(textKeymap, {
|
||||
flavour: TableBlockSchema.model.flavour,
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { tableSlashMenuConfig } from './configs/slash-menu';
|
||||
import { effects } from './effects';
|
||||
import { TableKeymapExtension } from './table-keymap.js';
|
||||
|
||||
export class TableViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-table-block';
|
||||
@@ -22,6 +23,7 @@ export class TableViewExtension extends ViewExtensionProvider {
|
||||
super.setup(context);
|
||||
context.register([
|
||||
FlavourExtension(TableModelFlavour),
|
||||
TableKeymapExtension,
|
||||
BlockViewExtension(TableModelFlavour, literal`affine-table`),
|
||||
SlashMenuConfigExtension(TableModelFlavour, tableSlashMenuConfig),
|
||||
]);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"date-fns": "^4.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lit": "^3.2.0",
|
||||
"lit-html": "^3.2.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
@@ -74,5 +74,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
sortByManually,
|
||||
} from '../../core/group-by/trait.js';
|
||||
import { fromJson } from '../../core/property/utils';
|
||||
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||
import type { Row } from '../../core/view-manager/row.js';
|
||||
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
||||
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
||||
@@ -92,6 +94,19 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
return this.data$.value?.filter ?? emptyFilterGroup;
|
||||
});
|
||||
|
||||
private readonly sortList$ = computed(() => {
|
||||
return this.data$.value?.sort;
|
||||
});
|
||||
|
||||
private readonly sortManager = this.traitSet(
|
||||
sortTraitKey,
|
||||
new SortManager(this.sortList$, this, {
|
||||
setSortList: sortList => {
|
||||
this.dataUpdate(data => ({ sort: { ...data.sort, ...sortList } }));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
filterTrait = this.traitSet(
|
||||
filterTraitKey,
|
||||
new FilterTrait(this.filter$, this, {
|
||||
@@ -140,6 +155,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
return asc === false ? sorted.reverse() : sorted;
|
||||
},
|
||||
sortRow: (key, rows) => {
|
||||
if (this.sortManager.hasSort$.value) return rows;
|
||||
const property = this.view?.groupProperties.find(v => v.key === key);
|
||||
return sortByManually(
|
||||
rows,
|
||||
@@ -359,6 +375,10 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override rowsMapping(rows: Row[]): Row[] {
|
||||
return this.sortManager.sort(super.rowsMapping(rows));
|
||||
}
|
||||
|
||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||
return new KanbanColumn(this, columnId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -5,7 +7,9 @@ export default defineConfig({
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
globalSetup: '../../scripts/vitest-global.js',
|
||||
globalSetup: fileURLToPath(
|
||||
new URL('../../../scripts/vitest-global.js', import.meta.url)
|
||||
),
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
@@ -26,5 +26,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -32,5 +32,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -32,5 +32,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type LocalConnectorElementModel,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
getBezierParameters,
|
||||
type PointLocation,
|
||||
@@ -253,7 +254,7 @@ function renderLabel(
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
if (renderer.usePlaceholder) {
|
||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return; // Skip actual label rendering
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -39,5 +39,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -5,7 +7,9 @@ export default defineConfig({
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
globalSetup: '../../../scripts/vitest-global.js',
|
||||
globalSetup: fileURLToPath(
|
||||
new URL('../../../../scripts/vitest-global.js', import.meta.url)
|
||||
),
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -119,15 +119,14 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
|
||||
private _setLayoutMethod() {
|
||||
this.model.setLayoutMethod(function (
|
||||
this: MindmapElementModel,
|
||||
tree: MindmapNode | MindmapRoot = this.tree,
|
||||
options: {
|
||||
applyStyle?: boolean;
|
||||
layoutType?: LayoutType;
|
||||
stashed?: boolean;
|
||||
} = {
|
||||
applyStyle: true,
|
||||
stashed: true,
|
||||
}
|
||||
tree: MindmapNode | MindmapRoot | undefined,
|
||||
options:
|
||||
| {
|
||||
applyStyle?: boolean;
|
||||
layoutType?: LayoutType;
|
||||
stashed?: boolean;
|
||||
}
|
||||
| undefined
|
||||
) {
|
||||
const { stashed, applyStyle, layoutType } = Object.assign(
|
||||
{
|
||||
@@ -137,9 +136,10 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
|
||||
},
|
||||
options
|
||||
);
|
||||
const targetTree = tree ?? this.tree;
|
||||
|
||||
const pop = stashed ? this.stashTree(tree) : null;
|
||||
handleLayout(this, tree, applyStyle, layoutType);
|
||||
const pop = stashed ? this.stashTree(targetTree) : null;
|
||||
handleLayout(this, targetTree, applyStyle, layoutType);
|
||||
pop?.();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"lit": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -34,5 +34,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -5,7 +7,9 @@ export default defineConfig({
|
||||
target: 'es2018',
|
||||
},
|
||||
test: {
|
||||
globalSetup: '../../../scripts/vitest-global.js',
|
||||
globalSetup: fileURLToPath(
|
||||
new URL('../../../../scripts/vitest-global.js', import.meta.url)
|
||||
),
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
@@ -25,5 +26,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
getAffinePlaceholderFillColor,
|
||||
getAffinePlaceholderStrokeColor,
|
||||
inferColorSchemeFromThemeMode,
|
||||
} from '@blocksuite/affine-shared/theme';
|
||||
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
|
||||
import { type Viewport } from '@blocksuite/std/gfx';
|
||||
import { getEffectiveDpr, type Viewport } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
|
||||
@@ -10,9 +15,13 @@ import type {
|
||||
ViewportLayoutTree,
|
||||
} from './types';
|
||||
|
||||
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
|
||||
export function syncCanvasSize(
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom = 1
|
||||
) {
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(zoom);
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.left = '0px';
|
||||
canvas.style.top = '0px';
|
||||
@@ -186,21 +195,21 @@ export function paintPlaceholder(
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || !layout) return;
|
||||
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(viewport.zoom);
|
||||
const { overallRect } = layout;
|
||||
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
|
||||
|
||||
const offsetX = layoutViewCoord[0];
|
||||
const offsetY = layoutViewCoord[1];
|
||||
const colors = [
|
||||
'rgba(200, 200, 200, 0.7)',
|
||||
'rgba(180, 180, 180, 0.7)',
|
||||
'rgba(160, 160, 160, 0.7)',
|
||||
];
|
||||
const colorScheme = inferColorSchemeFromThemeMode(
|
||||
document.documentElement.dataset.theme
|
||||
);
|
||||
const fillColor = getAffinePlaceholderFillColor(colorScheme);
|
||||
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
|
||||
|
||||
const paintNode = (node: BlockLayoutTreeNode, depth: number = 0) => {
|
||||
const paintNode = (node: BlockLayoutTreeNode) => {
|
||||
const { layout: nodeLayout } = node;
|
||||
ctx.fillStyle = colors[depth % colors.length];
|
||||
ctx.fillStyle = fillColor;
|
||||
const rect = nodeLayout.rect;
|
||||
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
|
||||
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
|
||||
@@ -209,12 +218,12 @@ export function paintPlaceholder(
|
||||
|
||||
ctx.fillRect(x, y, width, height);
|
||||
if (width > 10 && height > 5) {
|
||||
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
node.children.forEach(childNode => paintNode(childNode, depth + 1));
|
||||
node.children.forEach(childNode => paintNode(childNode));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_IOS } from '@blocksuite/global/env';
|
||||
import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
import {
|
||||
getEffectiveDpr,
|
||||
type GfxController,
|
||||
GfxExtension,
|
||||
GfxExtensionIdentifier,
|
||||
type GfxViewportElement,
|
||||
viewportRuntimeConfig,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -34,6 +37,26 @@ import type {
|
||||
} from './types';
|
||||
|
||||
const debug = false; // Toggle for debug logs
|
||||
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
|
||||
|
||||
export function shouldPreferBitmapCacheDuringLowZoomGesture(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
hasBitmap: boolean;
|
||||
}) {
|
||||
return (
|
||||
params.isIOS &&
|
||||
params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
params.hasBitmap
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldIdleTurboBlocksDuringZooming(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
}) {
|
||||
return !(params.isIOS && params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD);
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
|
||||
@@ -147,7 +170,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
|
||||
this.viewportElement = element;
|
||||
syncCanvasSize(this.canvas, this.std.host);
|
||||
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||
this.state$.next('pending');
|
||||
|
||||
this.disposables.add(
|
||||
@@ -156,6 +179,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.refresh().catch(console.error);
|
||||
})
|
||||
);
|
||||
@@ -166,7 +195,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
tap(isZooming => {
|
||||
this.debugLog(`Zooming signal changed: ${isZooming}`);
|
||||
if (isZooming) {
|
||||
this.state$.next('zooming');
|
||||
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
this.state$.next('zooming');
|
||||
}
|
||||
} else if (this.state$.value === 'zooming') {
|
||||
this.clearOptimizedBlocks();
|
||||
this.isRecentlyZoomed$.next(true);
|
||||
@@ -183,6 +214,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
)
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
// Post-gesture refresh for SKIP mode
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
cancelRefresh();
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
if (
|
||||
!this.viewport.panning$.value &&
|
||||
!this.viewport.zooming$.value
|
||||
) {
|
||||
this.refresh().catch(console.error);
|
||||
}
|
||||
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||
};
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) cancelRefresh();
|
||||
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) cancelRefresh();
|
||||
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this.disposables.add({ dispose: cancelRefresh });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle selection and block updates
|
||||
@@ -235,10 +305,22 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
nextState = 'pending';
|
||||
this.clearOptimizedBlocks();
|
||||
} else if (this.isZooming()) {
|
||||
this.debugLog('Currently zooming, using placeholder rendering');
|
||||
nextState = 'zooming';
|
||||
this.paintPlaceholder();
|
||||
this.updateOptimizedBlocks();
|
||||
if (
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
hasBitmap: !!this.bitmap,
|
||||
})
|
||||
) {
|
||||
this.debugLog('Currently zooming, reusing cached bitmap');
|
||||
this.clearOptimizedBlocks();
|
||||
this.drawCachedBitmap();
|
||||
} else {
|
||||
this.debugLog('Currently zooming, using placeholder rendering');
|
||||
this.paintPlaceholder();
|
||||
this.updateOptimizedBlocks();
|
||||
}
|
||||
} else if (this.canUseBitmapCache()) {
|
||||
this.debugLog('Using cached bitmap');
|
||||
nextState = 'ready';
|
||||
@@ -286,7 +368,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
}
|
||||
|
||||
const layout = this.layoutCache;
|
||||
const dpr = window.devicePixelRatio;
|
||||
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||
const currentVersion = this.layoutVersion;
|
||||
|
||||
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
||||
@@ -368,12 +450,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
layout.overallRect.y
|
||||
);
|
||||
|
||||
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
layoutViewCoord[0] * window.devicePixelRatio,
|
||||
layoutViewCoord[1] * window.devicePixelRatio,
|
||||
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
|
||||
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
|
||||
layoutViewCoord[0] * dpr,
|
||||
layoutViewCoord[1] * dpr,
|
||||
layout.overallRect.w * dpr * this.viewport.zoom,
|
||||
layout.overallRect.h * dpr * this.viewport.zoom
|
||||
);
|
||||
|
||||
this.debugLog('Bitmap drawn to canvas');
|
||||
@@ -389,6 +473,16 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
private updateOptimizedBlocks() {
|
||||
if (!this.canOptimize()) return;
|
||||
if (
|
||||
!shouldIdleTurboBlocksDuringZooming({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
})
|
||||
) {
|
||||
this.clearOptimizedBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.viewportElement || !this.layoutCache) return;
|
||||
const blockElements = this.viewportElement.getModelsInViewport();
|
||||
@@ -416,7 +510,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
|
||||
private handleResize() {
|
||||
this.debugLog('Container resized, syncing canvas size');
|
||||
syncCanvasSize(this.canvas, this.std.host);
|
||||
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||
this.invalidate();
|
||||
this.refresh$.next();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ export class AffineLink extends WithDisposable(ShadowlessElement) {
|
||||
const linkStyle = {
|
||||
color: 'var(--affine-link-color)',
|
||||
fill: 'var(--affine-link-color)',
|
||||
'text-decoration': 'none',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.26.3"
|
||||
"version": "0.27.0"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user