Compare commits

..

30 Commits

Author SHA1 Message Date
DarkSky
744c78abbb feat(infra): improve ci perf (#14503)
#### PR Dependency Tree


* **PR #14503** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Improved CI efficiency with targeted Rust test detection and
workspace-scoped builds for faster, more focused runs.
* Added workflow static-analysis configuration and refined
Node/Playwright/Electron build flags and platform controls.
* Added a new test workspace dependency and TypeScript project reference
for blocksuite tests; updated workspace wiring for focused builds.
  * Cleaned CI environment handling for server test orchestration.

* **Bug Fixes**
* Hardened mobile release steps (safer credential handling and quoting)
to improve iOS/Android publish reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-23 21:52:17 +08:00
DarkSky
91c5869053 feat: improve selfhosted login (#14502)
fix #13397
fix #14011

#### PR Dependency Tree


* **PR #14502** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Centralized CORS policy with dynamic origin validation applied to
server and realtime connections
* Improved sign-in flows with contextual, localized error hints and
toast notifications
* Centralized network-error normalization and conditional OAuth provider
fetching

* **Bug Fixes**
* Better feedback for self-hosted connection failures and clearer
authentication error handling
* More robust handling of network-related failures with user-friendly
messages
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-23 21:23:01 +08:00
DarkSky
6d805b302c docs: cleanup outdated infos 2026-02-23 21:03:13 +08:00
Pixel Perfect
fb9f49b948 fix(data-view): preserve filtering on hidden properties (#14500)
Fixes issue #14036 where hiding a column used in filters caused empty
table/kanban results.

Root cause: filter evaluation built the row map from visible properties
only.

Change: evaluate filters using full property set (propertiesRaw$) so
hidden filtered columns still participate.

Added unit regressions for both table and kanban hidden-column filtering
behavior.

Verified this does fix the filtering issue for hidden columns:

<img width="3440" height="1440" alt="Screenshot of before and after
views of a database with hidden columns and filtering on said column"
src="https://github.com/user-attachments/assets/c1e2674f-06be-44e9-97bd-63593172f05b"
/>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Fixed filtering in Kanban and Table views so filters evaluate against
all properties (including hidden/raw columns), ensuring consistent
results regardless of column visibility.
* **Tests**
* Added tests covering filtering behavior with hidden and filtered
columns to prevent regressions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-23 20:45:12 +08:00
DarkSky
ef6717e59a fix(editor): editor behavior and styles (#14498)
fix #14269 
fix #13920
fix #13977
fix #13953
fix #13895
fix #13905
fix #14136
fix #14357
fix #14491

#### PR Dependency Tree


* **PR #14498** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Callout and toolbar defaults now reliably show grey backgrounds
  * Keyboard shortcuts behave better across layouts and non-ASCII input
  * Deleted workspaces no longer appear in local listings

* **New Features**
  * Cell editing now respects pre-entry validation hooks
* Scrollbars use themeable variables and include Chromium compatibility
fixes

* **Style**
  * Minor UI color adjustment for hidden properties

* **Tests**
  * Added unit tests for table column handling and keymap behavior
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-23 06:37:16 +08:00
DarkSky
ad988dbd1e chore: trim useless files for client (#14488)
#### PR Dependency Tree


* **PR #14488** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Improved Electron build to trim unused locale files on macOS, Windows,
and Linux while always preserving English fallbacks; added post-build
cleanup and stricter packaging ignore rules to exclude tests, examples,
scripts, docs, README, and build metadata.
* **Style**
  * Reformatted a TypeScript type annotation for consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-22 17:17:42 +08:00
DarkSky
3d01766f55 fix: history may duplicate on concurrency (#14487)
#### PR Dependency Tree


* **PR #14487** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced history record creation to prevent duplicate entries in
concurrent scenarios.

* **Tests**
  * Added validation for idempotent history record creation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-22 02:13:51 +08:00
DarkSky
2414aa5848 feat: improve admin build (#14485)
#### PR Dependency Tree


* **PR #14485** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
  * Admin static assets now served under /admin for self-hosted installs
  * CLI is directly executable from the command line
  * Build tooling supports a configurable self-hosted public path
  * Updated admin package script for adding UI components
* Added a PostCSS dependency and plugin to the build toolchain for admin
builds

* **Style**
* Switched queue module to a local queuedash stylesheet, added queuedash
Tailwind layer, and scoped queuedash styles for the admin UI

* **Bug Fixes**
  * Improved error propagation in the Electron renderer
* Migration compatibility to repair a legacy checksum during native
storage upgrades

* **Tests**
  * Added tests covering the migration repair flow
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-21 23:25:05 +08:00
DarkSky
0de1bd0da8 feat: bump deps (#14484) 2026-02-21 08:04:18 +08:00
DarkSky
186ec5431d fix: android build 2026-02-21 05:12:12 +08:00
DarkSky
da57bfe8e7 fix: enhance MCP token handling (#14483)
fix #14475 

#### PR Dependency Tree


* **PR #14483** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Enhanced MCP server token management with improved security—tokens now
display only once with redaction support.
* Updated token creation and deletion workflows with clearer UI state
controls.
* Added tooltip guidance when copying configuration with redacted
tokens.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-21 04:14:14 +08:00
DarkSky
c9bffc13b5 feat: improve mobile native impl (#14481)
fix #13529 

#### PR Dependency Tree


* **PR #14481** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Mobile blob caching with file-backed storage for faster loads and
reduced network usage
* Blob decoding with lazy refresh on token-read failures for improved
reliability
  * Full-text search/indexing exposed to mobile apps
* Document sync APIs and peer clock management for robust cross-device
sync

* **Tests**
* Added unit tests covering payload decoding, cache safety, and
concurrency

* **Dependencies**
* Added an LRU cache dependency and a new mobile-shared package for
shared mobile logic
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-21 04:13:24 +08:00
DarkSky
d8cc0acdd0 chore: update flags 2026-02-19 10:18:43 +08:00
Neo
35e1411407 fix: docTitle unexpectedly translated (#14467)
fix #14465

In Chinese mode, the document with the specified name may not be
displayed correctly in the sidebar, and it may be mistaken for the
translation of the content that needs to be translated.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed document title display in navigation panels on desktop and
mobile to properly render without additional processing steps.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-17 13:45:31 +00:00
DarkSky
8f833388eb feat: improve admin panel design (#14464) 2026-02-17 17:40:29 +08:00
DarkSky
850e646ab9 fix: electon rendering on windows (#14456)
fix #14450
fix #14401
fix #13983
fix #12766
fix #14404
fix #12019

#### PR Dependency Tree


* **PR #14456** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added new tab navigation functions: `switchTab`, `switchToNextTab`,
and `switchToPreviousTab`.

* **Bug Fixes**
  * Improved bounds validation for tab view resizing.
  * Enhanced tab lifecycle management during navigation events.
  * Refined background throttling behavior for active tabs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 14:08:26 +08:00
DarkSky
728e02cab7 feat: bump eslint & oxlint (#14452)
#### PR Dependency Tree


* **PR #14452** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved null-safety, dependency tracking, upload validation, and
error logging for more reliable uploads, clipboard, calendar linking,
telemetry, PDF/theme printing, and preview/zoom behavior.
* Tightened handling of all-day calendar events (missing date now
reported).

* **Deprecations**
  * Removed deprecated RadioButton and RadioButtonGroup; use RadioGroup.

* **Chores**
* Unified and upgraded linting/config, reorganized imports, and
standardized binary handling for more consistent builds and tooling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 13:52:08 +08:00
DarkSky
792164edd1 fix: sign 2026-02-16 12:23:26 +08:00
DarkSky
e3177e6837 feat: normalize search text (#14449)
#### PR Dependency Tree


* **PR #14449** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Improvements**
* Search text normalization now applied consistently across doc titles,
search results, and highlights for uniform display formatting.

* **Tests**
* Added comprehensive test coverage for search text normalization
utility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 08:07:04 +08:00
DarkSky
42f2d2b337 feat: support markdown preview (#14447) 2026-02-15 21:05:52 +08:00
DarkSky
9d7f4acaf1 fix: s3 upload compatibility (#14445)
fix #14432 

#### PR Dependency Tree


* **PR #14445** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Improved file upload handling to ensure consistent support for
different data formats during object and multipart uploads.
* Enhanced type safety throughout storage and workflow components by
removing unnecessary type assertions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-15 19:16:36 +08:00
DarkSky
9a1f600fc9 chore: update i18n status 2026-02-15 14:59:52 +08:00
steffenrapp
0f906ad623 feat(i18n): update German translation (#14444)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Chat panel: session management, history loading, embedding progress,
and deletion flow
* Document analytics: views, unique visitors, guest metrics, charts,
viewers and paywall messaging
* Calendar integration: expanded account/provider states, errors and
flow copy; DOCX import tooltip
  * Appearance: image antialiasing option and window-behavior toggles
  * Workspace sharing: visibility controls and related tooltips

* **Improvements**
* Expanded error and empty-state wording, subscription/payment
description, and experimental feature labels
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-15 14:57:47 +08:00
DarkSky
09aa65c52a feat: improve ci 2026-02-15 14:53:35 +08:00
DarkSky
25227a09f7 feat: improve grouping perf in edgeless (#14442)
fix #14433 

#### PR Dependency Tree


* **PR #14442** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Level-of-detail thumbnails for large images.
  * Adaptive pacing for snapping, distribution and other alignment work.
  * RAF coalescer utility to batch high-frequency updates.
  * Operation timing utility to measure synchronous work.

* **Improvements**
* Batch group/ungroup reparenting that preserves element order and
selection.
  * Coalesced panning and drag updates to reduce jitter.
* Connector/group indexing for more reliable updates, deletions and
sync.
  * Throttled viewport refresh behavior.

* **Documentation**
  * Docs added for RAF coalescer and measureOperation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-15 03:17:22 +08:00
DarkSky
c0694c589b fix: editor style (#14440)
#### PR Dependency Tree


* **PR #14440** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Refined CSS styling rules in workspace detail pages for improved
layout rendering consistency.
* Enhanced editor container display handling during loading states to
ensure proper layout adjustments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 19:12:24 +08:00
DarkSky
819402d9f1 feat: asset upload with retry 2026-02-14 17:24:22 +08:00
DarkSky
33bc3e2fe9 feat: improve ci (#14438)
#### PR Dependency Tree


* **PR #14438** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Refined PR trigger automation to run only on open/reopen/synchronize
events
* Split native CI into platform-specific builds (Linux, Windows, macOS)
for more reliable pipelines
* Added conditional Copilot test gating to run API/E2E tests only when
relevant
* Added conditional PR-title lint skip when edits don't change the title
  * Improved test result uploads and artifact handling for gated flows
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 16:59:49 +08:00
DarkSky
2b71b3f345 feat: improve test & bundler (#14434)
#### PR Dependency Tree


* **PR #14434** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced rspack bundler as an alternative to webpack for optimized
builds.

* **Tests & Quality**
* Added comprehensive editor semantic tests covering markdown, hotkeys,
and slash-menu operations.
* Expanded CI cross-browser testing to Chromium, Firefox, and WebKit;
improved shape-rendering tests to account for zoom.

* **Bug Fixes**
  * Corrected CSS overlay styling for development servers.
  * Fixed TypeScript typings for build tooling.

* **Other**
  * Document duplication now produces consistent "(n)" suffixes.
  * French i18n completeness increased to 100%.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 16:09:09 +08:00
dcornuel-del
3bc28ba78c feat(i18n): update French translations for various keys (#14437)
ajout de definition

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Enhanced French language support with improved grammar, gender
neutrality, and consistency across UI text.
  * Added French translations for new AI-powered features.
* Refined French phrasing in prompts, tooltips, and messages for better
clarity and natural language flow.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 14:43:22 +08:00
408 changed files with 15223 additions and 6929 deletions

View File

@@ -222,7 +222,7 @@
},
"SMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"default": "AFFiNE Self Hosted <noreply@example.com>"
},
"SMTP.ignoreTLS": {
@@ -262,7 +262,7 @@
},
"fallbackSMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"\"",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"\"",
"default": ""
},
"fallbackSMTP.ignoreTLS": {

View File

@@ -3,6 +3,6 @@ contact_links:
- name: Something else?
url: https://github.com/toeverything/AFFiNE/discussions
about: Feel free to ask and answer questions over in GitHub Discussions
- name: AFFiNE Community Support
url: https://community.affine.pro
- name: AFFiNE Community Support (Discord)
url: https://affine.pro/redirect/discord
about: AFFiNE Community - a place to ask, learn and engage with others

20
.github/actionlint.yaml vendored Normal file
View File

@@ -0,0 +1,20 @@
self-hosted-runner:
# Labels of self-hosted runner in array of strings.
labels:
- win-signer
# Configuration variables in array of strings defined in your repository or
# organization. `null` means disabling configuration variables check.
# Empty array means no configuration variable is allowed.
config-variables: null
# Configuration for file paths. The keys are glob patterns to match to file
# paths relative to the repository root. The values are the configurations for
# the file paths. Note that the path separator is always '/'.
# The following configurations are available.
#
# "ignore" is an array of regular expression patterns. Matched error messages
# are ignored. This is similar to the "-ignore" command line option.
paths:
# .github/workflows/**/*.yml:
# ignore: []

View File

@@ -7,7 +7,6 @@ inputs:
ios-app-version:
description: 'iOS App Store Version (Optional, use App version if empty)'
required: false
type: string
runs:
using: 'composite'
steps:

View File

@@ -1,6 +1,10 @@
name: 'Pull Request Labeler'
on:
- pull_request_target
pull_request_target:
types:
- opened
- reopened
- synchronize
jobs:
triage:

View File

@@ -68,9 +68,26 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Setup Go (for actionlint)
uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Install actionlint
shell: bash
run: |
set -euo pipefail
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11
- name: Run actionlint
shell: bash
run: |
set -euo pipefail
"$(go env GOPATH)/bin/actionlint"
- name: Run oxlint
# oxlint is fast, so wrong code will fail quickly
run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'].replace('oxlint', 'oxlint@' + require('./package.json').devDependencies.oxlint))")
run: |
set -euo pipefail
oxlint_version="$(node -e "console.log(require('./package.json').devDependencies.oxlint)")"
yarn dlx "oxlint@${oxlint_version}" --deny-warnings
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -108,20 +125,45 @@ jobs:
run: |
yarn affine bs-docs build
git checkout packages/frontend/i18n/src/i18n-completenesses.json
git status --porcelain | grep . && {
if git status --porcelain | grep -q .; then
echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted"
exit 1
} || {
else
echo "All changes are submitted"
}
fi
rust-test-filter:
name: Rust test filter
runs-on: ubuntu-latest
outputs:
run-rust: ${{ steps.rust-filter.outputs.rust }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: rust-filter
with:
filters: |
rust:
- '**/*.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '.cargo/**'
- 'rust-toolchain*'
- '.github/actions/build-rust/**'
lint-rust:
name: Lint Rust
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: 'affine'
no-build: 'true'
- name: fmt check
run: |
@@ -159,12 +201,12 @@ jobs:
yarn affine i18n build
yarn affine server genconfig
git checkout packages/frontend/i18n/src/i18n-completenesses.json
git status --porcelain | grep . && {
if git status --porcelain | grep -q .; then
echo "Run 'yarn affine init && yarn affine gql build && yarn affine i18n build && yarn affine server genconfig' and make sure all changes are submitted"
exit 1
} || {
else
echo "All changes are submitted"
}
fi
check-yarn-binary:
name: Check yarn binary
@@ -173,7 +215,9 @@ jobs:
- uses: actions/checkout@v4
- name: Run check
run: |
yarn set version $(node -e "console.log(require('./package.json').packageManager.split('@')[1])")
set -euo pipefail
yarn_version="$(node -e "console.log(require('./package.json').packageManager.split('@')[1])")"
yarn set version "$yarn_version"
git diff --exit-code
e2e-blocksuite-test:
@@ -188,6 +232,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
playwright-install: true
playwright-platform: 'chromium'
electron-install: false
@@ -210,18 +255,14 @@ jobs:
e2e-blocksuite-cross-browser-test:
name: E2E BlockSuite Cross Browser Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1]
browser: ['chromium', 'firefox', 'webkit']
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
playwright-install: true
playwright-platform: ${{ matrix.browser }}
playwright-platform: 'chromium,firefox,webkit'
electron-install: false
full-cache: true
@@ -229,18 +270,64 @@ jobs:
run: yarn workspace @blocksuite/playground build
- name: Run playwright tests
env:
BROWSER: ${{ matrix.browser }}
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
run: |
yarn workspace @blocksuite/integration-test test:unit
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
name: test-results-e2e-bs-cross-browser
path: ./test-results
if-no-files-found: ignore
bundler-matrix:
name: Bundler Matrix (${{ matrix.bundler }})
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
bundler: [webpack, rspack]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: false
electron-install: false
full-cache: true
- name: Run frontend build matrix
env:
AFFINE_BUNDLER: ${{ matrix.bundler }}
run: |
set -euo pipefail
packages=(
"@affine/web"
"@affine/mobile"
"@affine/ios"
"@affine/android"
"@affine/admin"
"@affine/electron-renderer"
)
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
: > "$summary"
for pkg in "${packages[@]}"; do
start=$(date +%s)
yarn affine "$pkg" build
end=$(date +%s)
echo "${pkg},$((end-start))" >> "$summary"
done
- name: Upload bundler timing
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-bundler-${{ matrix.bundler }}
path: ./test-results-bundler-${{ matrix.bundler }}.txt
if-no-files-found: ignore
e2e-test:
name: E2E Test
runs-on: ubuntu-24.04-arm
@@ -257,6 +344,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-local @affine/web @affine/server
playwright-install: true
playwright-platform: 'chromium'
electron-install: false
@@ -288,6 +376,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-mobile @affine/mobile
playwright-install: true
electron-install: false
full-cache: true
@@ -307,7 +396,7 @@ jobs:
name: Unit Test
runs-on: ubuntu-latest
needs:
- build-native
- build-native-linux
env:
DISTRIBUTION: web
strategy:
@@ -321,6 +410,7 @@ jobs:
with:
electron-install: true
playwright-install: true
playwright-platform: 'chromium,firefox,webkit'
full-cache: true
- name: Download affine.linux-x64-gnu.node
@@ -341,7 +431,39 @@ jobs:
name: affine
fail_ci_if_error: false
build-native:
build-native-linux:
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
runs-on: ubuntu-latest
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/native
electron-install: false
- name: Setup filename
id: filename
working-directory: ${{ github.workspace }}
shell: bash
run: |
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")"
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: '@affine/native'
- name: Upload ${{ steps.filename.outputs.filename }}
uses: actions/upload-artifact@v4
if: always()
with:
name: ${{ steps.filename.outputs.filename }}
path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }}
if-no-files-found: error
build-native-macos:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -350,7 +472,6 @@ jobs:
fail-fast: false
matrix:
spec:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: macos-latest, target: x86_64-apple-darwin }
- { os: macos-latest, target: aarch64-apple-darwin }
@@ -366,7 +487,7 @@ jobs:
working-directory: ${{ github.workspace }}
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
@@ -383,7 +504,7 @@ jobs:
# Split Windows build because it's too slow
# and other ci jobs required linux native
build-windows-native:
build-native-windows:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -415,7 +536,7 @@ jobs:
working-directory: ${{ env.DEV_DRIVE_WORKSPACE }}
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
@@ -463,6 +584,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/electron-renderer @affine/nbstore @toeverything/infra
electron-install: false
full-cache: true
- name: Build Electron renderer
@@ -483,7 +605,7 @@ jobs:
name: Native Unit Test
runs-on: ubuntu-latest
needs:
- build-native
- build-native-linux
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -544,6 +666,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
@@ -577,8 +700,6 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
strategy:
fail-fast: false
env:
NODE_ENV: test
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -626,6 +747,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
@@ -642,8 +764,6 @@ jobs:
run: yarn affine @affine/server test:coverage "**/*/*elasticsearch.spec.ts" --forbid-only
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
CI_NODE_INDEX: ${{ matrix.node_index }}
CI_NODE_TOTAL: ${{ matrix.total_nodes }}
- name: Upload server test coverage results
uses: codecov/codecov-action@v5
@@ -690,6 +810,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
@@ -716,7 +837,10 @@ jobs:
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
@@ -741,7 +865,10 @@ jobs:
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
@@ -764,7 +891,10 @@ jobs:
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:
@@ -800,7 +930,10 @@ jobs:
rust-test:
name: Run native tests
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
CARGO_TERM_COLOR: always
steps:
@@ -808,6 +941,7 @@ jobs:
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: 'affine'
no-build: 'true'
@@ -819,11 +953,51 @@ jobs:
- name: Run tests
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
copilot-test-filter:
name: Copilot test filter
runs-on: ubuntu-latest
outputs:
run-api: ${{ steps.decision.outputs.run_api }}
run-e2e: ${{ steps.decision.outputs.run_e2e }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: copilot-filter
with:
filters: |
api:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
e2e:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Decide test scope
id: decision
run: |
if [[ "${{ steps.copilot-filter.outputs.api }}" == "true" ]]; then
echo "run_api=true" >> "$GITHUB_OUTPUT"
else
echo "run_api=false" >> "$GITHUB_OUTPUT"
fi
if [[ "${{ steps.copilot-filter.outputs.e2e }}" == "true" ]]; then
echo "run_e2e=true" >> "$GITHUB_OUTPUT"
else
echo "run_e2e=false" >> "$GITHUB_OUTPUT"
fi
copilot-api-test:
name: Server Copilot Api Test
if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }}
runs-on: ubuntu-latest
needs:
- build-server-native
- copilot-test-filter
env:
NODE_ENV: test
DISTRIBUTION: web
@@ -857,53 +1031,30 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: apifilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run server tests
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
run: yarn affine @affine/server test:copilot:coverage --forbid-only
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
- name: Upload server test coverage results
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -914,6 +1065,7 @@ jobs:
copilot-e2e-test:
name: Frontend Copilot E2E Test
if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }}
runs-on: ubuntu-latest
env:
DISTRIBUTION: web
@@ -928,6 +1080,7 @@ jobs:
shardTotal: [5]
needs:
- build-server-native
- copilot-test-filter
services:
postgres:
image: pgvector/pgvector:pg16
@@ -951,52 +1104,27 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: e2efilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud-copilot @affine/web @affine/server
playwright-install: true
playwright-platform: 'chromium'
electron-install: false
hard-link-nm: false
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/copilot-test
with:
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -1006,7 +1134,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
- build-native
- build-native-linux
env:
DISTRIBUTION: web
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -1062,7 +1190,10 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud @affine-test/affine-desktop-cloud @affine/web @affine/server @affine/electron @affine/electron-renderer @affine/nbstore @toeverything/infra
playwright-install: true
playwright-platform: 'chromium'
electron-install: ${{ matrix.tests.shard == 'desktop' && 'true' || 'false' }}
hard-link-nm: false
- name: Download server-native.node
@@ -1099,7 +1230,9 @@ jobs:
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
- build-native-linux
- build-native-macos
- build-native-windows
strategy:
fail-fast: false
matrix:
@@ -1139,7 +1272,8 @@ jobs:
timeout-minutes: 10
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
playwright-install: true
playwright-install: ${{ matrix.spec.test && 'true' || 'false' }}
playwright-platform: 'chromium'
hard-link-nm: false
enableScripts: false
@@ -1147,7 +1281,7 @@ jobs:
id: filename
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Download ${{ steps.filename.outputs.filename }}
@@ -1182,84 +1316,6 @@ jobs:
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
run: yarn affine @affine-test/affine-desktop e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
desktop-bundle-check:
name: Desktop bundle check (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
strategy:
fail-fast: false
matrix:
spec:
- {
os: macos-latest,
platform: macos,
arch: x64,
target: x86_64-apple-darwin,
test: false,
}
- {
os: macos-latest,
platform: macos,
arch: arm64,
target: aarch64-apple-darwin,
test: true,
}
- {
os: ubuntu-latest,
platform: linux,
arch: x64,
target: x86_64-unknown-linux-gnu,
test: true,
}
- {
os: windows-latest,
platform: windows,
arch: x64,
target: x86_64-pc-windows-msvc,
test: true,
}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
playwright-install: true
hard-link-nm: false
enableScripts: false
- name: Setup filename
id: filename
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Download ${{ steps.filename.outputs.filename }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.filename.outputs.filename }}
path: ./packages/frontend/native
- name: Download web artifact
uses: ./.github/actions/download-web
with:
path: packages/frontend/apps/electron/resources/web-static
- name: Build Desktop Layers
run: yarn affine @affine/electron build
- name: Make bundle (macOS)
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
env:
@@ -1299,6 +1355,14 @@ jobs:
run: |
yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
test-done:
needs:
- analyze
@@ -1312,8 +1376,9 @@ jobs:
- e2e-blocksuite-cross-browser-test
- e2e-mobile-test
- unit-test
- build-native
- build-windows-native
- build-native-linux
- build-native-macos
- build-native-windows
- build-server-native
- build-electron-renderer
- native-unit-test
@@ -1323,10 +1388,11 @@ jobs:
- server-test
- server-e2e-test
- rust-test
- rust-test-filter
- copilot-test-filter
- copilot-api-test
- copilot-e2e-test
- desktop-test
- desktop-bundle-check
- cloud-e2e-test
if: always()
runs-on: ubuntu-latest

View File

@@ -16,6 +16,7 @@ jobs:
check-pull-request-title:
name: Check pull request title
runs-on: ubuntu-latest
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js

View File

@@ -201,13 +201,44 @@ jobs:
nmHoistingLimits: workspaces
env:
npm_config_arch: ${{ matrix.spec.arch }}
- name: Download and overwrite packaged artifacts
- name: Download packaged artifacts
uses: actions/download-artifact@v4
with:
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: packaged-unsigned
- name: unzip packaged artifacts
run: Expand-Archive -Path packaged-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out
- name: Download signed packaged file diff
uses: actions/download-artifact@v4
with:
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out
path: signed-packaged-diff
- name: Apply signed packaged file diff
shell: pwsh
run: |
$DiffRoot = 'signed-packaged-diff/files'
$TargetRoot = 'packages/frontend/apps/electron/out'
if (!(Test-Path -LiteralPath $DiffRoot)) {
throw "Signed diff directory not found: $DiffRoot"
}
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
$ManifestPath = 'signed-packaged-diff/manifest.json'
if (Test-Path -LiteralPath $ManifestPath) {
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
foreach ($Entry in $ManifestEntries) {
$TargetPath = Join-Path $TargetRoot $Entry.path
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
throw "Applied signed file not found: $($Entry.path)"
}
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
if ($TargetHash -ne $Entry.sha256) {
throw "Signed file hash mismatch: $($Entry.path)"
}
}
}
- name: Make squirrel.windows installer
run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
@@ -267,13 +298,44 @@ jobs:
arch: arm64
runs-on: ${{ matrix.spec.runner }}
steps:
- name: Download and overwrite installer artifacts
- name: Download installer artifacts
uses: actions/download-artifact@v4
with:
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: installer-unsigned
- name: unzip installer artifacts
run: Expand-Archive -Path installer-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
- name: Download signed installer file diff
uses: actions/download-artifact@v4
with:
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
path: signed-installer-diff
- name: Apply signed installer file diff
shell: pwsh
run: |
$DiffRoot = 'signed-installer-diff/files'
$TargetRoot = 'packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make'
if (!(Test-Path -LiteralPath $DiffRoot)) {
throw "Signed diff directory not found: $DiffRoot"
}
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
$ManifestPath = 'signed-installer-diff/manifest.json'
if (Test-Path -LiteralPath $ManifestPath) {
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
foreach ($Entry in $ManifestEntries) {
$TargetPath = Join-Path $TargetRoot $Entry.path
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
throw "Applied signed file not found: $($Entry.path)"
}
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
if ($TargetHash -ne $Entry.sha256) {
throw "Signed file hash mismatch: $($Entry.path)"
}
}
}
- name: Save artifacts
run: |

View File

@@ -128,9 +128,9 @@ jobs:
- name: Testflight
working-directory: packages/frontend/apps/ios/App
run: |
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
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
env:
BUILD_TARGET: distribution
@@ -160,7 +160,9 @@ jobs:
- name: Load Google Service file
env:
DATA: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICE_JSON }}
run: echo $DATA | base64 -di > packages/frontend/apps/android/App/app/google-services.json
run: |
set -euo pipefail
printf '%s' "$DATA" | base64 -di > packages/frontend/apps/android/App/app/google-services.json
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10

View File

@@ -148,7 +148,7 @@ jobs:
name: Wait for approval
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: darkskygit,pengx17,L-Sun,EYHN
approvers: darkskygit
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image

View File

@@ -30,13 +30,43 @@ jobs:
run: |
cd ${{ env.ARCHIVE_DIR }}/out
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
- name: zip file
shell: cmd
- name: collect signed file diff
shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0}
run: |
cd ${{ env.ARCHIVE_DIR }}
7za a signed.zip .\out\*
$OutDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'out'
$DiffDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'signed-diff'
$FilesDir = Join-Path $DiffDir 'files'
New-Item -ItemType Directory -Path $FilesDir -Force | Out-Null
$SignedFiles = [regex]::Matches('${{ inputs.files }}', '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
if ($SignedFiles.Count -eq 0) {
throw 'No files to sign were provided.'
}
$Manifest = @()
foreach ($RelativePath in $SignedFiles) {
$SourcePath = Join-Path $OutDir $RelativePath
if (!(Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
throw "Signed file not found: $RelativePath"
}
$TargetPath = Join-Path $FilesDir $RelativePath
$TargetDir = Split-Path -Parent $TargetPath
if ($TargetDir) {
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
}
Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force
$Manifest += [PSCustomObject]@{
path = $RelativePath
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
}
}
$Manifest | ConvertTo-Json -Depth 4 | Out-File -FilePath (Join-Path $DiffDir 'manifest.json') -Encoding utf8
Write-Host "Collected $($SignedFiles.Count) signed files."
- name: upload
uses: actions/upload-artifact@v4
with:
name: signed-${{ inputs.artifact-name }}
path: ${{ env.ARCHIVE_DIR }}/signed.zip
path: ${{ env.ARCHIVE_DIR }}/signed-diff

View File

@@ -5,6 +5,10 @@
"correctness": "error",
"perf": "error"
},
"env": {
"builtin": true,
"es2026": true
},
"ignorePatterns": [
"**/node_modules",
".yarn",
@@ -44,6 +48,34 @@
"**/test-blocks.json"
],
"rules": {
"no-empty-static-block": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-unused-private-class-members": "error",
"no-useless-backreference": "error",
"react/display-name": "error",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/no-unsafe-function-type": "error",
"@typescript-eslint/no-wrapper-object-types": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["**/dist"],
"message": "Don't import from dist",
"allowTypeImports": false
},
{
"group": ["**/src"],
"message": "Don't import from src",
"allowTypeImports": false
}
]
}
],
"no-await-in-loop": "allow",
"no-redeclare": "allow",
"promise/no-callback-in-promise": "allow",
@@ -70,6 +102,14 @@
"no-func-assign": "error",
"no-global-assign": "error",
"no-unused-vars": "error",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true,
"allowTaggedTemplates": true
}
],
"no-ex-assign": "error",
"no-loss-of-precision": "error",
"no-fallthrough": "error",
@@ -126,6 +166,7 @@
"react/no-render-return-value": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-comment-textnodes": "error",
"react/no-array-index-key": "off",
"typescript/consistent-type-imports": "error",
"typescript/no-non-null-assertion": "error",
"typescript/triple-slash-reference": "error",
@@ -241,6 +282,42 @@
"typescript/consistent-type-imports": "off",
"import/no-cycle": "off"
}
},
{
"files": [
"packages/**/*.{ts,tsx}",
"tools/**/*.{ts,tsx}",
"blocksuite/**/*.{ts,tsx}"
],
"rules": {
"react/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)"
}
]
}
},
{
"files": [
"**/__tests__/**/*",
"**/*.stories.tsx",
"**/*.spec.ts",
"**/tests/**/*",
"scripts/**/*",
"**/benchmark/**/*",
"**/__debug__/**/*",
"**/e2e/**/*"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["**/*.{ts,js,mjs}"],
"rules": {
"react/rules-of-hooks": "off"
}
}
]
}

View File

@@ -17,7 +17,7 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, oxlint.json, nyc.config.*",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, .oxlintrc.json, oxlint.json, nyc.config.*",
"Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml",
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"

11
Cargo.lock generated
View File

@@ -111,10 +111,12 @@ dependencies = [
"base64-simd",
"chrono",
"homedir",
"lru",
"objc2",
"objc2-foundation",
"sqlx",
"thiserror 2.0.17",
"tokio",
"uniffi",
]
@@ -2572,6 +2574,15 @@ dependencies = [
"weezl",
]
[[package]]
name = "lru"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "mac"
version = "0.1.1"

View File

@@ -46,6 +46,7 @@ resolver = "3"
libc = "0.2"
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
memory-indexer = "0.3.0"
mimalloc = "0.1"
mp4parse = "0.17"

View File

@@ -90,10 +90,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
## Contributing
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE's Discord](https://affine.pro/redirect/discord) |
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what youre made of.
@@ -101,11 +101,9 @@ Calling all developers, testers, tech writers and more! Contributions of all typ
For **bug reports**, **feature requests** and other **suggestions** you can also [create a new issue](https://github.com/toeverything/AFFiNE/issues/new/choose) and choose the most appropriate template for your feedback.
For **translation** and **language support** you can visit our [i18n General Space](https://community.affine.pro/c/i18n-general).
For **translation** and **language support** you can visit our [Discord](https://affine.pro/redirect/discord).
Looking for **other ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide range of support and resources.
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [Discord](https://affine.pro/redirect/discord) where you can engage with other like-minded individuals.
## Templates
@@ -182,20 +180,16 @@ Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiN
[![Run on ClawCloud](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Daffine)
## Hiring
Some amazing companies, including AFFiNE, are looking for developers! Are you interested in joining AFFiNE or its partners? Check out our [Discord channel](https://affine.pro/redirect/discord) for some of the latest jobs available.
## Feature Request
For feature requests, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
For feature requests, please see [discussions](https://github.com/toeverything/AFFiNE/discussions/categories/ideas).
## Building
### Codespaces
From the GitHub repo main page, click the green "Code" button and select "Create codespace on master". This will open a new Codespace with the (supposedly auto-forked
AFFiNE repo cloned, built, and ready to go.
AFFiNE repo cloned, built, and ready to go).
### Local

View File

@@ -216,9 +216,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
override renderBlock() {
const icon = this.model.props.icon$.value;
const backgroundColorName = this.model.props.backgroundColorName$.value;
const normalizedBackgroundName =
backgroundColorName === 'default' || backgroundColorName === ''
? 'grey'
: backgroundColorName;
const backgroundColor = (
cssVarV2.block.callout.background as Record<string, string>
)[backgroundColorName ?? ''];
)[normalizedBackgroundName ?? 'grey'];
const iconContent = getIcon(icon);

View File

@@ -68,14 +68,14 @@ const backgroundColorAction = {
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault
? null
? cssVarV2.block.callout.background.grey
: `var(--affine-text-highlight-${color})`;
const displayName = `${color} Background`;
return html`
<editor-menu-action
data-testid="background-${color}"
@click=${() => updateBackground(color)}
@click=${() => updateBackground(isDefault ? 'grey' : color)}
>
<affine-text-duotone-icon
style=${styleMap({

View File

@@ -27,6 +27,16 @@ export const codeBlockStyles = css`
${scrollbarStyle('.affine-code-block-container rich-text')}
/* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */
@supports not selector(::-webkit-scrollbar) {
.affine-code-block-container rich-text {
scrollbar-width: thin;
scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')}
transparent;
scrollbar-gutter: stable both-edges;
}
}
.affine-code-block-container .inline-editor {
font-family: var(--affine-font-code-family);
font-variant-ligatures: none;

View File

@@ -26,6 +26,11 @@ import {
@Peekable()
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024;
private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080;
private static readonly LOD_MAX_ZOOM = 0.4;
private static readonly LOD_THUMBNAIL_MAX_EDGE = 256;
static override styles = css`
affine-edgeless-image {
position: relative;
@@ -63,6 +68,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
width: 100%;
height: 100%;
}
affine-edgeless-image .resizable-img {
position: relative;
overflow: hidden;
}
`;
resourceController = new ResourceController(
@@ -70,6 +80,12 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
'Image'
);
private _lodThumbnailUrl: string | null = null;
private _lodSourceUrl: string | null = null;
private _lodGeneratingSourceUrl: string | null = null;
private _lodGenerationToken = 0;
private _lastShouldUseLod = false;
get blobUrl() {
return this.resourceController.blobUrl$.value;
}
@@ -96,6 +112,134 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
});
}
private _isLargeImage() {
const { width = 0, height = 0, size = 0 } = this.model.props;
const pixels = width * height;
return (
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
);
}
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
return (
Boolean(blobUrl) &&
this._isLargeImage() &&
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
);
}
private _revokeLodThumbnail() {
if (!this._lodThumbnailUrl) {
return;
}
URL.revokeObjectURL(this._lodThumbnailUrl);
this._lodThumbnailUrl = null;
}
private _resetLodSource(blobUrl: string | null) {
if (this._lodSourceUrl === blobUrl) {
return;
}
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = blobUrl;
this._revokeLodThumbnail();
}
private _createImageElement(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load image'));
image.src = src;
});
}
private _createThumbnailBlob(image: HTMLImageElement) {
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve<Blob | null>(null);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'low';
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
return new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve);
});
}
private _ensureLodThumbnail(blobUrl: string) {
if (
this._lodThumbnailUrl ||
this._lodGeneratingSourceUrl === blobUrl ||
!this._shouldUseLod(blobUrl)
) {
return;
}
const token = ++this._lodGenerationToken;
this._lodGeneratingSourceUrl = blobUrl;
void this._createImageElement(blobUrl)
.then(image => this._createThumbnailBlob(image))
.then(blob => {
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
return;
}
const thumbnailUrl = URL.createObjectURL(blob);
if (token !== this._lodGenerationToken || !this.isConnected) {
URL.revokeObjectURL(thumbnailUrl);
return;
}
this._revokeLodThumbnail();
this._lodThumbnailUrl = thumbnailUrl;
if (this._shouldUseLod(this.blobUrl)) {
this.requestUpdate();
}
})
.catch(err => {
if (token !== this._lodGenerationToken || !this.isConnected) {
return;
}
console.error(err);
})
.finally(() => {
if (token === this._lodGenerationToken) {
this._lodGeneratingSourceUrl = null;
}
});
}
private _updateLodFromViewport(zoom: number) {
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
if (shouldUseLod === this._lastShouldUseLod) {
return;
}
this._lastShouldUseLod = shouldUseLod;
if (shouldUseLod && this.blobUrl) {
this._ensureLodThumbnail(this.blobUrl);
}
this.requestUpdate();
}
override connectedCallback() {
super.connectedCallback();
@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
this.disposables.add(
this.model.props.sourceId$.subscribe(() => {
this._resetLodSource(null);
this.refreshData();
})
);
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._updateLodFromViewport(zoom);
})
);
this._lastShouldUseLod = this._shouldUseLod(this.blobUrl);
}
override disconnectedCallback() {
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = null;
this._revokeLodThumbnail();
super.disconnectedCallback();
}
override renderGfxBlock() {
const blobUrl = this.blobUrl;
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
this._resetLodSource(blobUrl);
const containerStyleMap = styleMap({
display: 'flex',
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
});
const { loading, icon, description, error, needUpload } = resovledState;
const shouldUseLod = this._shouldUseLod(blobUrl);
if (shouldUseLod && blobUrl) {
this._ensureLodThumbnail(blobUrl);
}
this._lastShouldUseLod = shouldUseLod;
const imageUrl =
shouldUseLod && this._lodThumbnailUrl ? this._lodThumbnailUrl : blobUrl;
return html`
<div class="affine-image-container" style=${containerStyleMap}>
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
class="drag-target"
draggable="false"
loading="lazy"
src=${blobUrl}
src=${imageUrl ?? ''}
alt=${caption}
@error=${this._handleError}
/>

View File

@@ -33,7 +33,11 @@ import {
ReleaseFromGroupIcon,
UnlockIcon,
} from '@blocksuite/icons/lit';
import type { GfxModel } from '@blocksuite/std/gfx';
import {
batchAddChildren,
batchRemoveChildren,
type GfxModel,
} from '@blocksuite/std/gfx';
import { html } from 'lit';
import { renderAlignmentMenu } from './alignment';
@@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = {
const group = firstModel.group;
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(firstModel);
batchRemoveChildren(group, [firstModel]);
firstModel.index = ctx.gfx.layer.generateIndex();
const parent = group.group;
if (parent && parent instanceof GroupElementModel) {
parent.addChild(firstModel);
batchAddChildren(parent, [firstModel]);
}
},
},
@@ -255,9 +258,12 @@ export const builtinMiscToolbarConfig = {
// release other elements from their groups and group with top element
otherElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
element.group?.removeChild(element);
topElement.group?.addChild(element);
if (element.group) {
batchRemoveChildren(element.group, [element]);
}
if (topElement.group) {
batchAddChildren(topElement.group, [element]);
}
});
if (otherElements.length === 0) {

View File

@@ -40,10 +40,146 @@ export const SurfaceBlockSchemaExtension =
export class SurfaceBlockModel extends BaseSurfaceModel {
private readonly _disposables: DisposableGroup = new DisposableGroup();
private readonly _connectorIdsByEndpoint = new Map<string, Set<string>>();
private readonly _connectorIndexDisposables = new DisposableGroup();
private readonly _connectorEndpoints = new Map<
string,
{ sourceId: string | null; targetId: string | null }
>();
private _addConnectorEndpoint(endpointId: string, connectorId: string) {
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
if (connectorIds) {
connectorIds.add(connectorId);
return;
}
this._connectorIdsByEndpoint.set(endpointId, new Set([connectorId]));
}
private _isConnectorModel(model: unknown): model is ConnectorElementModel {
return (
!!model &&
typeof model === 'object' &&
'type' in model &&
(model as { type?: string }).type === 'connector'
);
}
private _removeConnectorEndpoint(endpointId: string, connectorId: string) {
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
if (!connectorIds) {
return;
}
connectorIds.delete(connectorId);
if (connectorIds.size === 0) {
this._connectorIdsByEndpoint.delete(endpointId);
}
}
private _removeConnectorFromIndex(connectorId: string) {
const endpoints = this._connectorEndpoints.get(connectorId);
if (!endpoints) {
return;
}
if (endpoints.sourceId) {
this._removeConnectorEndpoint(endpoints.sourceId, connectorId);
}
if (endpoints.targetId) {
this._removeConnectorEndpoint(endpoints.targetId, connectorId);
}
this._connectorEndpoints.delete(connectorId);
}
private _rebuildConnectorIndex() {
this._connectorIdsByEndpoint.clear();
this._connectorEndpoints.clear();
this.getElementsByType('connector').forEach(connector => {
this._setConnectorEndpoints(connector as ConnectorElementModel);
});
}
private _setConnectorEndpoints(connector: ConnectorElementModel) {
const sourceId = connector.source?.id ?? null;
const targetId = connector.target?.id ?? null;
const previousEndpoints = this._connectorEndpoints.get(connector.id);
if (
previousEndpoints?.sourceId === sourceId &&
previousEndpoints?.targetId === targetId
) {
return;
}
if (previousEndpoints?.sourceId) {
this._removeConnectorEndpoint(previousEndpoints.sourceId, connector.id);
}
if (previousEndpoints?.targetId) {
this._removeConnectorEndpoint(previousEndpoints.targetId, connector.id);
}
if (sourceId) {
this._addConnectorEndpoint(sourceId, connector.id);
}
if (targetId) {
this._addConnectorEndpoint(targetId, connector.id);
}
this._connectorEndpoints.set(connector.id, {
sourceId,
targetId,
});
}
override _init() {
this._extendElement(elementsCtorMap);
super._init();
this._rebuildConnectorIndex();
this._connectorIndexDisposables.add(
this.elementAdded.subscribe(({ id }) => {
const model = this.getElementById(id);
if (this._isConnectorModel(model)) {
this._setConnectorEndpoints(model);
}
})
);
this._connectorIndexDisposables.add(
this.elementUpdated.subscribe(({ id, props }) => {
if (!props['source'] && !props['target']) {
return;
}
const model = this.getElementById(id);
if (this._isConnectorModel(model)) {
this._setConnectorEndpoints(model);
}
})
);
this._connectorIndexDisposables.add(
this.elementRemoved.subscribe(({ id, type }) => {
if (type === 'connector') {
this._removeConnectorFromIndex(id);
}
})
);
this.deleted.subscribe(() => {
this._connectorIndexDisposables.dispose();
this._connectorIdsByEndpoint.clear();
this._connectorEndpoints.clear();
});
this.store.provider
.getAll(surfaceMiddlewareIdentifier)
.forEach(({ middleware }) => {
@@ -52,13 +188,31 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
}
getConnectors(id: string) {
const connectors = this.getElementsByType(
'connector'
) as unknown[] as ConnectorElementModel[];
const connectorIds = this._connectorIdsByEndpoint.get(id);
return connectors.filter(
connector => connector.source?.id === id || connector.target?.id === id
);
if (!connectorIds?.size) {
return [];
}
const staleConnectorIds: string[] = [];
const connectors: ConnectorElementModel[] = [];
connectorIds.forEach(connectorId => {
const model = this.getElementById(connectorId);
if (!this._isConnectorModel(model)) {
staleConnectorIds.push(connectorId);
return;
}
connectors.push(model);
});
staleConnectorIds.forEach(connectorId => {
this._removeConnectorFromIndex(connectorId);
});
return connectors;
}
override getElementsByType<K extends keyof SurfaceElementModelMap>(

View File

@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import type { FilterGroup } from '../core/filter/types.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
@@ -17,7 +18,10 @@ import {
pickKanbanGroupColumn,
resolveKanbanGroupBy,
} from '../view-presets/kanban/group-by-utils.js';
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
import {
KanbanSingleView,
materializeKanbanColumns,
} from '../view-presets/kanban/kanban-view-manager.js';
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
@@ -270,6 +274,73 @@ describe('kanban', () => {
});
});
describe('filtering', () => {
const sharedFilter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const sharedTitleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
it('evaluates filters with hidden columns', () => {
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Done',
},
}),
};
const view = {
filter$: { value: sharedFilter },
// Simulate status being hidden in current view.
properties$: { value: [sharedTitleProperty] },
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
} as unknown as KanbanSingleView;
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
});
it('returns false when hidden filtered column does not match', () => {
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'In Progress',
},
}),
};
const view = {
filter$: { value: sharedFilter },
// Simulate status being hidden in current view.
properties$: { value: [sharedTitleProperty] },
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
} as unknown as KanbanSingleView;
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
});
});
describe('drag indicator', () => {
it('shows drop preview when insert position exists', () => {
const controller = createDragController();

View File

@@ -1,15 +1,21 @@
import { describe, expect, test } from 'vitest';
import type { FilterGroup } from '../core/filter/types.js';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { DEFAULT_COLUMN_WIDTH } from '../view-presets/table/consts.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
import {
materializeTableColumns,
TableSingleView,
} from '../view-presets/table/table-view-manager.js';
/** @vitest-environment happy-dom */
@@ -41,6 +47,146 @@ describe('TableGroup', () => {
});
});
describe('table column materialization', () => {
test('appends missing properties while preserving existing order and state', () => {
const columns = [
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
];
const next = materializeTableColumns(columns, ['title', 'status', 'date']);
expect(next).toEqual([
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
{ id: 'date', width: DEFAULT_COLUMN_WIDTH },
]);
});
test('drops stale columns that no longer exist in data source', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'removed', width: 200, hide: true },
];
const next = materializeTableColumns(columns, ['title']);
expect(next).toEqual([{ id: 'title', width: 320 }]);
});
test('returns original reference when columns are already materialized', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'status', width: 240, hide: true },
];
const next = materializeTableColumns(columns, ['title', 'status']);
expect(next).toBe(columns);
});
test('supports type-aware default width when materializing missing columns', () => {
const next = materializeTableColumns([], ['title', 'status'], id =>
id === 'title' ? 260 : DEFAULT_COLUMN_WIDTH
);
expect(next).toEqual([
{ id: 'title', width: 260 },
{ id: 'status', width: DEFAULT_COLUMN_WIDTH },
]);
});
});
describe('table filtering', () => {
test('evaluates filters with hidden columns', () => {
const filter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const titleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Done',
},
}),
};
const view = {
filter$: { value: filter },
// Simulate status being hidden in current view.
properties$: { value: [titleProperty] },
propertiesRaw$: { value: [titleProperty, statusProperty] },
} as unknown as TableSingleView;
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
});
test('returns false when hidden filtered column does not match', () => {
const filter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const titleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'In Progress',
},
}),
};
const view = {
filter$: { value: filter },
// Simulate status being hidden in current view.
properties$: { value: [titleProperty] },
propertiesRaw$: { value: [titleProperty, statusProperty] },
} as unknown as TableSingleView;
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);

View File

@@ -67,7 +67,7 @@ export const autoScrollOnBoundary = (
};
const cancelBoxListen = effect(() => {
box.value;
void box.value;
startUpdate();
});

View File

@@ -349,7 +349,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries(
this.properties$.value.map(column => [
this.propertiesRaw$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])

View File

@@ -54,7 +54,9 @@ export class DatabaseCellContainer extends SignalWatcher(
const selectionView = this.selectionView;
if (selectionView) {
const selection = selectionView.selection;
if (selection && this.isSelected(selection) && editing) {
const shouldEnterEditMode =
editing && this.cell?.beforeEnterEditMode() !== false;
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
selectionView.selection = TableViewAreaSelection.create({
groupKey: this.groupKey,
focus: {

View File

@@ -24,12 +24,12 @@ import {
DataViewUIBase,
DataViewUILogicBase,
} from '../../../core/view/data-view-base.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import {
type TableSingleView,
TableViewRowSelection,
type TableViewSelectionWithType,
} from '../../index.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
} from '../selection.js';
import type { TableSingleView } from '../table-view-manager.js';
import { TableClipboardController } from './controller/clipboard.js';
import { TableDragController } from './controller/drag.js';
import { TableHotkeysController } from './controller/hotkeys.js';

View File

@@ -57,7 +57,9 @@ export class TableViewCellContainer extends SignalWatcher(
const selectionView = this.selectionController;
if (selectionView) {
const selection = selectionView.selection;
if (selection && this.isSelected(selection) && editing) {
const shouldEnterEditMode =
editing && this.cell?.beforeEnterEditMode() !== false;
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
selectionView.selection = TableViewAreaSelection.create({
groupKey: this.groupKey,
focus: {

View File

@@ -26,6 +26,52 @@ import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
import type { TableViewData } from './define.js';
export const materializeColumnsByPropertyIds = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth: (id: string) => number = () => DEFAULT_COLUMN_WIDTH
) => {
const needShow = new Set(propertyIds);
const orderedColumns: TableColumnData[] = [];
for (const column of columns) {
if (needShow.has(column.id)) {
orderedColumns.push(column);
needShow.delete(column.id);
}
}
for (const id of needShow) {
orderedColumns.push({ id, width: getDefaultWidth(id), hide: undefined });
}
return orderedColumns;
};
export const materializeTableColumns = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth?: (id: string) => number
) => {
const nextColumns = materializeColumnsByPropertyIds(
columns,
propertyIds,
getDefaultWidth
);
const unchanged =
columns.length === nextColumns.length &&
columns.every((column, index) => {
const nextColumn = nextColumns[index];
return (
nextColumn != null &&
column.id === nextColumn.id &&
column.hide === nextColumn.hide
);
});
return unchanged ? columns : nextColumns;
};
export class TableSingleView extends SingleViewBase<TableViewData> {
propertiesRaw$ = computed(() => {
const needShow = new Set(this.dataSource.properties$.value);
@@ -220,14 +266,10 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
return this.data$.value?.mode ?? 'table';
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries(
this.properties$.value.map(column => [
this.propertiesRaw$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])
@@ -290,6 +332,33 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
});
}
);
private materializeColumns() {
const data = this.data$.value;
if (!data) {
return;
}
const nextColumns = materializeTableColumns(
data.columns,
this.dataSource.properties$.value,
id => this.propertyGetOrCreate(id).width$.value
);
if (nextColumns === data.columns) {
return;
}
this.dataUpdate(() => ({ columns: nextColumns }));
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
// Materialize view columns on view activation so newly added properties
// can participate in hide/order operations in table.
queueMicrotask(() => {
this.materializeColumns();
});
}
}
type TableColumnData = TableViewData['columns'][number];

View File

@@ -60,10 +60,9 @@ export class BaseExtensionProvider<
* @param context - The context object containing scope and registration function
* @param option - Optional configuration options for the provider
*/
setup(context: Context<Scope>, option?: Options) {
setup(_context: Context<Scope>, option?: Options) {
if (option) {
this.schema.parse(option);
}
context;
}
}

View File

@@ -884,7 +884,7 @@ export class ConnectionOverlay extends Overlay {
private _setupThemeListener(): void {
const themeService = this.gfx.std.get(ThemeProvider);
this._themeDisposer = effect(() => {
themeService.theme$;
void themeService.theme$.value;
this._emphasisColor = this._getEmphasisColor();
});
}

View File

@@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = (
);
return () => {
pendingFlag = false;
pendingList.clear();
disposables.forEach(d => d.unsubscribe());
};
};

View File

@@ -26,6 +26,7 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
@@ -33,6 +34,9 @@
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('fractional-indexing', () => ({
generateKeyBetween: vi.fn(),
generateNKeysBetween: vi.fn(),
}));
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
import { ungroupCommand } from '../command/group-api.js';
type TestElement = {
id: string;
index: string;
group: TestElement | null;
childElements: TestElement[];
removeChildren?: (elements: TestElement[]) => void;
addChildren?: (elements: TestElement[]) => void;
};
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
const createElement = (
id: string,
index: string,
group: TestElement | null
): TestElement => ({
id,
index,
group,
childElements: [],
});
const createUngroupFixture = () => {
const parent = createElement('parent', 'p0', null);
const left = createElement('left', 'a0', parent);
const right = createElement('right', 'a0', parent);
const group = createElement('group', 'm0', parent);
const childA = createElement('child-a', 'c0', group);
const childB = createElement('child-b', 'c1', group);
group.childElements = [childB, childA];
parent.childElements = [left, group, right];
parent.removeChildren = vi.fn();
parent.addChildren = vi.fn();
group.removeChildren = vi.fn();
const elementOrder = new Map<TestElement, number>([
[left, 0],
[group, 1],
[right, 2],
[childA, 3],
[childB, 4],
]);
const selectionSet = vi.fn();
const gfx = {
layer: {
compare: (a: TestElement, b: TestElement) =>
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
},
selection: {
set: selectionSet,
},
};
const std = {
get: vi.fn(() => gfx),
store: {
transact: (callback: () => void) => callback(),
},
};
return {
childA,
childB,
group,
parent,
selectionSet,
std,
};
};
describe('ungroupCommand', () => {
beforeEach(() => {
mockedGenerateNKeysBetween.mockReset();
mockedGenerateKeyBetween.mockReset();
});
test('falls back to open-ended key generation when sibling interval is invalid', () => {
const fixture = createUngroupFixture();
mockedGenerateNKeysBetween
.mockImplementationOnce(() => {
throw new Error('interval reversed');
})
.mockReturnValueOnce(['n0', 'n1']);
const next = vi.fn();
ungroupCommand(
{
std: fixture.std,
group: fixture.group as any,
} as any,
next
);
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
1,
'a0',
'a0',
2
);
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
2,
'a0',
null,
2
);
expect(fixture.childA.index).toBe('n0');
expect(fixture.childB.index).toBe('n1');
expect(fixture.selectionSet).toHaveBeenCalledWith({
editing: false,
elements: ['child-a', 'child-b'],
});
expect(next).toHaveBeenCalledTimes(1);
});
test('falls back to key-by-key generation when all batched strategies fail', () => {
const fixture = createUngroupFixture();
mockedGenerateNKeysBetween.mockImplementation(() => {
throw new Error('invalid range');
});
let seq = 0;
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
ungroupCommand(
{
std: fixture.std,
group: fixture.group as any,
} as any,
vi.fn()
);
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
expect(fixture.childA.index).toBe('k0');
expect(fixture.childB.index).toBe('k1');
});
});

View File

@@ -4,7 +4,80 @@ import {
MindmapElementModel,
} from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/std';
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
import {
batchAddChildren,
batchRemoveChildren,
type GfxController,
GfxControllerIdentifier,
type GfxModel,
measureOperation,
} from '@blocksuite/std/gfx';
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
const getTopLevelOrderedElements = (gfx: GfxController) => {
const topLevelElements = gfx.layer.layers.reduce<GfxModel[]>(
(elements, layer) => {
layer.elements.forEach(element => {
if (element.group === null) {
elements.push(element as GfxModel);
}
});
return elements;
},
[]
);
topLevelElements.sort((a, b) => gfx.layer.compare(a, b));
return topLevelElements;
};
const buildUngroupIndexes = (
orderedElements: GfxModel[],
afterIndex: string | null,
beforeIndex: string | null,
fallbackAnchorIndex: string
) => {
if (orderedElements.length === 0) {
return [];
}
const count = orderedElements.length;
const tryGenerateN = (left: string | null, right: string | null) => {
try {
const generated = generateNKeysBetween(left, right, count);
return generated.length === count ? generated : null;
} catch {
return null;
}
};
const tryGenerateOneByOne = (left: string | null, right: string | null) => {
try {
let cursor = left;
return orderedElements.map(() => {
cursor = generateKeyBetween(cursor, right);
return cursor;
});
} catch {
return null;
}
};
// Preferred: keep ungrouped children in the original group slot.
return (
tryGenerateN(afterIndex, beforeIndex) ??
// Fallback: ignore the upper bound when legacy/broken data has reversed interval.
tryGenerateN(afterIndex, null) ??
// Fallback: use group index as anchor when sibling interval is unavailable.
tryGenerateN(fallbackAnchorIndex, null) ??
// Last resort: always valid.
tryGenerateN(null, null) ??
// Defensive fallback for unexpected library behavior.
tryGenerateOneByOne(null, null) ??
[]
);
};
export const createGroupCommand: Command<
{ elements: GfxModel[] | string[] },
@@ -39,96 +112,118 @@ export const createGroupFromSelectedCommand: Command<
{},
{ groupId: string }
> = (ctx, next) => {
const { std } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection, surface } = gfx;
measureOperation('edgeless:create-group-from-selected', () => {
const { std } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection, surface } = gfx;
if (!surface) {
return;
}
if (!surface) {
return;
}
if (
selection.selectedElements.length === 0 ||
!selection.selectedElements.every(
element =>
element.group === selection.firstElement.group &&
!(element.group instanceof MindmapElementModel)
)
) {
return;
}
if (
selection.selectedElements.length === 0 ||
!selection.selectedElements.every(
element =>
element.group === selection.firstElement.group &&
!(element.group instanceof MindmapElementModel)
)
) {
return;
}
const parent = selection.firstElement.group as GroupElementModel;
const parent = selection.firstElement.group;
let groupId: string | undefined;
std.store.transact(() => {
const [_, result] = std.command.exec(createGroupCommand, {
elements: selection.selectedElements,
});
if (parent !== null) {
selection.selectedElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(element);
if (!result.groupId) {
return;
}
groupId = result.groupId;
const group = surface.getElementById(groupId);
if (parent !== null && group) {
batchRemoveChildren(parent, selection.selectedElements);
batchAddChildren(parent, [group]);
}
});
}
const [_, result] = std.command.exec(createGroupCommand, {
elements: selection.selectedElements,
if (!groupId) {
return;
}
selection.set({
editing: false,
elements: [groupId],
});
next({ groupId });
});
if (!result.groupId) {
return;
}
const group = surface.getElementById(result.groupId);
if (parent !== null && group) {
parent.addChild(group);
}
selection.set({
editing: false,
elements: [result.groupId],
});
next({ groupId: result.groupId });
};
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
ctx,
next
) => {
const { std, group } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const parent = group.group as GroupElementModel;
const elements = group.childElements;
measureOperation('edgeless:ungroup', () => {
const { std, group } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const parent = group.group;
const elements = [...group.childElements];
if (group instanceof MindmapElementModel) {
return;
}
if (group instanceof MindmapElementModel) {
return;
}
if (parent !== null) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(group);
}
const orderedElements = [...elements].sort((a, b) =>
gfx.layer.compare(a, b)
);
const siblings = parent
? [...parent.childElements].sort((a, b) => gfx.layer.compare(a, b))
: getTopLevelOrderedElements(gfx);
const groupPosition = siblings.indexOf(group);
const beforeSiblingIndex =
groupPosition > 0 ? (siblings[groupPosition - 1]?.index ?? null) : null;
const afterSiblingIndex =
groupPosition === -1
? null
: (siblings[groupPosition + 1]?.index ?? null);
const nextIndexes = buildUngroupIndexes(
orderedElements,
beforeSiblingIndex,
afterSiblingIndex,
group.index
);
elements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(element);
});
std.store.transact(() => {
if (parent !== null) {
batchRemoveChildren(parent, [group]);
}
// keep relative index order of group children after ungroup
elements
.sort((a, b) => gfx.layer.compare(a, b))
.forEach(element => {
std.store.transact(() => {
element.index = gfx.layer.generateIndex();
batchRemoveChildren(group, elements);
// keep relative index order of group children after ungroup
orderedElements.forEach((element, idx) => {
const index = nextIndexes[idx];
if (element.index !== index) {
element.index = index;
}
});
if (parent !== null) {
batchAddChildren(parent, orderedElements);
}
});
if (parent !== null) {
elements.forEach(element => {
parent.addChild(element);
selection.set({
editing: false,
elements: orderedElements.map(ele => ele.id),
});
}
selection.set({
editing: false,
elements: elements.map(ele => ele.id),
next();
});
next();
};

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/affine-gfx-group',
},
onConsoleLog(log, type) {
if (log.includes('lit.dev/msg/dev-mode')) {
return false;
}
console.warn(`Unexpected ${type} log`, log);
throw new Error(log);
},
environment: 'happy-dom',
},
});

View File

@@ -32,6 +32,9 @@
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from 'vitest';
import {
AdaptiveCooldownController,
AdaptiveStrideController,
} from '../snap/adaptive-load-controller.js';
describe('AdaptiveStrideController', () => {
test('increases stride under heavy cost and respects maxStride', () => {
const controller = new AdaptiveStrideController({
heavyCostMs: 6,
maxStride: 3,
recoveryCostMs: 2,
});
controller.reportCost(10);
controller.reportCost(12);
controller.reportCost(15);
// stride should be capped at 3, so only every 3rd tick runs.
expect(controller.shouldSkip()).toBe(false);
expect(controller.shouldSkip()).toBe(true);
expect(controller.shouldSkip()).toBe(true);
expect(controller.shouldSkip()).toBe(false);
});
test('decreases stride when cost recovers and reset clears state', () => {
const controller = new AdaptiveStrideController({
heavyCostMs: 8,
maxStride: 4,
recoveryCostMs: 3,
});
controller.reportCost(12);
controller.reportCost(12);
controller.reportCost(1);
// From stride 3 recovered to stride 2: run every other tick.
expect(controller.shouldSkip()).toBe(false);
expect(controller.shouldSkip()).toBe(true);
expect(controller.shouldSkip()).toBe(false);
controller.reset();
expect(controller.shouldSkip()).toBe(false);
expect(controller.shouldSkip()).toBe(false);
});
});
describe('AdaptiveCooldownController', () => {
test('enters cooldown when cost exceeds threshold', () => {
const controller = new AdaptiveCooldownController({
cooldownFrames: 2,
maxCostMs: 5,
});
controller.reportCost(9);
expect(controller.shouldRun()).toBe(false);
expect(controller.shouldRun()).toBe(false);
expect(controller.shouldRun()).toBe(true);
});
test('reset exits cooldown immediately', () => {
const controller = new AdaptiveCooldownController({
cooldownFrames: 3,
maxCostMs: 5,
});
controller.reportCost(6);
expect(controller.shouldRun()).toBe(false);
controller.reset();
expect(controller.shouldRun()).toBe(true);
});
});

View File

@@ -0,0 +1,177 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { MouseButton } from '@blocksuite/std/gfx';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { PanTool } from '../tools/pan-tool.js';
type PointerDownHandler = (event: {
raw: {
button: number;
preventDefault: () => void;
};
}) => unknown;
const mockRaf = () => {
let callback: FrameRequestCallback | undefined;
const requestAnimationFrameMock = vi
.fn()
.mockImplementation((cb: FrameRequestCallback) => {
callback = cb;
return 1;
});
const cancelAnimationFrameMock = vi.fn();
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock);
return {
getCallback: () => callback,
requestAnimationFrameMock,
cancelAnimationFrameMock,
};
};
const createToolFixture = (options?: {
currentToolName?: string;
currentToolOptions?: Record<string, unknown>;
}) => {
const applyDeltaCenter = vi.fn();
const selectionSet = vi.fn();
const setTool = vi.fn();
const navigatorSettingUpdated = {
next: vi.fn(),
};
const currentToolName = options?.currentToolName;
const currentToolOption = {
toolType: currentToolName
? ({
toolName: currentToolName,
} as any)
: undefined,
options: options?.currentToolOptions,
};
const gfx = {
viewport: {
zoom: 2,
applyDeltaCenter,
},
selection: {
surfaceSelections: [{ elements: ['shape-1'] }],
set: selectionSet,
},
tool: {
currentTool$: {
peek: () => null,
},
currentToolOption$: {
peek: () => currentToolOption,
},
setTool,
},
std: {
get: (identifier: unknown) => {
if (identifier === EdgelessLegacySlotIdentifier) {
return { navigatorSettingUpdated };
}
return null;
},
},
doc: {},
};
const tool = new PanTool(gfx as any);
return {
applyDeltaCenter,
navigatorSettingUpdated,
selectionSet,
setTool,
tool,
};
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('PanTool', () => {
test('flushes accumulated delta on dragEnd', () => {
mockRaf();
const { tool, applyDeltaCenter } = createToolFixture();
tool.dragStart({ x: 100, y: 100 } as any);
tool.dragMove({ x: 80, y: 60 } as any);
tool.dragMove({ x: 70, y: 40 } as any);
expect(applyDeltaCenter).not.toHaveBeenCalled();
tool.dragEnd({} as any);
expect(applyDeltaCenter).toHaveBeenCalledTimes(1);
expect(applyDeltaCenter).toHaveBeenCalledWith(15, 30);
expect(tool.panning$.value).toBe(false);
});
test('cancel in unmounted drops pending deltas', () => {
mockRaf();
const { tool, applyDeltaCenter } = createToolFixture();
tool.dragStart({ x: 100, y: 100 } as any);
tool.dragMove({ x: 80, y: 60 } as any);
tool.unmounted();
tool.dragEnd({} as any);
expect(applyDeltaCenter).not.toHaveBeenCalled();
});
test('middle click temporary pan restores frameNavigator with restoredAfterPan', () => {
const { tool, navigatorSettingUpdated, selectionSet, setTool } =
createToolFixture({
currentToolName: 'frameNavigator',
currentToolOptions: { mode: 'fit' },
});
const hooks: Partial<Record<'pointerDown', PointerDownHandler>> = {};
(tool as any).eventTarget = {
addHook: (eventName: 'pointerDown', handler: PointerDownHandler) => {
hooks[eventName] = handler;
},
};
tool.mounted();
const preventDefault = vi.fn();
const pointerDown = hooks.pointerDown!;
const ret = pointerDown({
raw: {
button: MouseButton.MIDDLE,
preventDefault,
},
});
expect(ret).toBe(false);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(navigatorSettingUpdated.next).toHaveBeenCalledWith({
blackBackground: false,
});
expect(setTool).toHaveBeenNthCalledWith(1, PanTool, {
panning: true,
});
document.dispatchEvent(
new PointerEvent('pointerup', { button: MouseButton.MIDDLE })
);
expect(selectionSet).toHaveBeenCalledWith([{ elements: ['shape-1'] }]);
expect(setTool).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
toolName: 'frameNavigator',
}),
{
mode: 'fit',
restoredAfterPan: true,
}
);
});
});

View File

@@ -0,0 +1,65 @@
export class AdaptiveStrideController {
private _stride = 1;
private _ticks = 0;
constructor(
private readonly _options: {
heavyCostMs: number;
maxStride: number;
recoveryCostMs: number;
}
) {}
reportCost(costMs: number) {
if (costMs > this._options.heavyCostMs) {
this._stride = Math.min(this._options.maxStride, this._stride + 1);
return;
}
if (costMs < this._options.recoveryCostMs && this._stride > 1) {
this._stride -= 1;
}
}
reset() {
this._stride = 1;
this._ticks = 0;
}
shouldSkip() {
const shouldSkip = this._stride > 1 && this._ticks % this._stride !== 0;
this._ticks += 1;
return shouldSkip;
}
}
export class AdaptiveCooldownController {
private _remainingFrames = 0;
constructor(
private readonly _options: {
cooldownFrames: number;
maxCostMs: number;
}
) {}
reportCost(costMs: number) {
if (costMs > this._options.maxCostMs) {
this._remainingFrames = this._options.cooldownFrames;
}
}
reset() {
this._remainingFrames = 0;
}
shouldRun() {
if (this._remainingFrames <= 0) {
return true;
}
this._remainingFrames -= 1;
return false;
}
}

View File

@@ -8,11 +8,18 @@ import {
InteractivityExtension,
} from '@blocksuite/std/gfx';
import { AdaptiveStrideController } from './adaptive-load-controller';
import type { SnapOverlay } from './snap-overlay';
export class SnapExtension extends InteractivityExtension {
static override key = 'snap-manager';
private static readonly MAX_ALIGN_SKIP_STRIDE = 3;
private static readonly ALIGN_HEAVY_COST_MS = 5;
private static readonly ALIGN_RECOVERY_COST_MS = 2;
get snapOverlay() {
return this.std.getOptional(
OverlayIdentifier('snap-manager')
@@ -29,6 +36,11 @@ export class SnapExtension extends InteractivityExtension {
}
let alignBound: Bound | null = null;
const alignStride = new AdaptiveStrideController({
heavyCostMs: SnapExtension.ALIGN_HEAVY_COST_MS,
maxStride: SnapExtension.MAX_ALIGN_SKIP_STRIDE,
recoveryCostMs: SnapExtension.ALIGN_RECOVERY_COST_MS,
});
return {
onDragStart() {
@@ -42,6 +54,7 @@ export class SnapExtension extends InteractivityExtension {
return pre;
}, [] as GfxModel[])
);
alignStride.reset();
},
onDragMove(context: ExtensionDragMoveContext) {
if (
@@ -53,14 +66,22 @@ export class SnapExtension extends InteractivityExtension {
return;
}
if (alignStride.shouldSkip()) {
return;
}
const currentBound = alignBound.moveDelta(context.dx, context.dy);
const alignStart = performance.now();
const alignRst = snapOverlay.align(currentBound);
const alignCost = performance.now() - alignStart;
alignStride.reportCost(alignCost);
context.dx = alignRst.dx + context.dx;
context.dy = alignRst.dy + context.dy;
},
clear() {
alignBound = null;
alignStride.reset();
snapOverlay.clear();
},
};

View File

@@ -6,6 +6,8 @@ import {
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
import type { GfxModel } from '@blocksuite/std/gfx';
import { AdaptiveCooldownController } from './adaptive-load-controller';
interface Distance {
horiz?: {
/**
@@ -35,6 +37,9 @@ interface Distance {
const ALIGN_THRESHOLD = 8;
const DISTRIBUTION_LINE_OFFSET = 1;
const STROKE_WIDTH = 2;
const DISTRIBUTE_ALIGN_MAX_CANDIDATES = 160;
const DISTRIBUTE_ALIGN_MAX_COST_MS = 5;
const DISTRIBUTE_ALIGN_COOLDOWN_FRAMES = 2;
export class SnapOverlay extends Overlay {
static override overlayName: string = 'snap-manager';
@@ -75,6 +80,11 @@ export class SnapOverlay extends Overlay {
vertical: [],
};
private readonly _distributeCooldown = new AdaptiveCooldownController({
cooldownFrames: DISTRIBUTE_ALIGN_COOLDOWN_FRAMES,
maxCostMs: DISTRIBUTE_ALIGN_MAX_COST_MS,
});
override clear() {
this._referenceBounds = {
vertical: [],
@@ -87,6 +97,7 @@ export class SnapOverlay extends Overlay {
};
this._distributedAlignLines = [];
this._skippedElements.clear();
this._distributeCooldown.reset();
super.clear();
}
@@ -673,13 +684,24 @@ export class SnapOverlay extends Overlay {
}
}
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
}
const shouldTryDistribute =
this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
this._distributeCooldown.shouldRun();
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
if (shouldTryDistribute) {
const distributeStart = performance.now();
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
}
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
}
const distributeCost = performance.now() - distributeStart;
this._distributeCooldown.reportCost(distributeCost);
}
this._renderer?.refresh();
@@ -776,24 +798,26 @@ export class SnapOverlay extends Overlay {
});
const verticalBounds: Bound[] = [];
const horizBounds: Bound[] = [];
const allBounds: Bound[] = [];
const allCandidateElements = new Set<GfxModel>();
vertCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
verticalBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
const bound = candidate.elementBound;
verticalBounds.push(bound);
allCandidateElements.add(candidate);
});
horizCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
horizBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
const bound = candidate.elementBound;
horizBounds.push(bound);
allCandidateElements.add(candidate);
});
this._referenceBounds = {
horizontal: horizBounds,
vertical: verticalBounds,
all: allBounds,
all: [...allCandidateElements].map(element => element.elementBound),
};
}

View File

@@ -4,7 +4,12 @@ import {
} from '@blocksuite/affine-block-surface';
import { on } from '@blocksuite/affine-shared/utils';
import type { PointerEventState } from '@blocksuite/std';
import { BaseTool, MouseButton, type ToolOptions } from '@blocksuite/std/gfx';
import {
BaseTool,
createRafCoalescer,
MouseButton,
type ToolOptions,
} from '@blocksuite/std/gfx';
import { Signal } from '@preact/signals-core';
interface RestorablePresentToolOptions {
@@ -21,13 +26,30 @@ export class PanTool extends BaseTool<PanToolOption> {
private _lastPoint: [number, number] | null = null;
private _pendingDelta: [number, number] = [0, 0];
private readonly _deltaFlushCoalescer = createRafCoalescer<void>(() => {
this._flushPendingDelta();
});
readonly panning$ = new Signal<boolean>(false);
private _flushPendingDelta() {
if (this._pendingDelta[0] === 0 && this._pendingDelta[1] === 0) {
return;
}
const [deltaX, deltaY] = this._pendingDelta;
this._pendingDelta = [0, 0];
this.gfx.viewport.applyDeltaCenter(deltaX, deltaY);
}
override get allowDragWithRightButton(): boolean {
return true;
}
override dragEnd(_: PointerEventState): void {
this._deltaFlushCoalescer.flush();
this._lastPoint = null;
this.panning$.value = false;
}
@@ -43,12 +65,14 @@ export class PanTool extends BaseTool<PanToolOption> {
const deltaY = lastY - e.y;
this._lastPoint = [e.x, e.y];
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
this._pendingDelta[0] += deltaX / zoom;
this._pendingDelta[1] += deltaY / zoom;
this._deltaFlushCoalescer.schedule(undefined);
}
override dragStart(e: PointerEventState): void {
this._lastPoint = [e.x, e.y];
this._pendingDelta = [0, 0];
this.panning$.value = true;
}
@@ -120,4 +144,8 @@ export class PanTool extends BaseTool<PanToolOption> {
return false;
});
}
override unmounted(): void {
this._deltaFlushCoalescer.cancel();
}
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/affine-gfx-pointer',
},
onConsoleLog(log, type) {
if (log.includes('lit.dev/msg/dev-mode')) {
return false;
}
console.warn(`Unexpected ${type} log`, log);
throw new Error(log);
},
environment: 'happy-dom',
},
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/await-thenable */
import type {
Template,
TemplateCategory,

View File

@@ -155,9 +155,22 @@ export class FrameBlockModel
}
removeChild(element: GfxModel): void {
this.removeChildren([element]);
}
removeChildren(elements: GfxModel[]): void {
const childIds = [...new Set(elements.map(element => element.id))];
if (!this.props.childElementIds || childIds.length === 0) {
return;
}
this.store.transact(() => {
this.props.childElementIds &&
delete this.props.childElementIds[element.id];
const childElementIds = this.props.childElementIds;
if (!childElementIds) return;
childIds.forEach(childId => {
delete childElementIds[childId];
});
});
}
}

View File

@@ -54,12 +54,21 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
}
override addChild(element: GfxModel) {
if (!canSafeAddToContainer(this, element)) {
this.addChildren([element]);
}
addChildren(elements: GfxModel[]) {
elements = [...new Set(elements)].filter(element =>
canSafeAddToContainer(this, element)
);
if (elements.length === 0) {
return;
}
this.surface.store.transact(() => {
this.children.set(element.id, true);
elements.forEach(element => {
this.children.set(element.id, true);
});
});
}
@@ -76,11 +85,22 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
}
removeChild(element: GfxModel) {
this.removeChildren([element]);
}
removeChildren(elements: GfxModel[]) {
if (!this.children) {
return;
}
const childIds = [...new Set(elements.map(element => element.id))];
if (childIds.length === 0) {
return;
}
this.surface.store.transact(() => {
this.children.delete(element.id);
childIds.forEach(childId => {
this.children.delete(childId);
});
});
}

View File

@@ -9,7 +9,7 @@ import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import type { AffineTextAttributes } from '../../types/index.js';
import { HtmlDeltaConverter } from '../html/delta-converter.js';
import type { HtmlDeltaConverter } from '../html/delta-converter.js';
import {
rehypeInlineToBlock,
rehypeWrapInlineElements,

View File

@@ -873,7 +873,7 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
return {
table: {
headerRows: 0,
widths: Array(sortedColumns.length).fill('*'),
widths: Array.from({ length: sortedColumns.length }, () => '*'),
body: tableBody,
},
margin: [0, 5, 0, 5],

View File

@@ -1,5 +1,7 @@
import { css, unsafeCSS } from 'lit';
import { unsafeCSSVarV2 } from '../theme/css-variables';
/**
* You should add a container before the scrollbar style to prevent the style pollution of the whole doc.
*/
@@ -28,7 +30,7 @@ export const scrollbarStyle = (container: string) => {
}
${unsafeCSS(container)}::-webkit-scrollbar-thumb {
border-radius: 2px;
background-color: #b1b1b1;
background-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')};
}
${unsafeCSS(container)}::-webkit-scrollbar-corner {
display: none;

View File

@@ -115,12 +115,9 @@ export async function printToPdf(
) as HTMLDivElement;
// force light theme in print iframe
iframe.contentWindow.document.documentElement.setAttribute(
'data-theme',
'light'
);
iframe.contentWindow.document.body.setAttribute('data-theme', 'light');
importedRoot.setAttribute('data-theme', 'light');
iframe.contentWindow.document.documentElement.dataset.theme = 'light';
iframe.contentWindow.document.body.dataset.theme = 'light';
importedRoot.dataset.theme = 'light';
// draw saved canvas image to canvas
const allImportedCanvas = importedRoot.getElementsByTagName('canvas');

View File

@@ -126,7 +126,7 @@ export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
this.disposables.add(
effect(() => {
this.gfx.tool.currentToolName$.value;
void this.gfx.tool.currentToolName$.value;
this.requestUpdate();
})
);

View File

@@ -289,7 +289,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
this.disposables.add(
effect(() => {
const std = this.rootComponent.std;
std.selection.value;
void std.selection.value;
// wait cursor updated
requestAnimationFrame(() => {
this._scrollCurrentBlockIntoView();

View File

@@ -220,9 +220,7 @@ export class ImportDoc extends WithDisposable(LitElement) {
</header>
<div>
AFFiNE will gradually support more file formats for import.
<a
href="https://community.affine.pro/c/feature-requests/import-export"
target="_blank"
<a href="https://affine.pro/redirect/discord" target="_blank"
>Provide feedback.</a
>
</div>

View File

@@ -1,5 +1,5 @@
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
// @ts-ignore
// @ts-expect-error -- mammoth.browser has no compatible type declaration for this subpath.
import { convertToHtml } from 'mammoth/mammoth.browser';
import { HtmlTransformer } from './html';

View File

@@ -10,12 +10,12 @@ import { Container } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { sha } from '@blocksuite/global/utils';
import type {
DocMeta,
ExtensionType,
Schema,
Store,
Workspace,
} from '@blocksuite/store';
import type { DocMeta } from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';

View File

@@ -171,9 +171,11 @@ export class Unzip {
const fileExt =
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
const mime = extMimeMap.get(fileExt ?? '');
const content = new File([this.unzipped![path]], fileName, {
type: mime ?? '',
}) as Blob;
const content = new File(
[new Uint8Array(this.unzipped![path]).buffer],
fileName,
mime ? { type: mime } : undefined
) as Blob;
const fixedPath = this.fixFileNameEncoding(path);

View File

@@ -27,10 +27,10 @@ async function exportDocs(
titleMiddleware(collection.meta.docMetas),
],
});
const snapshots = await Promise.all(docs.map(job.docToSnapshot));
await Promise.all(
snapshots
docs
.map(job.docToSnapshot)
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
.map(async snapshot => {
// Use the title and id as the snapshot file name

View File

@@ -34,6 +34,7 @@
- [canSafeAddToContainer](functions/canSafeAddToContainer.md)
- [compareLayer](functions/compareLayer.md)
- [convert](functions/convert.md)
- [createRafCoalescer](functions/createRafCoalescer.md)
- [derive](functions/derive.md)
- [generateKeyBetween](functions/generateKeyBetween.md)
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
@@ -42,5 +43,6 @@
- [GfxCompatible](functions/GfxCompatible.md)
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
- [local](functions/local.md)
- [measureOperation](functions/measureOperation.md)
- [observe](functions/observe.md)
- [watch](functions/watch.md)

View File

@@ -0,0 +1,27 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / createRafCoalescer
# Function: createRafCoalescer()
> **createRafCoalescer**\<`T`\>(`apply`): `RafCoalescer`\<`T`\>
Coalesce high-frequency updates and only process the latest payload in one frame.
## Type Parameters
### T
`T`
## Parameters
### apply
(`payload`) => `void`
## Returns
`RafCoalescer`\<`T`\>

View File

@@ -0,0 +1,34 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / measureOperation
# Function: measureOperation()
> **measureOperation**\<`T`\>(`name`, `fn`): `T`
Measure operation cost via Performance API when available.
Marks are always cleared, while measure entries are intentionally retained
so callers can inspect them from Performance tools.
## Type Parameters
### T
`T`
## Parameters
### name
`string`
### fn
() => `T`
## Returns
`T`

View File

@@ -356,3 +356,63 @@ describe('convert decorator', () => {
expect(elementModel.shapeType).toBe('rect');
});
});
describe('surface group index cache', () => {
test('syncGroupChildrenIndex should replace outdated parent mappings', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
model._syncGroupChildrenIndex('group-1', ['a', 'b'], []);
expect(model._parentGroupMap.get('a')).toBe('group-1');
expect(model._parentGroupMap.get('b')).toBe('group-1');
model._syncGroupChildrenIndex('group-1', ['b', 'c']);
expect(model._parentGroupMap.has('a')).toBe(false);
expect(model._parentGroupMap.get('b')).toBe('group-1');
expect(model._parentGroupMap.get('c')).toBe('group-1');
});
test('removeGroupFromChildrenIndex should clear both child snapshot and reverse lookup', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
model._syncGroupChildrenIndex('group-2', ['x', 'y'], []);
model._removeGroupFromChildrenIndex('group-2');
expect(model._groupChildIdsMap.has('group-2')).toBe(false);
expect(model._parentGroupMap.has('x')).toBe(false);
expect(model._parentGroupMap.has('y')).toBe(false);
});
test('getGroup should recover from stale cache and update reverse lookup', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
const shapeId = surfaceModel.addElement({
type: 'testShape',
});
const shape = surfaceModel.getElementById(shapeId)!;
const fakeGroup = {
id: 'group-fallback',
hasChild: (element: { id: string }) => element.id === shapeId,
};
model._groupLikeModels.set(fakeGroup.id, fakeGroup);
model._parentGroupMap.set(shapeId, 'stale-group-id');
expect(surfaceModel.getGroup(shapeId)).toBe(fakeGroup);
expect(model._parentGroupMap.get(shapeId)).toBe(fakeGroup.id);
expect(model._parentGroupMap.has('stale-group-id')).toBe(false);
const otherShapeId = surfaceModel.addElement({
type: 'testShape',
});
model._parentGroupMap.set(otherShapeId, 'another-missing-group');
expect(surfaceModel.getGroup(otherShapeId)).toBeNull();
expect(model._parentGroupMap.has(otherShapeId)).toBe(false);
// keep one explicit check on element-based lookup path
expect(surfaceModel.getGroup(shape as any)).toBe(fakeGroup);
});
});

View File

@@ -0,0 +1,165 @@
import { describe, expect, test, vi } from 'vitest';
import {
type GfxGroupCompatibleInterface,
gfxGroupCompatibleSymbol,
} from '../../gfx/model/base.js';
import type { GfxModel } from '../../gfx/model/model.js';
import {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
} from '../../utils/tree.js';
type TestElement = {
id: string;
group: TestGroup | null;
groups: TestGroup[];
};
type TestGroup = TestElement & {
[gfxGroupCompatibleSymbol]: true;
childIds: string[];
childElements: GfxModel[];
addChild: (element: GfxModel) => void;
removeChild: (element: GfxModel) => void;
hasChild: (element: GfxModel) => boolean;
hasDescendant: (element: GfxModel) => boolean;
};
const createElement = (id: string): TestElement => ({
id,
group: null,
groups: [],
});
const createGroup = (id: string): TestGroup => {
const group: TestGroup = {
id,
[gfxGroupCompatibleSymbol]: true,
group: null,
groups: [],
childIds: [],
childElements: [],
addChild(element: GfxModel) {
const child = element as unknown as TestElement;
if (this.childElements.includes(element)) {
return;
}
this.childElements.push(element);
this.childIds.push(child.id);
child.group = this;
child.groups = [...this.groups, this];
},
removeChild(element: GfxModel) {
const child = element as unknown as TestElement;
this.childElements = this.childElements.filter(item => item !== element);
this.childIds = this.childIds.filter(id => id !== child.id);
if (child.group === this) {
child.group = null;
child.groups = [];
}
},
hasChild(element: GfxModel) {
return this.childElements.includes(element);
},
hasDescendant(element: GfxModel) {
return descendantElementsImpl(
this as unknown as GfxGroupCompatibleInterface
).includes(element);
},
};
return group;
};
describe('tree utils', () => {
test('batchAddChildren prefers container.addChildren and deduplicates', () => {
const a = createElement('a') as unknown as GfxModel;
const b = createElement('b') as unknown as GfxModel;
const container = {
addChildren: vi.fn(),
addChild: vi.fn(),
};
batchAddChildren(container as any, [a, a, b]);
expect(container.addChildren).toHaveBeenCalledTimes(1);
expect(container.addChildren).toHaveBeenCalledWith([a, b]);
expect(container.addChild).not.toHaveBeenCalled();
});
test('batchRemoveChildren falls back to container.removeChild and deduplicates', () => {
const a = createElement('a') as unknown as GfxModel;
const b = createElement('b') as unknown as GfxModel;
const container = {
removeChild: vi.fn(),
};
batchRemoveChildren(container as any, [a, a, b]);
expect(container.removeChild).toHaveBeenCalledTimes(2);
expect(container.removeChild).toHaveBeenNthCalledWith(1, a);
expect(container.removeChild).toHaveBeenNthCalledWith(2, b);
});
test('getTopElements removes descendants when ancestors are selected', () => {
const root = createGroup('root');
const nested = createGroup('nested');
const leafA = createElement('leaf-a');
const leafB = createElement('leaf-b');
const leafC = createElement('leaf-c');
root.addChild(leafA as unknown as GfxModel);
root.addChild(nested as unknown as GfxModel);
nested.addChild(leafB as unknown as GfxModel);
const result = getTopElements([
root as unknown as GfxModel,
nested as unknown as GfxModel,
leafA as unknown as GfxModel,
leafB as unknown as GfxModel,
leafC as unknown as GfxModel,
]);
expect(result).toEqual([
root as unknown as GfxModel,
leafC as unknown as GfxModel,
]);
});
test('descendantElementsImpl stops on cyclic graph', () => {
const groupA = createGroup('group-a');
const groupB = createGroup('group-b');
groupA.addChild(groupB as unknown as GfxModel);
groupB.addChild(groupA as unknown as GfxModel);
const descendants = descendantElementsImpl(groupA as unknown as any);
expect(descendants).toHaveLength(2);
expect(new Set(descendants).size).toBe(2);
});
test('canSafeAddToContainer blocks self and circular descendants', () => {
const parent = createGroup('parent');
const child = createGroup('child');
const unrelated = createElement('plain');
parent.addChild(child as unknown as GfxModel);
expect(
canSafeAddToContainer(parent as unknown as any, parent as unknown as any)
).toBe(false);
expect(
canSafeAddToContainer(child as unknown as any, parent as unknown as any)
).toBe(false);
expect(
canSafeAddToContainer(
parent as unknown as any,
unrelated as unknown as any
)
).toBe(true);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, expect, test } from 'vitest';
import { bindKeymap } from '../event/keymap.js';
const createKeyboardEvent = (options: {
key: string;
keyCode: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
}): KeyboardEvent => {
const event = new KeyboardEvent('keydown', {
key: options.key,
altKey: options.altKey ?? false,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false,
});
Object.defineProperty(event, 'keyCode', {
configurable: true,
get: () => options.keyCode,
});
Object.defineProperty(event, 'which', {
configurable: true,
get: () => options.keyCode,
});
return event;
};
const createCtx = (event: KeyboardEvent) => {
return {
get(name: string) {
if (name === 'keyboardState') {
return { raw: event };
}
return undefined;
},
} as any;
};
describe('bindKeymap', () => {
test('falls back to physical key for ctrl shortcuts on non-US layouts', () => {
let handled = false;
const handler = bindKeymap({
'Ctrl-f': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'а',
keyCode: 70,
ctrlKey: true,
});
expect(handler(createCtx(event))).toBe(true);
expect(handled).toBe(true);
});
test('does not fallback for Alt+locale-character letter input', () => {
let handled = false;
const handler = bindKeymap({
'Alt-s': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'ś',
keyCode: 83,
altKey: true,
});
expect(handler(createCtx(event))).toBe(false);
expect(handled).toBe(false);
});
test('keeps Alt+digit fallback for non-ASCII key outputs', () => {
let handled = false;
const handler = bindKeymap({
'Alt-0': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'º',
keyCode: 48,
altKey: true,
});
expect(handler(createCtx(event))).toBe(true);
expect(handled).toBe(true);
});
test('does not fallback on non-ASCII input without modifiers', () => {
let handled = false;
const handler = bindKeymap({
'[': () => {
handled = true;
return true;
},
});
const event = createKeyboardEvent({
key: 'х',
keyCode: 219,
});
expect(handler(createCtx(event))).toBe(false);
expect(handled).toBe(false);
});
});

View File

@@ -190,7 +190,7 @@ export class Clipboard extends LifeCycleWatcher {
);
}
return slice;
} catch (error) {
} catch {
const getDataByType = this._getDataByType(data);
const slice = await this._getSnapshotByPriority(
type => getDataByType(type),

View File

@@ -90,9 +90,21 @@ export function bindKeymap(
// Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard),
// because the user intends to type that character, not trigger a shortcut bound to the physical key.
// See: https://github.com/toeverything/AFFiNE/issues/14059
const hasModifier = event.shiftKey || event.altKey || event.metaKey;
const hasModifier =
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
const baseName = base[event.keyCode];
if (hasModifier && baseName && baseName !== name) {
const isSingleAscii = name.length === 1 && name.charCodeAt(0) <= 0x7e;
const isAltInputChar = event.altKey && !event.ctrlKey && !isSingleAscii;
// Keep supporting existing Alt+digit shortcuts (e.g. Alt-0/1/2 in edgeless)
// while preventing Alt-based locale input characters from triggering letter shortcuts.
const isDigitBaseKey =
baseName != null && baseName.length === 1 && /[0-9]/.test(baseName);
if (
hasModifier &&
baseName &&
baseName !== name &&
!(isAltInputChar && !isDigitBaseKey)
) {
const fromCode = map[modifiers(baseName, event)];
if (fromCode && fromCode(ctx)) {
return true;

View File

@@ -1,5 +1,5 @@
import { LifeCycleWatcher } from '../extension/index.js';
import { BlockServiceIdentifier } from '../identifier.js';
import { LifeCycleWatcher } from './lifecycle-watcher.js';
export class ServiceManager extends LifeCycleWatcher {
static override readonly key = 'serviceManager';

View File

@@ -5,6 +5,8 @@ export {
SortOrder,
} from '../utils/layer.js';
export {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
@@ -94,6 +96,8 @@ export {
type SurfaceBlockProps,
type SurfaceMiddleware,
} from './model/surface/surface-model.js';
export { measureOperation } from './perf.js';
export { createRafCoalescer, type RafCoalescer } from './raf-coalescer.js';
export { GfxSelectionManager } from './selection.js';
export {
SurfaceMiddlewareBuilder,

View File

@@ -11,6 +11,7 @@ import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
import type { GfxModel } from '../model/model.js';
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import { createRafCoalescer } from '../raf-coalescer.js';
import type { GfxElementModelView } from '../view/view.js';
import { createInteractionContext, type SupportedEvents } from './event.js';
import {
@@ -55,6 +56,20 @@ export const InteractivityIdentifier = GfxExtensionIdentifier(
'interactivity-manager'
) as ServiceIdentifier<InteractivityManager>;
const DRAG_MOVE_RAF_THRESHOLD = 100;
const DRAG_MOVE_HEAVY_COST_MS = 4;
const shouldAllowDragMoveCoalescing = (
elements: { model: GfxModel }[]
): boolean => {
return elements.every(({ model }) => {
const isConnector = 'type' in model && model.type === 'connector';
const isContainer = 'childIds' in model;
return !isConnector && !isContainer;
});
};
export class InteractivityManager extends GfxExtension {
static override key = 'interactivity-manager';
@@ -381,11 +396,18 @@ export class InteractivityManager extends GfxExtension {
};
let dragLastPos = internal.dragStartPos;
let lastEvent = event;
let lastMoveDelta: [number, number] | null = null;
const canCoalesceDragMove = shouldAllowDragMoveCoalescing(
internal.elements
);
let shouldCoalesceDragMove =
canCoalesceDragMove &&
internal.elements.length >= DRAG_MOVE_RAF_THRESHOLD;
const applyDragMove = (event: PointerEvent) => {
const moveStart = performance.now();
lastEvent = event;
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
onDragMove(lastEvent as PointerEvent);
});
const onDragMove = (event: PointerEvent) => {
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
);
@@ -407,6 +429,16 @@ export class InteractivityManager extends GfxExtension {
moveContext[direction] = 0;
}
if (
lastMoveDelta &&
lastMoveDelta[0] === moveContext.dx &&
lastMoveDelta[1] === moveContext.dy
) {
return;
}
lastMoveDelta = [moveContext.dx, moveContext.dy];
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler?.onDragMove?.(moveContext)
@@ -423,13 +455,39 @@ export class InteractivityManager extends GfxExtension {
elements: internal.elements,
});
});
if (
canCoalesceDragMove &&
!shouldCoalesceDragMove &&
performance.now() - moveStart > DRAG_MOVE_HEAVY_COST_MS
) {
shouldCoalesceDragMove = true;
}
};
const dragMoveCoalescer = createRafCoalescer<PointerEvent>(applyDragMove);
const flushPendingDragMove = () => {
dragMoveCoalescer.flush();
};
const onDragMove = (event: PointerEvent) => {
if (!shouldCoalesceDragMove) {
applyDragMove(event);
return;
}
dragMoveCoalescer.schedule(event);
};
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
onDragMove(lastEvent as PointerEvent);
});
const onDragEnd = (event: PointerEvent) => {
this.activeInteraction$.value = null;
host.removeEventListener('pointermove', onDragMove, false);
host.removeEventListener('pointerup', onDragEnd, false);
viewportWatcher.unsubscribe();
flushPendingDragMove();
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])

View File

@@ -101,6 +101,8 @@ export class LayerManager extends GfxExtension {
layers: Layer[] = [];
private readonly _groupChildSnapshot = new Map<string, string[]>();
slots = {
layerUpdated: new Subject<{
type: 'delete' | 'add' | 'update';
@@ -148,6 +150,43 @@ export class LayerManager extends GfxExtension {
: 'block';
}
private _getModelById(id: string): GfxModel | null {
if (!this._surface) return null;
return (
this._surface.getElementById(id) ??
(this._doc.getModelById(id) as GfxModel | undefined) ??
null
);
}
private _getRelatedGroupElements(
group: GfxModel & GfxGroupCompatibleInterface,
oldChildIds?: string[]
) {
const elements = new Set<GfxModel>([group, ...group.descendantElements]);
oldChildIds?.forEach(id => {
const model = this._getModelById(id);
if (!model) return;
elements.add(model);
if (isGfxGroupCompatibleModel(model)) {
model.descendantElements.forEach(descendant => {
elements.add(descendant);
});
}
});
return [...elements];
}
private _syncGroupChildSnapshot(
group: GfxModel & GfxGroupCompatibleInterface
) {
this._groupChildSnapshot.set(group.id, [...group.childIds]);
}
private _initLayers() {
let blockIdx = 0;
let canvasIdx = 0;
@@ -487,6 +526,29 @@ export class LayerManager extends GfxExtension {
updateLayersZIndex(layers, index);
}
private _refreshElementsInLayer(elements: GfxModel[]) {
const uniqueElements = [...new Set(elements)];
uniqueElements.forEach(element => {
const modelType = this._getModelType(element);
if (modelType === 'canvas') {
removeFromOrderedArray(this.canvasElements, element);
insertToOrderedArray(this.canvasElements, element);
} else {
removeFromOrderedArray(this.blocks, element);
insertToOrderedArray(this.blocks, element);
}
});
uniqueElements.forEach(element => {
this._removeFromLayer(element, this._getModelType(element));
});
uniqueElements.sort(compare).forEach(element => {
this._insertIntoLayer(element, this._getModelType(element));
});
}
private _reset() {
const elements = (
this._doc
@@ -512,6 +574,17 @@ export class LayerManager extends GfxExtension {
this.canvasElements.sort(compare);
this.blocks.sort(compare);
this._groupChildSnapshot.clear();
this.canvasElements.forEach(element => {
if (isGfxGroupCompatibleModel(element)) {
this._syncGroupChildSnapshot(element);
}
});
this.blocks.forEach(element => {
if (isGfxGroupCompatibleModel(element)) {
this._syncGroupChildSnapshot(element);
}
});
this._initLayers();
this._buildCanvasLayers();
@@ -522,7 +595,8 @@ export class LayerManager extends GfxExtension {
*/
private _updateLayer(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
) {
const modelType = this._getModelType(element);
const isLocalElem = element instanceof GfxLocalElementModel;
@@ -539,7 +613,16 @@ export class LayerManager extends GfxExtension {
};
if (shouldUpdateGroupChildren) {
this._reset();
const group = element as GfxModel & GfxGroupCompatibleInterface;
const oldChildIds = childIdsChanged
? Array.isArray(oldValues?.['childIds'])
? (oldValues['childIds'] as string[])
: this._groupChildSnapshot.get(group.id)
: undefined;
const relatedElements = this._getRelatedGroupElements(group, oldChildIds);
this._refreshElementsInLayer(relatedElements);
this._syncGroupChildSnapshot(group);
return true;
}
@@ -581,6 +664,13 @@ export class LayerManager extends GfxExtension {
element
);
}
if (isContainer) {
this._syncGroupChildSnapshot(
element as GfxModel & GfxGroupCompatibleInterface
);
}
this._insertIntoLayer(element as GfxModel, modelType);
if (isContainer) {
@@ -648,7 +738,26 @@ export class LayerManager extends GfxExtension {
const isLocalElem = element instanceof GfxLocalElementModel;
if (isGroup) {
this._reset();
const groupElements = this._getRelatedGroupElements(
element as GfxModel & GfxGroupCompatibleInterface
);
const descendants = groupElements.filter(model => model !== element);
if (!isLocalElem) {
const groupType = this._getModelType(element);
if (groupType === 'canvas') {
removeFromOrderedArray(this.canvasElements, element);
} else {
removeFromOrderedArray(this.blocks, element);
}
this._removeFromLayer(element, groupType);
}
this._groupChildSnapshot.delete(element.id);
this._refreshElementsInLayer(descendants);
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'delete',
initiatingElement: element as GfxModel,
@@ -680,6 +789,7 @@ export class LayerManager extends GfxExtension {
override unmounted() {
this.slots.layerUpdated.complete();
this._groupChildSnapshot.clear();
this._disposable.dispose();
}
@@ -777,9 +887,10 @@ export class LayerManager extends GfxExtension {
update(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
) {
if (this._updateLayer(element, props)) {
if (this._updateLayer(element, props, oldValues)) {
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'update',
@@ -867,7 +978,11 @@ export class LayerManager extends GfxExtension {
this._disposable.add(
surface.elementUpdated.subscribe(payload => {
if (payload.props['index'] || payload.props['childIds']) {
this.update(surface.getElementById(payload.id)!, payload.props);
this.update(
surface.getElementById(payload.id)!,
payload.props,
payload.oldValues
);
}
})
);

View File

@@ -6,6 +6,7 @@ import { signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import { measureOperation } from '../../perf.js';
import {
type GfxGroupCompatibleInterface,
isGfxGroupCompatibleModel,
@@ -74,6 +75,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
protected _groupLikeModels = new Map<string, GfxGroupModel>();
protected _parentGroupMap = new Map<string, string>();
protected _groupChildIdsMap = new Map<string, string[]>();
protected _middlewares: SurfaceMiddleware[] = [];
protected _surfaceBlockModel = true;
@@ -133,6 +138,44 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
});
}
private _collectElementsToDelete(
id: string,
deleteElementIds: Set<string>,
orderedDeleteIds: string[],
deleteBlockIds: Set<string>
) {
if (deleteElementIds.has(id)) {
return;
}
const element = this.getElementById(id);
if (!element) {
return;
}
deleteElementIds.add(id);
if (element instanceof GfxGroupLikeElementModel) {
element.childIds.forEach(childId => {
if (this.hasElementById(childId)) {
this._collectElementsToDelete(
childId,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
return;
}
if (this.store.hasBlock(childId)) {
deleteBlockIds.add(childId);
}
});
}
orderedDeleteIds.push(id);
}
private _createElementFromProps(
props: Record<string, unknown>,
options: {
@@ -247,6 +290,26 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
};
}
private _emitElementUpdated(
model: GfxPrimitiveElementModel,
payload: ElementUpdatedData
) {
if (
isGfxGroupCompatibleModel(model) &&
('childIds' in payload.props || 'childIds' in payload.oldValues)
) {
const oldChildIds = Array.isArray(payload.oldValues['childIds'])
? (payload.oldValues['childIds'] as string[])
: undefined;
this._syncGroupChildrenIndex(model.id, model.childIds, oldChildIds);
}
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.propsUpdated.next({ key });
});
}
private _initElementModels() {
const elementsYMap = this.elements.getValue()!;
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
@@ -260,6 +323,7 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
if (isGfxGroupCompatibleModel(model)) {
this._groupLikeModels.set(model.id, model);
this._syncGroupChildrenIndex(model.id, model.childIds, []);
}
};
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
@@ -270,7 +334,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
sameTypeElements.splice(index, 1);
}
if (this._groupLikeModels.has(model.id)) {
this._parentGroupMap.delete(model.id);
if (isGfxGroupCompatibleModel(model)) {
this._removeGroupFromChildrenIndex(model.id);
this._groupLikeModels.delete(model.id);
}
};
@@ -304,9 +371,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
element,
{
onChange: payload => {
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
this._emitElementUpdated(model.model, {
...payload,
id,
});
},
skipFieldInit: true,
@@ -351,10 +418,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
val,
{
onChange: payload => {
(this.elementUpdated.next(payload),
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
}));
this._emitElementUpdated(model.model, {
...payload,
id: key,
});
},
skipFieldInit: true,
}
@@ -371,9 +438,12 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
Object.values(this.store.blocks.peek()).forEach(block => {
if (isGfxGroupCompatibleModel(block.model)) {
this._groupLikeModels.set(block.id, block.model);
this._syncGroupChildrenIndex(block.id, block.model.childIds, []);
}
});
this._rebuildGroupChildrenIndex();
elementsYMap.observe(onElementsMapChange);
const subscription = this.store.slots.blockUpdated.subscribe(payload => {
@@ -381,11 +451,17 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
case 'add':
if (isGfxGroupCompatibleModel(payload.model)) {
this._groupLikeModels.set(payload.id, payload.model);
this._syncGroupChildrenIndex(
payload.id,
payload.model.childIds,
[]
);
}
break;
case 'delete':
if (isGfxGroupCompatibleModel(payload.model)) {
this._removeGroupFromChildrenIndex(payload.id);
this._groupLikeModels.delete(payload.id);
}
{
@@ -395,6 +471,16 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
group.removeChild(payload.model as GfxModel);
}
}
this._parentGroupMap.delete(payload.id);
break;
case 'update':
if (payload.props.key === 'childElementIds') {
const group = this.store.getBlock(payload.id)?.model;
if (group && isGfxGroupCompatibleModel(group)) {
this._syncGroupChildrenIndex(group.id, group.childIds);
}
}
break;
}
@@ -403,6 +489,8 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
this.deleted.subscribe(() => {
elementsYMap.unobserve(onElementsMapChange);
subscription.unsubscribe();
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
});
}
@@ -500,6 +588,71 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
return this._elementCtorMap[type];
}
private _rebuildGroupChildrenIndex() {
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
this._groupLikeModels.forEach(group => {
this._syncGroupChildrenIndex(group.id, group.childIds, []);
});
}
private _removeFromParentGroupIfNeeded(
element: GfxModel,
deleteElementIds: Set<string>
) {
const parentGroupId = this._parentGroupMap.get(element.id);
if (parentGroupId && deleteElementIds.has(parentGroupId)) {
return;
}
let parentGroup: GfxGroupModel | null = null;
if (parentGroupId) {
parentGroup = this._groupLikeModels.get(parentGroupId) ?? null;
}
parentGroup = parentGroup ?? this.getGroup(element.id);
if (parentGroup && !deleteElementIds.has(parentGroup.id)) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parentGroup.removeChild(element);
}
}
private _removeGroupFromChildrenIndex(groupId: string) {
const previousChildIds = this._groupChildIdsMap.get(groupId) ?? [];
previousChildIds.forEach(childId => {
if (this._parentGroupMap.get(childId) === groupId) {
this._parentGroupMap.delete(childId);
}
});
this._groupChildIdsMap.delete(groupId);
}
private _syncGroupChildrenIndex(
groupId: string,
nextChildIds: string[],
previousChildIds?: string[]
) {
const prev = previousChildIds ?? this._groupChildIdsMap.get(groupId) ?? [];
prev.forEach(childId => {
if (this._parentGroupMap.get(childId) === groupId) {
this._parentGroupMap.delete(childId);
}
});
nextChildIds.forEach(childId => {
this._parentGroupMap.set(childId, groupId);
});
this._groupChildIdsMap.set(groupId, [...nextChildIds]);
}
addElement<T extends object = Record<string, unknown>>(
props: Partial<T> & { type: string }
) {
@@ -526,9 +679,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
const elementModel = this._createElementFromProps(props, {
onChange: payload => {
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
elementModel.model.propsUpdated.next({ key });
this._emitElementUpdated(elementModel.model, {
...payload,
id,
});
},
});
@@ -560,24 +713,48 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
return;
}
this.store.transact(() => {
const element = this.getElementById(id)!;
const group = this.getGroup(id);
measureOperation('edgeless:delete-element', () => {
const deleteElementIds = new Set<string>();
const orderedDeleteIds: string[] = [];
const deleteBlockIds = new Set<string>();
if (element instanceof GfxGroupLikeElementModel) {
element.childIds.forEach(childId => {
if (this.hasElementById(childId)) {
this.deleteElement(childId);
} else if (this.store.hasBlock(childId)) {
this.store.deleteBlock(this.store.getBlock(childId)!.model);
}
});
this._collectElementsToDelete(
id,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
if (orderedDeleteIds.length === 0) {
return;
}
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group?.removeChild(element as GfxModel);
this.store.transact(() => {
orderedDeleteIds.forEach(elementId => {
const element = this.getElementById(elementId);
this.elements.getValue()!.delete(id);
if (!element) {
return;
}
this._removeFromParentGroupIfNeeded(element, deleteElementIds);
this.elements.getValue()!.delete(elementId);
});
deleteBlockIds.forEach(blockId => {
const block = this.store.getBlock(blockId)?.model;
if (!block) {
return;
}
this._removeFromParentGroupIfNeeded(
block as GfxModel,
deleteElementIds
);
this.store.deleteBlock(block);
});
});
});
}
@@ -607,18 +784,31 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
}
getGroup(elem: string | GfxModel): GfxGroupModel | null {
elem =
const id = typeof elem === 'string' ? elem : elem.id;
const parentGroupId = this._parentGroupMap.get(id);
if (parentGroupId) {
const group = this._groupLikeModels.get(parentGroupId);
if (group) {
return group;
}
this._parentGroupMap.delete(id);
}
const model =
typeof elem === 'string'
? ((this.getElementById(elem) ??
this.store.getBlock(elem)?.model) as GfxModel)
: elem;
if (!elem) return null;
if (!model) return null;
assertType<GfxModel>(elem);
assertType<GfxModel>(model);
for (const group of this._groupLikeModels.values()) {
if (group.hasChild(elem)) {
if (group.hasChild(model)) {
this._parentGroupMap.set(id, group.id);
return group;
}
}

View File

@@ -0,0 +1,31 @@
let opMeasureSeq = 0;
/**
* Measure operation cost via Performance API when available.
*
* Marks are always cleared, while measure entries are intentionally retained
* so callers can inspect them from Performance tools.
*/
export const measureOperation = <T>(name: string, fn: () => T): T => {
if (
typeof performance === 'undefined' ||
typeof performance.mark !== 'function' ||
typeof performance.measure !== 'function'
) {
return fn();
}
const operationId = opMeasureSeq++;
const startMark = `${name}:${operationId}:start`;
const endMark = `${name}:${operationId}:end`;
performance.mark(startMark);
try {
return fn();
} finally {
performance.mark(endMark);
performance.measure(name, startMark, endMark);
performance.clearMarks(startMark);
performance.clearMarks(endMark);
}
};

View File

@@ -0,0 +1,76 @@
export interface RafCoalescer<T> {
cancel: () => void;
flush: () => void;
schedule: (payload: T) => void;
}
type FrameScheduler = (callback: FrameRequestCallback) => number;
type FrameCanceller = (id: number) => void;
const getFrameScheduler = (): FrameScheduler => {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame;
}
return callback => {
return globalThis.setTimeout(() => {
callback(
typeof performance !== 'undefined' ? performance.now() : Date.now()
);
}, 16) as unknown as number;
};
};
const getFrameCanceller = (): FrameCanceller => {
if (typeof cancelAnimationFrame === 'function') {
return cancelAnimationFrame;
}
return id => globalThis.clearTimeout(id);
};
/**
* Coalesce high-frequency updates and only process the latest payload in one frame.
*/
export const createRafCoalescer = <T>(
apply: (payload: T) => void
): RafCoalescer<T> => {
const scheduleFrame = getFrameScheduler();
const cancelFrame = getFrameCanceller();
let pendingPayload: T | undefined;
let hasPendingPayload = false;
let rafId: number | null = null;
const run = () => {
rafId = null;
if (!hasPendingPayload) return;
const payload = pendingPayload as T;
pendingPayload = undefined;
hasPendingPayload = false;
apply(payload);
};
return {
schedule(payload: T) {
pendingPayload = payload;
hasPendingPayload = true;
if (rafId !== null) return;
rafId = scheduleFrame(run);
},
flush() {
if (rafId !== null) cancelFrame(rafId);
run();
},
cancel() {
if (rafId !== null) {
cancelFrame(rafId);
rafId = null;
}
pendingPayload = undefined;
hasPendingPayload = false;
},
};
};

View File

@@ -41,6 +41,10 @@ export function requestThrottledConnectedFrame<
viewport: PropTypes.instanceOf(Viewport),
})
export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private static readonly VIEWPORT_REFRESH_PIXEL_THRESHOLD = 18;
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
static override styles = css`
gfx-viewport {
position: absolute;
@@ -104,6 +108,14 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private _lastVisibleModels?: Set<GfxBlockElementModel>;
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
private _lastViewportRefreshTime = 0;
private _pendingViewportRefreshTimer: ReturnType<
typeof globalThis.setTimeout
> | null = null;
private readonly _pendingChildrenUpdates: {
id: string;
resolve: () => void;
@@ -115,26 +127,90 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private _updatingChildrenFlag = false;
private _clearPendingViewportRefreshTimer() {
if (this._pendingViewportRefreshTimer !== null) {
clearTimeout(this._pendingViewportRefreshTimer);
this._pendingViewportRefreshTimer = null;
}
}
private _scheduleTrailingViewportRefresh() {
this._clearPendingViewportRefreshTimer();
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
this._pendingViewportRefreshTimer = null;
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
}
private _refreshViewportByViewportUpdate(update: {
zoom: number;
center: [number, number];
}) {
const now = performance.now();
const previous = this._lastViewportUpdate;
this._lastViewportUpdate = {
zoom: update.zoom,
center: [update.center[0], update.center[1]],
};
if (!previous) {
this._lastViewportRefreshTime = now;
this._refreshViewport();
return;
}
const zoomChanged = Math.abs(previous.zoom - update.zoom) > 0.0001;
const centerMovedInPixel = Math.hypot(
(update.center[0] - previous.center[0]) * update.zoom,
(update.center[1] - previous.center[1]) * update.zoom
);
const timeoutReached =
now - this._lastViewportRefreshTime >=
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
if (
zoomChanged ||
centerMovedInPixel >=
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
timeoutReached
) {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = now;
this._refreshViewport();
return;
}
this._scheduleTrailingViewportRefresh();
}
override connectedCallback(): void {
super.connectedCallback();
const viewportUpdateCallback = () => {
this._refreshViewport();
};
if (!this.enableChildrenSchedule) {
delete this.scheduleUpdateChildren;
}
this._hideOutsideAndNoSelectedBlock();
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
this.viewport.viewportUpdated.subscribe(update =>
this._refreshViewportByViewportUpdate(update)
)
);
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => viewportUpdateCallback())
this.viewport.sizeUpdated.subscribe(() => {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
})
);
}
override disconnectedCallback(): void {
this._clearPendingViewportRefreshTimer();
super.disconnectedCallback();
}
override render() {
return html``;
}

View File

@@ -7,6 +7,11 @@ import {
} from '../gfx/model/base.js';
import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
type BatchGroupContainer = GfxGroupCompatibleInterface & {
addChildren?: (elements: GfxModel[]) => void;
removeChildren?: (elements: GfxModel[]) => void;
};
/**
* Get the top elements from the list of elements, which are in some tree structures.
*
@@ -26,19 +31,65 @@ import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
* The result should be `[G1, G4, E6]`
*/
export function getTopElements(elements: GfxModel[]): GfxModel[] {
const results = new Set(elements);
const uniqueElements = [...new Set(elements)];
const selected = new Set(uniqueElements);
const topElements: GfxModel[] = [];
elements = [...new Set(elements)];
for (const element of uniqueElements) {
let ancestor = element.group;
let hasSelectedAncestor = false;
elements.forEach(e1 => {
elements.forEach(e2 => {
if (isGfxGroupCompatibleModel(e1) && e1.hasDescendant(e2)) {
results.delete(e2);
while (ancestor) {
if (selected.has(ancestor as GfxModel)) {
hasSelectedAncestor = true;
break;
}
});
});
ancestor = ancestor.group;
}
return [...results];
if (!hasSelectedAncestor) {
topElements.push(element);
}
}
return topElements;
}
export function batchAddChildren(
container: GfxGroupCompatibleInterface,
elements: GfxModel[]
) {
const uniqueElements = [...new Set(elements)];
if (uniqueElements.length === 0) return;
const batchContainer = container as BatchGroupContainer;
if (batchContainer.addChildren) {
batchContainer.addChildren(uniqueElements);
return;
}
uniqueElements.forEach(element => {
container.addChild(element);
});
}
export function batchRemoveChildren(
container: GfxGroupCompatibleInterface,
elements: GfxModel[]
) {
const uniqueElements = [...new Set(elements)];
if (uniqueElements.length === 0) return;
const batchContainer = container as BatchGroupContainer;
if (batchContainer.removeChildren) {
batchContainer.removeChildren(uniqueElements);
return;
}
uniqueElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
container.removeChild(element);
});
}
function traverse(
@@ -64,7 +115,9 @@ function traverse(
});
}
postCallBack && postCallBack(element);
if (postCallBack) {
postCallBack(element);
}
};
innerTraverse(element);

View File

@@ -170,10 +170,10 @@ export class EditorHost extends SignalWatcher(
...Object.values(widgetTags),
];
await Promise.all(
elementsTags.map(tag => {
elementsTags.map(async tag => {
const element = this.renderRoot.querySelector(tag._$litStatic$);
if (element instanceof LitElement) {
return element.updateComplete;
return await element.updateComplete;
}
return null;
})

View File

@@ -382,6 +382,7 @@ describe('addBlock', () => {
const doc0 = collection.createDoc('doc:home');
const doc1 = collection.createDoc('space:doc1');
// eslint-disable-next-line @typescript-eslint/await-thenable
await Promise.all([doc0.load(), doc1.load()]);
assert.equal(collection.docs.size, 2);
const store0 = doc0.getStore({

View File

@@ -1,7 +1,7 @@
import { minimatch } from 'minimatch';
import { SCHEMA_NOT_FOUND_MESSAGE } from '../consts.js';
import { BlockSchema, type BlockSchemaType } from '../model/index.js';
import { BlockSchema, type BlockSchemaType } from '../model/block/zod.js';
import { SchemaValidateError } from './error.js';
/**

View File

@@ -1,9 +1,6 @@
import {
BlockModel,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index';
import { BlockModel } from '../model/block/block-model.js';
import { type DraftModel, toDraftModel } from '../model/block/draft.js';
import type { Store } from '../model/store/store.js';
type SliceData = {
content: DraftModel[];

View File

@@ -3,14 +3,11 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick } from '@blocksuite/global/utils';
import { Subject } from 'rxjs';
import {
BlockModel,
type BlockSchemaType,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index.js';
import type { Schema } from '../schema/index.js';
import { BlockModel } from '../model/block/block-model.js';
import { type DraftModel, toDraftModel } from '../model/block/draft.js';
import type { BlockSchemaType } from '../model/block/zod.js';
import type { Store } from '../model/store/store.js';
import type { Schema } from '../schema/schema.js';
import { AssetsManager } from './assets.js';
import { BaseBlockTransformer } from './base.js';
import type {

View File

@@ -5,6 +5,14 @@ import { wait } from '../utils/common.js';
import { getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
function expectPxCloseTo(
value: string,
expected: number,
precision: number = 2
) {
expect(Number.parseFloat(value)).toBeCloseTo(expected, precision);
}
describe('Shape rendering with DOM renderer', () => {
beforeEach(async () => {
const cleanup = await setupEditor('edgeless', [], {
@@ -59,7 +67,8 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.borderRadius).toBe('6px');
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.borderRadius, 6 * zoom);
});
test('should remove shape DOM node when element is deleted', async () => {
@@ -110,8 +119,9 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
});
test('should correctly render triangle shape', async () => {
@@ -132,7 +142,8 @@ describe('Shape rendering with DOM renderer', () => {
);
expect(shapeElement).not.toBeNull();
expect(shapeElement?.style.width).toBe('80px');
expect(shapeElement?.style.height).toBe('60px');
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
});
});

View File

@@ -235,6 +235,69 @@ describe('connector', () => {
expect(model.getConnectors(id2)).toEqual([]);
});
test('should update endpoint index when connector retargets', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const id3 = model.addElement({
type: 'shape',
});
const connectorId = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
const connector = model.getElementById(connectorId)!;
expect(model.getConnectors(id).map(c => c.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
model.updateElement(connectorId, {
source: {
id: id3,
},
target: {
id: id2,
},
});
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id3).map(c => c.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
});
test('getConnectors should purge stale connector ids from endpoint cache', () => {
const shapeId = model.addElement({
type: 'shape',
});
const surfaceModel = model as any;
surfaceModel._connectorIdsByEndpoint.set(
shapeId,
new Set(['missing-connector-id'])
);
surfaceModel._connectorEndpoints.set('missing-connector-id', {
sourceId: shapeId,
targetId: null,
});
expect(model.getConnectors(shapeId)).toEqual([]);
expect(
surfaceModel._connectorIdsByEndpoint
.get(shapeId)
?.has('missing-connector-id') ?? false
).toBe(false);
expect(surfaceModel._connectorEndpoints.has('missing-connector-id')).toBe(
false
);
});
test('should return null if connector are deleted', async () => {
const id = model.addElement({
type: 'shape',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,363 @@
import { LinkExtension } from '@blocksuite/affine-inline-link';
import { textKeymap } from '@blocksuite/affine-inline-preset';
import type {
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { insertContent } from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
import { TextSelection } from '@blocksuite/std';
import type { InlineMarkdownMatch } from '@blocksuite/std/inline';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defaultSlashMenuConfig } from '../../../../affine/widgets/slash-menu/src/config.js';
import type {
SlashMenuActionItem,
SlashMenuItem,
} from '../../../../affine/widgets/slash-menu/src/types.js';
import { wait } from '../utils/common.js';
import { addNote } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
type RichTextElement = HTMLElement & {
inlineEditor: {
getFormat: (range: {
index: number;
length: number;
}) => Record<string, unknown>;
getInlineRange: () => { index: number; length: number } | null;
setInlineRange: (range: { index: number; length: number }) => void;
yTextString: string;
};
markdownMatches: InlineMarkdownMatch[];
undoManager: {
stopCapturing: () => void;
};
};
function findSlashActionItem(
items: SlashMenuItem[],
name: string
): SlashMenuActionItem {
const item = items.find(entry => entry.name === name);
if (!item || !('action' in item)) {
throw new Error(`Cannot find slash-menu action: ${name}`);
}
return item;
}
function getRichTextByBlockId(blockId: string): RichTextElement {
const block = editor.host?.view.getBlock(blockId) as HTMLElement | null;
if (!block) {
throw new Error(`Cannot find block view: ${blockId}`);
}
const richText = block.querySelector('rich-text') as RichTextElement | null;
if (!richText) {
throw new Error(`Cannot find rich-text for block: ${blockId}`);
}
return richText;
}
async function createParagraph(text = '') {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const paragraph = note.children[0] as ParagraphBlockModel | undefined;
if (!paragraph) {
throw new Error('Cannot find paragraph model');
}
if (text) {
doc.updateBlock(paragraph, {
text: new Text(text),
});
}
await wait();
return {
noteId,
paragraphId: paragraph.id,
};
}
function setTextSelection(blockId: string, index: number, length: number) {
const to = length
? {
blockId,
index: index + length,
length: 0,
}
: null;
const selection = editor.host?.selection.create(TextSelection, {
from: {
blockId,
index,
length: 0,
},
to,
});
if (!selection) {
throw new Error('Cannot create text selection');
}
editor.host?.selection.setGroup('note', [selection]);
const richText = getRichTextByBlockId(blockId);
richText.inlineEditor.setInlineRange({ index, length });
}
async function triggerMarkdown(
blockId: string,
input: string,
matcherName: string
) {
const model = doc.getBlock(blockId)?.model as ParagraphBlockModel | undefined;
if (!model) {
throw new Error(`Cannot find paragraph model: ${blockId}`);
}
doc.updateBlock(model, {
text: new Text(input),
});
await wait();
const richText = getRichTextByBlockId(blockId);
const matcher = richText.markdownMatches.find(
item => item.name === matcherName
);
if (!matcher) {
throw new Error(`Cannot find markdown matcher: ${matcherName}`);
}
const inlineRange = { index: input.length, length: 0 };
setTextSelection(blockId, inlineRange.index, 0);
matcher.action({
inlineEditor: richText.inlineEditor as any,
prefixText: input,
inlineRange,
pattern: matcher.pattern,
undoManager: richText.undoManager as any,
});
await wait();
}
function mockKeyboardContext() {
const preventDefault = vi.fn();
const ctx = {
get(key: string) {
if (key === 'keyboardState') {
return { raw: { preventDefault } };
}
throw new Error(`Unexpected state key: ${key}`);
},
};
return { ctx: ctx as any, preventDefault };
}
beforeEach(async () => {
const cleanup = await setupEditor('page', [LinkExtension]);
return cleanup;
});
describe('markdown/list/paragraph/quote/code/link', () => {
test('markdown list shortcut converts to todo list and keeps checked state', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '[x] ', 'list');
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const model = note.children[0] as ListBlockModel;
expect(model.flavour).toBe('affine:list');
expect(model.props.type).toBe('todo');
expect(model.props.checked).toBe(true);
});
test('markdown heading and quote shortcuts convert paragraph type', async () => {
const { noteId: headingNoteId, paragraphId: headingParagraphId } =
await createParagraph();
await triggerMarkdown(headingParagraphId, '# ', 'heading');
const headingNote = doc.getBlock(headingNoteId)?.model;
if (!headingNote) {
throw new Error('Cannot find heading note model');
}
const headingModel = headingNote.children[0] as ParagraphBlockModel;
expect(headingModel.flavour).toBe('affine:paragraph');
expect(headingModel.props.type).toBe('h1');
const { noteId: quoteNoteId, paragraphId: quoteParagraphId } =
await createParagraph();
await triggerMarkdown(quoteParagraphId, '> ', 'heading');
const quoteNote = doc.getBlock(quoteNoteId)?.model;
if (!quoteNote) {
throw new Error('Cannot find quote note model');
}
const quoteModel = quoteNote.children[0] as ParagraphBlockModel;
expect(quoteModel.flavour).toBe('affine:paragraph');
expect(quoteModel.props.type).toBe('quote');
});
test('markdown code shortcut converts paragraph to code block with language', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const model = note.children[0];
expect(model.flavour).toBe('affine:code');
expect((model as any).props.language).toBe('typescript');
});
test('inline markdown converts style and link attributes', async () => {
const { paragraphId: boldParagraphId } = await createParagraph();
await triggerMarkdown(boldParagraphId, '**bold** ', 'bold');
const boldRichText = getRichTextByBlockId(boldParagraphId);
expect(boldRichText.inlineEditor.yTextString).toBe('bold');
expect(
boldRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
bold: true,
});
const { paragraphId: codeParagraphId } = await createParagraph();
await triggerMarkdown(codeParagraphId, '`code` ', 'code');
const codeRichText = getRichTextByBlockId(codeParagraphId);
expect(codeRichText.inlineEditor.yTextString).toBe('code');
expect(
codeRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
code: true,
});
const { paragraphId: linkParagraphId } = await createParagraph();
await triggerMarkdown(
linkParagraphId,
'[AFFiNE](https://affine.pro) ',
'link'
);
const linkRichText = getRichTextByBlockId(linkParagraphId);
expect(linkRichText.inlineEditor.yTextString).toBe('AFFiNE');
expect(
linkRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
link: 'https://affine.pro',
});
});
});
describe('hotkey/bracket/linked-page', () => {
test('bracket keymap skips redundant right bracket in code block', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
const note = doc.getBlock(noteId)?.model;
const codeId = note?.children[0]?.id;
if (!codeId) {
throw new Error('Cannot find code block id');
}
const codeModel = doc.getBlock(codeId)?.model;
if (!codeModel) {
throw new Error('Cannot find code block model');
}
const keymap = textKeymap(editor.std);
const leftHandler = keymap['('];
const rightHandler = keymap[')'];
expect(leftHandler).toBeDefined();
if (!rightHandler) {
throw new Error('Cannot find bracket key handlers');
}
doc.updateBlock(codeModel, {
text: new Text('()'),
});
await wait();
const codeRichText = getRichTextByBlockId(codeId);
setTextSelection(codeId, 1, 0);
const rightContext = mockKeyboardContext();
rightHandler(rightContext.ctx);
expect(rightContext.preventDefault).not.toHaveBeenCalled();
expect(codeRichText.inlineEditor.yTextString).toBe('()');
});
test('consecutive linked-page reference nodes render as separate references', async () => {
const { paragraphId } = await createParagraph();
const paragraphModel = doc.getBlock(paragraphId)?.model as
| ParagraphBlockModel
| undefined;
if (!paragraphModel) {
throw new Error('Cannot find paragraph model');
}
const linkedDoc = createDefaultDoc(collection, {
title: 'Linked page',
});
setTextSelection(paragraphId, 0, 0);
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: linkedDoc.id,
},
});
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: linkedDoc.id,
},
});
await wait();
expect(collection.docs.has(linkedDoc.id)).toBe(true);
const richText = getRichTextByBlockId(paragraphId);
expect(richText.querySelectorAll('affine-reference').length).toBe(2);
expect(richText.inlineEditor.yTextString.length).toBe(2);
});
});
describe('slash-menu action semantics', () => {
test('date and move actions mutate block content/order as expected', async () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const first = note.children[0] as ParagraphBlockModel;
const secondId = doc.addBlock(
'affine:paragraph',
{ text: new Text('second') },
noteId
);
const second = doc.getBlock(secondId)?.model as
| ParagraphBlockModel
| undefined;
if (!second) {
throw new Error('Cannot find second paragraph model');
}
doc.updateBlock(first, { text: new Text('first') });
await wait();
const slashItems = defaultSlashMenuConfig.items;
const items =
typeof slashItems === 'function'
? slashItems({ std: editor.std, model: first })
: slashItems;
const today = findSlashActionItem(items, 'Today');
const moveDown = findSlashActionItem(items, 'Move Down');
const moveUp = findSlashActionItem(items, 'Move Up');
moveDown.action({ std: editor.std, model: first });
await wait();
expect(note.children.map(child => child.id)).toEqual([second.id, first.id]);
moveUp.action({ std: editor.std, model: first });
await wait();
expect(note.children.map(child => child.id)).toEqual([first.id, second.id]);
setTextSelection(first.id, 0, 0);
today.action({ std: editor.std, model: first });
await wait();
const richText = getRichTextByBlockId(first.id);
expect(richText.inlineEditor.yTextString).toMatch(/\d{4}-\d{2}-\d{2}/);
});
});

View File

@@ -19,7 +19,11 @@ export default defineConfig(_configEnv =>
browser: {
enabled: true,
headless: process.env.CI === 'true',
instances: [{ browser: 'chromium' }],
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' },
],
provider: 'playwright',
isolate: false,
viewport: {

View File

@@ -1 +1 @@
# Please visit https://docs.affine.pro/docs/contributing
# Please visit https://docs.affine.pro/contributing

View File

@@ -1,10 +0,0 @@
# Contributor Add
- https://allcontributors.org/docs/en/cli/usage
- https://allcontributors.org/docs/en/emoji-key
```shell
all-contributors check
all-contributors add tzhangchi code,doc
all-contributors generate
```

View File

@@ -26,4 +26,4 @@ You may be able to find additional help and information on our social media plat
### :earth_asia: Translations
AFFiNE is internationalized and available in multiple languages. The source content in this repository is written in English. We integrate with an external localization platform to work with the community in localizing the English content. You can find more info on our community page, in our [i18n General Space ](https://community.affine.pro/c/i18n-general).
AFFiNE is internationalized and available in multiple languages. The source content in this repository is written in English. We integrate with an external localization platform to work with the community in localizing the English content. You can find more info on our community page, in our [Discord](https://affine.pro/redirect/discord).

View File

@@ -5,6 +5,7 @@ import eslint from '@eslint/js';
import tsParser from '@typescript-eslint/parser';
import eslintConfigPrettier from 'eslint-config-prettier';
import importX from 'eslint-plugin-import-x';
import oxlint from 'eslint-plugin-oxlint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
@@ -16,7 +17,10 @@ const __require = createRequire(import.meta.url);
const rxjs = __require('@smarttools/eslint-plugin-rxjs');
const ignoreList = readFileSync('.prettierignore', 'utf-8')
const ignoreList = readFileSync(
new URL('.prettierignore', import.meta.url),
'utf-8'
)
.split('\n')
.filter(line => line.trim() && !line.startsWith('#'));
@@ -60,105 +64,51 @@ export default tseslint.config(
'simple-import-sort': simpleImportSort,
rxjs,
unicorn,
oxlint,
},
rules: {
...eslint.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
...oxlint.configs.recommended.rules,
// covered by TypeScript
'no-dupe-args': 'off',
// the following rules are disabled because they are covered by oxlint
'array-callback-return': 'off',
'constructor-super': 'off',
eqeqeq: 'off',
'getter-return': 'off',
'for-direction': 'off',
'require-yield': 'off',
'use-isnan': 'off',
'valid-typeof': 'off',
'no-self-compare': 'off',
'no-empty': 'off',
'no-constant-binary-expression': 'off',
'no-constructor-return': 'off',
'no-func-assign': 'off',
'no-global-assign': 'off',
'no-ex-assign': 'off',
'no-fallthrough': 'off',
'no-irregular-whitespace': 'off',
'no-control-regex': 'off',
'no-with': 'off',
'no-debugger': 'off',
'no-const-assign': 'off',
'no-import-assign': 'off',
'no-setter-return': 'off',
'no-obj-calls': 'off',
'no-unsafe-negation': 'off',
'no-dupe-class-members': 'off',
'no-dupe-keys': 'off',
'no-this-before-super': 'off',
'no-empty-character-class': 'off',
'no-useless-catch': 'off',
'no-async-promise-executor': 'off',
'no-unreachable': 'off',
'no-duplicate-case': 'off',
'no-empty-pattern': 'off',
'no-unused-labels': 'off',
'no-sparse-arrays': 'off',
'no-delete-var': 'off',
'no-compare-neg-zero': 'off',
'no-redeclare': 'off',
'no-case-declarations': 'off',
'no-class-assign': 'off',
'no-var': 'off',
'no-self-assign': 'off',
'no-inner-declarations': 'off',
'no-dupe-else-if': 'off',
'no-invalid-regexp': 'off',
'no-unsafe-finally': 'off',
'no-prototype-builtins': 'off',
'no-shadow-restricted-names': 'off',
'no-nonoctal-decimal-escape': 'off',
'no-constant-condition': 'off',
'no-useless-escape': 'off',
'no-unsafe-optional-chaining': 'off',
'no-extra-boolean-cast': 'off',
'no-regex-spaces': 'off',
'no-unused-vars': 'off',
'no-undef': 'off',
'no-cond-assign': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/no-unknown-property': 'off',
'react/no-string-refs': 'off',
'react/no-direct-mutation-state': 'off',
'react/require-render-return': 'off',
'react/jsx-no-undef': 'off',
'react/jsx-no-duplicate-props': 'off',
'react/jsx-key': 'off',
'react/no-danger-with-children': 'off',
'react/no-unescaped-entities': 'off',
'react/no-is-mounted': 'off',
'react/no-find-dom-node': 'off',
'react/no-children-prop': 'off',
'react/no-render-return-value': 'off',
'react/jsx-no-target-blank': 'off',
'react/jsx-no-comment-textnodes': 'off',
'react/prop-types': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/use-memo': 'off',
'sonarjs/no-useless-catch': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-loss-of-precision': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/triple-slash-reference': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off',
'@typescript-eslint/no-extra-non-null-assertion': 'off',
'@typescript-eslint/no-misused-new': 'off',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/no-unsafe-declaration-merging': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
@@ -167,30 +117,13 @@ export default tseslint.config(
'@typescript-eslint/no-empty-function': 'off',
// rules that are not supported by oxlint
'no-unreachable-loop': 'error',
'@typescript-eslint/no-unsafe-function-type': 'error',
'@typescript-eslint/no-wrapper-object-types': 'error',
'@typescript-eslint/unified-signatures': 'error',
'@typescript-eslint/return-await': [
'error',
'error-handling-correctness-only',
],
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/dist'],
message: "Don't import from dist",
allowTypeImports: false,
},
{
group: ['**/src'],
message: "Don't import from src",
allowTypeImports: false,
},
],
},
],
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-empty-collection': 'error',
@@ -198,7 +131,6 @@ export default tseslint.config(
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-ignored-return': 'error',
'sonarjs/no-one-iteration-loop': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/non-existent-operator': 'error',
'sonarjs/no-collapsible-if': 'error',
@@ -234,13 +166,6 @@ export default tseslint.config(
'error',
{ includeInternal: true },
],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks:
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)',
},
],
'rxjs/finnish': [
'error',
{
@@ -304,7 +229,6 @@ export default tseslint.config(
{ ignoreVoid: true },
],
'@typescript-eslint/no-misused-promises': 0,
'@typescript-eslint/no-restricted-imports': 0,
},
},
{

View File

@@ -26,9 +26,10 @@
"lint:eslint:fix": "yarn lint:eslint --fix --fix-type problem,suggestion,layout",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint -c oxlint.json --deny-warnings",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"lint:ox": "oxlint --deny-warnings",
"lint:ox:fix": "yarn lint:ox --fix",
"lint": "yarn lint:ox && yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:ox:fix && yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
@@ -51,7 +52,7 @@
"devDependencies": {
"@affine-tools/cli": "workspace:*",
"@capacitor/cli": "^7.0.0",
"@eslint/js": "^9.16.0",
"@eslint/js": "^9.39.2",
"@faker-js/faker": "^10.1.0",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.6.1",
@@ -61,32 +62,33 @@
"@toeverything/infra": "workspace:*",
"@types/eslint": "^9.6.1",
"@types/node": "^22.0.0",
"@typescript-eslint/parser": "^8.18.0",
"@typescript-eslint/parser": "^8.55.0",
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-istanbul": "^3.2.4",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"electron": "^39.0.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import-x": "^4.5.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-oxlint": "^1.46.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^59.0.0",
"eslint-plugin-sonarjs": "^3.0.7",
"eslint-plugin-unicorn": "^63.0.0",
"happy-dom": "^20.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.0.0",
"msw": "^2.12.4",
"oxlint": "~1.18.0",
"oxlint": "^1.47.0",
"prettier": "^3.7.4",
"semver": "^7.7.3",
"serve": "^14.2.4",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0",
"typescript-eslint": "^8.55.0",
"unplugin-swc": "^1.5.9",
"vite": "^7.2.7",
"vitest": "^3.2.4"

View File

@@ -31,7 +31,7 @@ assert.strictEqual(
bench
.add('tiktoken', () => {
const encoder = encoding_for_model('gpt-4o');
encoder.encode_ordinary(FIXTURE).length;
void encoder.encode_ordinary(FIXTURE).length;
})
.add('native', () => {
fromModelName('gpt-4o').count(FIXTURE);

View File

@@ -39,18 +39,18 @@
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google-cloud/opentelemetry-resource-util": "^3.0.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@nestjs-cls/transactional": "^2.6.1",
"@nestjs-cls/transactional-adapter-prisma": "^1.2.19",
"@nestjs-cls/transactional": "^2.7.0",
"@nestjs-cls/transactional-adapter-prisma": "^1.2.24",
"@nestjs/apollo": "^13.0.4",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.12",
"@nestjs/core": "^11.0.12",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.21",
"@nestjs/core": "^11.1.14",
"@nestjs/graphql": "^13.0.4",
"@nestjs/platform-express": "^11.0.12",
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/websockets": "^11.0.12",
"@nestjs/platform-express": "^11.1.14",
"@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.14",
"@node-rs/argon2": "^2.0.2",
"@node-rs/crc32": "^1.10.6",
"@opentelemetry/api": "^1.9.0",
@@ -71,7 +71,7 @@
"@opentelemetry/semantic-conventions": "^1.38.0",
"@prisma/client": "^6.6.0",
"@prisma/instrumentation": "^6.7.0",
"@queuedash/api": "^3.14.0",
"@queuedash/api": "^3.16.0",
"@react-email/components": "0.0.38",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.118",
@@ -81,7 +81,7 @@
"date-fns": "^4.0.0",
"dotenv": "^16.4.7",
"eventemitter2": "^6.4.9",
"exa-js": "^1.6.13",
"exa-js": "^2.4.0",
"express": "^5.0.1",
"fast-xml-parser": "^5.3.4",
"get-stream": "^9.0.1",

View File

@@ -97,7 +97,7 @@ test('should always return static asset files', async t => {
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/main.b.js')
.get('/admin/main.b.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
@@ -119,7 +119,7 @@ test('should always return static asset files', async t => {
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/main.b.js')
.get('/admin/main.b.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");

View File

@@ -43,7 +43,6 @@ class MockR2Provider extends R2StorageProvider {
destroy() {}
// @ts-ignore expect override
override async proxyPutObject(
key: string,
body: any,

View File

@@ -1,6 +1,7 @@
import { LookupAddress } from 'node:dns';
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import { LookupAddress } from 'dns';
import Sinon from 'sinon';
import type { Response } from 'supertest';
@@ -14,7 +15,6 @@ import { createTestingApp, TestingApp } from './utils';
type TestContext = {
app: TestingApp;
};
const test = ava as TestFn<TestContext>;
const LookupAddressStub = (async (_hostname, options) => {

View File

@@ -0,0 +1,99 @@
import { URLHelper } from './helpers';
const DEV_LOOPBACK_PROTOCOLS = new Set(['http:', 'https:']);
const DEV_LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const MOBILE_CLIENT_ORIGINS = new Set([
'https://localhost',
'capacitor://localhost',
'ionic://localhost',
]);
const DESKTOP_CLIENT_ORIGINS = new Set(['assets://.', 'assets://another-host']);
export const CORS_ALLOWED_METHODS = [
'GET',
'HEAD',
'PUT',
'PATCH',
'POST',
'DELETE',
'OPTIONS',
];
export const CORS_ALLOWED_HEADERS = [
'accept',
'authorization',
'content-type',
'x-affine-version',
'x-operation-name',
'x-request-id',
'x-captcha-token',
'x-captcha-challenge',
'x-affine-csrf-token',
'x-requested-with',
'range',
];
export const CORS_EXPOSED_HEADERS = [
'content-length',
'content-range',
'x-request-id',
];
function normalizeHostname(hostname: string) {
return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
}
function isDevLoopbackOrigin(origin: string) {
try {
const parsed = new URL(origin);
return (
DEV_LOOPBACK_PROTOCOLS.has(parsed.protocol) &&
DEV_LOOPBACK_HOSTS.has(normalizeHostname(parsed.hostname))
);
} catch {
return false;
}
}
export function buildCorsAllowedOrigins(url: URLHelper) {
return new Set<string>([
...url.allowedOrigins,
...MOBILE_CLIENT_ORIGINS,
...DESKTOP_CLIENT_ORIGINS,
]);
}
export function isCorsOriginAllowed(
origin: string | undefined | null,
allowedOrigins: Set<string>
) {
if (!origin) {
return true;
}
if (allowedOrigins.has(origin)) {
return true;
}
if ((env.dev || env.testing) && isDevLoopbackOrigin(origin)) {
return true;
}
return false;
}
export function corsOriginCallback(
origin: string | undefined,
allowedOrigins: Set<string>,
onBlocked: (origin: string) => void,
callback: (error: Error | null, allow?: boolean) => void
) {
if (isCorsOriginAllowed(origin, allowedOrigins)) {
callback(null, true);
return;
}
const blockedOrigin = origin ?? '<empty>';
onBlocked(blockedOrigin);
callback(null, false);
}

View File

@@ -51,10 +51,10 @@ function parseKey(privateKey: string) {
let priv: KeyObject;
try {
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'pkcs8' });
} catch (e1) {
} catch {
try {
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'sec1' });
} catch (e2) {
} catch {
// As a last resort rely on auto-detection
priv = createPrivateKey(keyBuf);
}

View File

@@ -11,6 +11,7 @@ export {
defineModuleConfig,
type JSONSchema,
} from './config';
export * from './cors';
export * from './error';
export { EventBus, OnEvent } from './event';
export {

Some files were not shown because too many files have changed in this diff Show More