Compare commits

...

86 Commits

Author SHA1 Message Date
renovate[bot] 3bf06722b7 chore: bump up android.gradle.plugin to v9 2026-05-22 17:52:50 +00:00
steffenrapp 925c95ce88 feat(i18n): update German translation (#15011)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
  * German language completeness raised to 100%.
* Added German translations for Markdown export/copy labels and success
text, import formats (including Bear backup and Word .docx), editor
settings (auto-date-title formats, add-icon option), AI BYOK
workspace/provider-key UI and notifications, and a recording/importing
UI prompt.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15011?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-22 01:33:38 +08:00
DarkSky 3098b3b14b feat(server): bump models (#15013)
#### PR Dependency Tree


* **PR #15013** 👈

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**
* Expanded AI capabilities with the addition of Gemini 3.5 Flash model,
providing enhanced options for AI-powered features.

* **Updates**
* Updated Claude Sonnet to the latest version for improved performance.
  * Refreshed pro models configuration with optimized selections.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15013?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 15:29:00 +08:00
DarkSky dd1cd77ca0 chore(server): improve migration compatibility (#15014)
#### PR Dependency Tree


* **PR #15014** 👈

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**
* Remove orphaned legacy subscription and entitlement records during
backfill.
* Repair workspaces missing active owners by promoting eligible members
and cleaning up empty workspaces.
* Skip cloud subscription backfill when target user/workspace no longer
exists to avoid dangling data.

* **Tests**
  * Added tests verifying legacy data cleanup during backfill.
* Added tests verifying workspace ownership repair and migration
behavior.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15014?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-21 15:28:51 +08:00
Waqar Bin zafar d20dbfd6a2 feat(editor): add page emoji display toggle #14987 (#14999)
This PR adds a display toggle for Page Emoji, so users can choose
whether the add emoji option is shown in the page header when no emoji
is set.

What changed
read editor setting for display add icon option
hide emoji placeholder entry when the setting is disabled
keep existing behavior for readonly mode and for pages that already have
an emoji
Why
This implements the feature request to control Page Emoji visibility and
improves header cleanliness for users who prefer a minimal UI.

Issue
Closes #14093
<img width="1277" height="726" alt="Screenshot 2026-05-19 at 3 44 14 PM"
src="https://github.com/user-attachments/assets/caa29272-35c0-410d-bd54-2e038e4e0db2"
/>
<img width="1511" height="779" alt="Screenshot 2026-05-19 at 3 44 35 PM"
src="https://github.com/user-attachments/assets/3504136a-d34c-45cc-992b-0056b018ff92"
/>

Testing
verified in editable mode:
setting ON: add emoji placeholder is visible when page has no emoji
setting OFF: add emoji placeholder is hidden when page has no emoji
verified in readonly mode:
no emoji: nothing shown
with emoji: existing emoji is shown
verified no regression for selecting/changing/removing emoji
Screenshots
I will attach screenshots in this section.

Quick rule checks before submit

Base branch is canary.
PR title follows conventional format: type(scope): subject.
Scope editor is valid for this repo.
Include Closes #14093 in the body.
Add your screenshots before creating or right after opening the PR.

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

* **New Features**
* Added an editor setting to toggle whether the "add icon" option is
shown when creating new documents (default: enabled).
* **User Experience**
* When disabled, the add-icon trigger is hidden for documents that use a
placeholder icon; readonly display remains unchanged.
* **Tests**
  * Updated tests to cover the new setting and toggle behavior.
* **Localization**
* Added translations and updated i18n typings and completeness metrics.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14999?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 22:20:12 +08:00
renovate[bot] 41145961f9 chore: bump up RevenueCat/purchases-ios-spm version to from: "5.73.0" (#15008)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.66.0"` → `from: "5.73.0"` |

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.73.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5730)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.72.0...5.73.0)

#### 5.73.0

###
[`v5.72.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5720)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.71.0...5.72.0)

#### 5.72.0

###
[`v5.71.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5710)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.70.0...5.71.0)

#### 5.71.0

###
[`v5.70.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5700)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.69.0...5.70.0)

#### 5.70.0

###
[`v5.69.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5690)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.68.0...5.69.0)

#### 5.69.0

###
[`v5.68.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5680)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.2...5.68.0)

#### 5.68.0

###
[`v5.67.2`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5672)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.1...5.67.2)

#### 5.67.2

###
[`v5.67.1`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5671)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.67.0...5.67.1)

#### 5.67.1

###
[`v5.67.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5670)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.66.0...5.67.0)

#### 5.67.0

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 18:41:32 +08:00
DarkSky 1f2119e273 fix: migration timeout 2026-05-20 18:39:08 +08:00
renovate[bot] 6e97aff7ba chore: bump up oxlint-tsgolint version to ^0.23.0 (#15007)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint-tsgolint](https://redirect.github.com/oxc-project/tsgolint) |
[`^0.19.0` →
`^0.23.0`](https://renovatebot.com/diffs/npm/oxlint-tsgolint/0.19.0/0.23.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint-tsgolint/0.23.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint-tsgolint/0.19.0/0.23.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/tsgolint (oxlint-tsgolint)</summary>

###
[`v0.23.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.23.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0)

#### What's Changed

- chore(deps): update crate-ci/typos action to v1.45.2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;915](https://redirect.github.com/oxc-project/tsgolint/pull/915)
- feat: add skill for upgrading typescript-go by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;918](https://redirect.github.com/oxc-project/tsgolint/pull/918)
- chore(deps): update pnpm to v10.33.2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;921](https://redirect.github.com/oxc-project/tsgolint/pull/921)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;922](https://redirect.github.com/oxc-project/tsgolint/pull/922)
- fix: attach tsconfig path to diagnostics by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;923](https://redirect.github.com/oxc-project/tsgolint/pull/923)
- fix(prefer-nullish-coalescing): parenthesize mixed logical fixes by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;924](https://redirect.github.com/oxc-project/tsgolint/pull/924)
- tests(return-await): cover non-async arrow functions by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;926](https://redirect.github.com/oxc-project/tsgolint/pull/926)
- chore(deps): update github.com/go-json-experiment/json digest to
[`b6187a3`](https://redirect.github.com/oxc-project/tsgolint/commit/b6187a3)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;927](https://redirect.github.com/oxc-project/tsgolint/pull/927)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;928](https://redirect.github.com/oxc-project/tsgolint/pull/928)
- chore(deps): update crate-ci/typos action to v1.46.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;929](https://redirect.github.com/oxc-project/tsgolint/pull/929)
- chore(deps): update module github.com/dlclark/regexp2 to v2 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;930](https://redirect.github.com/oxc-project/tsgolint/pull/930)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;931](https://redirect.github.com/oxc-project/tsgolint/pull/931)
- chore(deps): update typescript-go digest to
[`48e2953`](https://redirect.github.com/oxc-project/tsgolint/commit/48e2953)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;933](https://redirect.github.com/oxc-project/tsgolint/pull/933)
- chore(deps): update typescript-go digest to
[`5eb880f`](https://redirect.github.com/oxc-project/tsgolint/commit/5eb880f)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;936](https://redirect.github.com/oxc-project/tsgolint/pull/936)
- fix(no-misused-promises): handle empty JSX attributes by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;938](https://redirect.github.com/oxc-project/tsgolint/pull/938)
- fix(no-unsafe-enum-comparison): flag string literal unions by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;937](https://redirect.github.com/oxc-project/tsgolint/pull/937)
- chore(deps): update typescript-go digest to
[`e1f8f97`](https://redirect.github.com/oxc-project/tsgolint/commit/e1f8f97)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;939](https://redirect.github.com/oxc-project/tsgolint/pull/939)
- chore(deps): update typescript-go digest to
[`092b34f`](https://redirect.github.com/oxc-project/tsgolint/commit/092b34f)
by [@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;940](https://redirect.github.com/oxc-project/tsgolint/pull/940)
- chore: configure typescript-go renovate schedule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;941](https://redirect.github.com/oxc-project/tsgolint/pull/941)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;945](https://redirect.github.com/oxc-project/tsgolint/pull/945)
- chore(deps): update dependency dprint-typescript to v0.96.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;947](https://redirect.github.com/oxc-project/tsgolint/pull/947)
- chore(deps): update gomod by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;946](https://redirect.github.com/oxc-project/tsgolint/pull/946)
- chore(deps): update crate-ci/typos action to v1.46.1 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;948](https://redirect.github.com/oxc-project/tsgolint/pull/948)
- fix(prefer-nullish-coalescing): emit suggestion over fix by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;951](https://redirect.github.com/oxc-project/tsgolint/pull/951)
- chore: update packageManager to pnpm 11.0.4 by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;953](https://redirect.github.com/oxc-project/tsgolint/pull/953)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;955](https://redirect.github.com/oxc-project/tsgolint/pull/955)
- fix(no-nullable-type-assertion-style): use suggestion instead of fix
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;956](https://redirect.github.com/oxc-project/tsgolint/pull/956)
- docs: Update Go version requirement to 1.26 in CONTRIBUTING.md. by
[@&#8203;connorshea](https://redirect.github.com/connorshea) in
[#&#8203;957](https://redirect.github.com/oxc-project/tsgolint/pull/957)
- fix: allow safe promise intersection members by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;959](https://redirect.github.com/oxc-project/tsgolint/pull/959)
- ci: switch security workflow to ubuntu-latest by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;962](https://redirect.github.com/oxc-project/tsgolint/pull/962)
- chore(deps): update dependency vitest to v4.1.6 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;963](https://redirect.github.com/oxc-project/tsgolint/pull/963)
- chore(deps): update module github.com/dlclark/regexp2/v2 to v2.0.3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;964](https://redirect.github.com/oxc-project/tsgolint/pull/964)
- chore(deps): update dependency dprint-markdown to v0.22.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;965](https://redirect.github.com/oxc-project/tsgolint/pull/965)
- chore(deps): update github actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;966](https://redirect.github.com/oxc-project/tsgolint/pull/966)
- perf(no-unnecessary-type-parameters): stop counting settled candidates
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;967](https://redirect.github.com/oxc-project/tsgolint/pull/967)
- chore: add `dprint` to pnpm `allowBuilds` by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;968](https://redirect.github.com/oxc-project/tsgolint/pull/968)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0>

###
[`v0.22.1`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.22.1)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1)

#### What's Changed

- fix: clarify `AGENTS.md` submodule guidance by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;909](https://redirect.github.com/oxc-project/tsgolint/pull/909)
- feat(no-unsafe-enum-comparison): implement suggestion by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;910](https://redirect.github.com/oxc-project/tsgolint/pull/910)
- feat(no-unnecessary-template-expression): implement fix by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;911](https://redirect.github.com/oxc-project/tsgolint/pull/911)
- chore(deps): update dependency vitest to v4.1.5 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;912](https://redirect.github.com/oxc-project/tsgolint/pull/912)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;913](https://redirect.github.com/oxc-project/tsgolint/pull/913)
- fix(prefer-optional-chain): avoid access comparison false positive by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;914](https://redirect.github.com/oxc-project/tsgolint/pull/914)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.22.0...v0.22.1>

###
[`v0.22.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.22.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0)

#### What's Changed

- chore: convert renovate config to json by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;893](https://redirect.github.com/oxc-project/tsgolint/pull/893)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;895](https://redirect.github.com/oxc-project/tsgolint/pull/895)
- ci: replace OXC\_BOT\_PAT with GitHub App tokens by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;894](https://redirect.github.com/oxc-project/tsgolint/pull/894)
- ci: add security analysis workflow by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;898](https://redirect.github.com/oxc-project/tsgolint/pull/898)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;899](https://redirect.github.com/oxc-project/tsgolint/pull/899)
- chore(deps): update module github.com/dlclark/regexp2 to v1.12.0 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;900](https://redirect.github.com/oxc-project/tsgolint/pull/900)
- chore(deps): update dependency typescript to v6.0.3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;901](https://redirect.github.com/oxc-project/tsgolint/pull/901)
- ci: make security analysis required-check friendly by
[@&#8203;Boshen](https://redirect.github.com/Boshen) in
[#&#8203;902](https://redirect.github.com/oxc-project/tsgolint/pull/902)
- feat(require-await): implement suggestions by
[@&#8203;younggglcy](https://redirect.github.com/younggglcy) in
[#&#8203;896](https://redirect.github.com/oxc-project/tsgolint/pull/896)
- fix: add warning for unsupported tsgolint CLI entrypoint by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;903](https://redirect.github.com/oxc-project/tsgolint/pull/903)
- fix: resolve ancestor tsconfig for excluded nearest config by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;904](https://redirect.github.com/oxc-project/tsgolint/pull/904)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;905](https://redirect.github.com/oxc-project/tsgolint/pull/905)
- fix: handle UTF-16 diagnostics by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;906](https://redirect.github.com/oxc-project/tsgolint/pull/906)
- fix(no-useless-default-assignment): make default assignment removal a
suggestion by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;907](https://redirect.github.com/oxc-project/tsgolint/pull/907)
- fix(no-unnecessary-type-arguments): preserve shadowed type arguments
by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;908](https://redirect.github.com/oxc-project/tsgolint/pull/908)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.21.1...v0.22.0>

###
[`v0.21.1`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.21.1)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1)

##### What's Changed

- fix(no-unnecessary-condition): handle null overlap in narrowed generic
intersections by [@&#8203;camc314](https://redirect.github.com/camc314)
in
[#&#8203;891](https://redirect.github.com/oxc-project/tsgolint/pull/891)
- revert(no-unnecessary-type-arguments): drop inference reporting by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;892](https://redirect.github.com/oxc-project/tsgolint/pull/892)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.21.0...v0.21.1>

###
[`v0.21.0`](https://redirect.github.com/oxc-project/tsgolint/releases/tag/v0.21.0)

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0)

##### What's Changed

- chore: migrate gen-json-schemas to TS by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;874](https://redirect.github.com/oxc-project/tsgolint/pull/874)
- chore: update typescript-go submodule by
[@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;879](https://redirect.github.com/oxc-project/tsgolint/pull/879)
- chore(deps): update github-actions by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;883](https://redirect.github.com/oxc-project/tsgolint/pull/883)
- chore(deps): update gomod by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;884](https://redirect.github.com/oxc-project/tsgolint/pull/884)
- chore(deps): update npm packages by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;885](https://redirect.github.com/oxc-project/tsgolint/pull/885)
- feat: improve `consistent-type-exports` diagnostics quality by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;880](https://redirect.github.com/oxc-project/tsgolint/pull/880)
- chore(deps): update softprops/action-gh-release action to v3 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;886](https://redirect.github.com/oxc-project/tsgolint/pull/886)
- feat: enrich the `no-array-delete` diagnostic by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;881](https://redirect.github.com/oxc-project/tsgolint/pull/881)
- feat: enrich `no-duplicate-type-constituents` diagnostic by
[@&#8203;camchenry](https://redirect.github.com/camchenry) in
[#&#8203;882](https://redirect.github.com/oxc-project/tsgolint/pull/882)
- fix(no-meaningless-void-operator): align with typescript-eslint union
handling by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;887](https://redirect.github.com/oxc-project/tsgolint/pull/887)
- chore(deps): update crate-ci/typos action to v1.45.1 by
[@&#8203;renovate](https://redirect.github.com/renovate)\[bot] in
[#&#8203;888](https://redirect.github.com/oxc-project/tsgolint/pull/888)
- fix(no-deprecated): avoid false positive on array destructuring
bindings by [@&#8203;camc314](https://redirect.github.com/camc314) in
[#&#8203;890](https://redirect.github.com/oxc-project/tsgolint/pull/890)

**Full Changelog**:
<https://github.com/oxc-project/tsgolint/compare/v0.20.0...v0.21.0>

### [`v0.20.0`]()

[Compare
Source](https://redirect.github.com/oxc-project/tsgolint/compare/v0.19.0...v0.20.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 13:17:46 +08:00
renovate[bot] 276b0db625 chore: bump up eslint-plugin-oxlint version to v1.66.0 (#15006)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.64.0` →
`1.66.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.64.0/1.66.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.66.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.64.0/1.66.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.66.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.66.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.65.0...v1.66.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.65.0...v1.66.0)

###
[`v1.65.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.65.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.64.0...v1.65.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.64.0...v1.65.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 10:26:38 +08:00
renovate[bot] bac346f304 chore: bump up nestjs to v13.4.1 (#15002) 2026-05-20 05:51:24 +08:00
DarkSky 9f33d37add feat(core): integrate realtime features (#15003) 2026-05-20 05:48:03 +08:00
renovate[bot] 3e42bbf4fa chore: bump up apple/swift-collections version to from: "1.5.1" (#15001)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[apple/swift-collections](https://redirect.github.com/apple/swift-collections)
| patch | `from: "1.5.0"` → `from: "1.5.1"` |

---

### Release Notes

<details>
<summary>apple/swift-collections (apple/swift-collections)</summary>

###
[`v1.5.1`](https://redirect.github.com/apple/swift-collections/releases/tag/1.5.1):
Swift Collections 1.5.1

[Compare
Source](https://redirect.github.com/apple/swift-collections/compare/1.5.0...1.5.1)

This is a patch release resolving three issues uncovered since 1.5.0 was
tagged, including a source breaking regression introduced in 1.4.0,
affecting clients importing the `Collections` module.

#### What's Changed

- Import error from `HashTreeCollections`, reported by
[@&#8203;vanvoorden](https://redirect.github.com/vanvoorden) in
[#&#8203;653](https://redirect.github.com/apple/swift-collections/issues/653)
- Resolve source break in the Collections module by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;654](https://redirect.github.com/apple/swift-collections/pull/654)
- Linker error around RigidArray when using in Embedded Swift for
WebAssembly, reported by
[@&#8203;sliemeobn](https://redirect.github.com/sliemeobn) in
[#&#8203;648](https://redirect.github.com/apple/swift-collections/issues/648)
- \[BasicContainers] Don’t define LLDB formatter symbol on Wasm by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;650](https://redirect.github.com/apple/swift-collections/pull/650)
- Guard `UniqueBox.borrow` correctly by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;649](https://redirect.github.com/apple/swift-collections/pull/649)

**Full Changelog**:
<https://github.com/apple/swift-collections/compare/1.5.0...1.5.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xODUuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE4NS4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 23:58:03 +08:00
renovate[bot] b5e5f0708a chore: bump up Lakr233/MarkdownView version to from: "3.9.1" (#14861)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[Lakr233/MarkdownView](https://redirect.github.com/Lakr233/MarkdownView)
| minor | `from: "3.8.2"` → `from: "3.9.1"` |

---

### Release Notes

<details>
<summary>Lakr233/MarkdownView (Lakr233/MarkdownView)</summary>

###
[`v3.9.1`](https://redirect.github.com/Lakr233/MarkdownView/compare/3.9.0...3.9.1)

[Compare
Source](https://redirect.github.com/Lakr233/MarkdownView/compare/3.9.0...3.9.1)

###
[`v3.9.0`](https://redirect.github.com/Lakr233/MarkdownView/compare/3.8.2...3.9.0)

[Compare
Source](https://redirect.github.com/Lakr233/MarkdownView/compare/3.8.2...3.9.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuOCIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 22:52:20 +08:00
Abdul Rehman f96bf3dd24 feat(i18n): expand Urdu translation (#14995)
Closes #14994
🇵🇰 Urdu Translation for Pakistani Users
## Summary

`ur.json` previously had only 31 of 2404 keys translated (~1%), leaving
most of the AFFiNE UI in English for Urdu-speaking users. This PR fills
in the remaining ~2400 keys so Pakistani / Urdu users get a fully
localized experience.

- `packages/frontend/i18n/src/resources/ur.json` — expanded from 31 →
2404 keys
- `packages/frontend/i18n/src/i18n-completenesses.json` — `ur: 2` → `ur:
100`

Existing hand-translated keys were preserved.

## Screenshots

<img width="1600" height="716" alt="image"
src="https://github.com/user-attachments/assets/1e3395b9-7cb0-44ba-a29f-a484419eb9fd"
/>
--------

<img width="1600" height="716" alt="image"
src="https://github.com/user-attachments/assets/f03cb1ac-dde8-4425-a898-c56acebe45b6"
/>



## Test plan

- [x] Switch app language to Urdu (اردو) in Settings → Appearance →
Language
- [x] Sidebar, top bar, calendar, doc list, settings panels all render
in Urdu
- [x] RTL layout flows correctly
- [x] Prettier + lint clean

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

* **Localization**
* Urdu language support is now fully available across the application,
including translated UI text and locale-specific content.
* Users can select Urdu as their preferred language and experience
consistent translations and messaging throughout the product.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14995?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-19 22:49:22 +08:00
DarkSky c53457691d feat(server): entitlement based model (#14996)
#### PR Dependency Tree


* **PR #14996** 👈

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**
  * Admin mutations to grant/revoke commercial entitlements.
  * New Doc comment-update permission.
  * Realtime user/workspace quota-state endpoints and live-update rooms.

* **Bug Fixes**
  * More accurate readable-doc filtering and permission evaluation.

* **Refactor**
* Workspace feature management moved to entitlement-based model;
permission and quota pipelines redesigned.
  * Admin workspace UI now edits flags only (feature toggles removed).

* **Tests**
* Extensive new and updated tests for permissions, entitlements, quota,
projection, and backfills.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-19 22:48:05 +08:00
Azamat Jauysh 103ad2a810 feat(i18n): add Kazakh translation (#14981)
Closes #14975

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

* **New Features**
* Kazakh (kk) language has been added — users can now choose Kazakh for
the interface, with complete localization coverage and language metadata
(name and flag) included.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14981?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-18 16:45:36 +08:00
DarkSky ef4939009f feat(editor): handle calendar view overflow in edgeless mode (#14992)
#### PR Dependency Tree


* **PR #14992** 👈

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**
* Calendar view now supports horizontal scrolling for better navigation.

* **Bug Fixes**
* Improved mouse wheel interaction handling to prevent unintended
scrolling.

* **Style**
* Calendar layout is now more responsive and adapts better to different
screen sizes.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14992?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-18 09:32:02 +08:00
DarkSky 0f5778ac89 feat(editor): calendar view for database block (#14984)
fix #13663


#### PR Dependency Tree


* **PR #14984** 👈

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**
* Calendar view for database blocks (month layout, entry cards,
external-source support)
  * Workspace calendar integration and new slash-menu "Calendar View"

* **Improvements**
* Create/manage database rows from calendar UI; preserve durations when
moving/resizing ranges
* Drag-and-drop, drop-preview, and hit-testing support for calendar and
docs
  * Redesigned in-menu View settings with multi-page navigation
  * Context-menu input autofocus toggle and conditional back-navigation

* **Tests**
* New unit and E2E suites covering calendar layout, interactions,
sources, and slash-menu integration
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-17 20:40:36 +08:00
DarkSky e9ef3c50c8 fix(editor): transcript note will create useless docs (#14976)
fix #13520


#### PR Dependency Tree


* **PR #14976** 👈

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

* **Tests**
* Added comprehensive test coverage for markdown insertion functionality
to verify that existing document metadata remains unchanged when
importing markdown content into workspace documents.

* **Chores**
* Optimized internal markdown-to-snapshot conversion process to use a
more direct and efficient conversion approach.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14976)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 20:18:22 +08:00
renovate[bot] 661d5d3831 chore: bump up eslint-plugin-oxlint version to v1.64.0 (#14972)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.60.0` →
`1.64.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.60.0/1.64.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.64.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.60.0/1.64.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.64.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.64.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.63.0...v1.64.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.63.0...v1.64.0)

###
[`v1.63.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.63.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.62.0...v1.63.0)

#####    🐞 Bug Fixes

- Ignore
[@&#8203;typescript-eslint/consistent-type-imports](https://redirect.github.com/typescript-eslint/consistent-type-imports)
for vue, astro, and svelte files  -  by
[@&#8203;Sysix](https://redirect.github.com/Sysix) in
[#&#8203;710](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/issues/710)
[<samp>(e9eb2)</samp>](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/commit/e9eb236)

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.62.0...v1.63.0)

###
[`v1.62.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.62.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.61.0...v1.62.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.61.0...v1.62.0)

###
[`v1.61.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.61.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.60.0...v1.61.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.60.0...v1.61.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzkuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE3OS4zIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 03:15:18 +08:00
DarkSky 6f55548661 fix(editor): improve tests stability (#14971)
#### PR Dependency Tree


* **PR #14971** 👈

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

* **Tests**
  * Improved shape selection reliability in edge case testing scenarios
* Enhanced rich text editor focusing logic with better synchronization
for inline editor state validation

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14971)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 03:14:52 +08:00
renovate[bot] c39fa1ff2d chore: bump up apple/swift-collections version to from: "1.5.0" (#14969)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[apple/swift-collections](https://redirect.github.com/apple/swift-collections)
| minor | `from: "1.4.1"` → `from: "1.5.0"` |

---

### Release Notes

<details>
<summary>apple/swift-collections (apple/swift-collections)</summary>

###
[`v1.5.0`](https://redirect.github.com/apple/swift-collections/releases/tag/1.5.0):
Swift Collections 1.5.0

[Compare
Source](https://redirect.github.com/apple/swift-collections/compare/1.4.1...1.5.0)

This feature release supports Swift toolchain versions 6.0, 6.1, 6.2,
and 6.3. It includes the following new features and bug fixes:

##### Debugging enhancements

The package now defines LLDB data formatters for `RigidArray`. The
formatters are emitted into the executable binary, and they are
automatically loaded by LLDB. We expect to implement formatters for
(many) more types in subsequent releases.

##### New stable APIs

- `RigidArray` and `UniqueArray` now conform to `Equatable` when their
element type is `Equatable`. This conformance requires a Swift 6.4 or
later toolchain (it relies on [SE-0499][SE-0499] generalizations of
`Equatable`/`Hashable` to support noncopyable conforming types).
- `RigidArray` and `UniqueArray` gained an `isTriviallyIdentical(to:)`
operation, which reports whether two instances share their underlying
storage allocation. This does not require the element type to be
`Equatable`, and it works with noncopyable elements.
- [`BitSet`][BitSet] gained a `makeIterator(from:)` shortcut for
starting iteration at (or after) a specific member, avoiding a linear
scan from the start of the set.
- [`OrderedDictionary`][OrderedDictionary] gained a
`replaceElement(at:withKey:value:)` operation that replaces the
key-value pair at a given index. The new key is allowed to equal the
existing key at that index (in which case only the value is updated).

[BitSet]:
https://swiftpackageindex.com/apple/swift-collections/documentation/bitcollections/bitset

[OrderedDictionary]:
https://swiftpackageindex.com/apple/swift-collections/documentation/orderedcollections/ordereddictionary

[SE-0499]:
https://redirect.github.com/swiftlang/swift-evolution/blob/main/proposals/0499-equatable-hashable-comparable-noncopyable.md

##### Experimental hashed containers (`UnstableHashedContainers` trait)

The Robin-Hood-hashed `UniqueSet`, `RigidSet`, `UniqueDictionary`, and
`RigidDictionary` types in the `BasicContainers` module continue to
evolve behind the `UnstableHashedContainers` package trait. This release
brings a number of correctness fixes and performance improvements:

- Faster removals, with better `maxProbeLength` maintenance to avoid
probe-length bloat.
- Small tables are now scrambled to avoid degenerate patterns on common
key distributions.
- A fast-path shortcut for insertions into under-utilized tables.
- Fixes to the insertion algorithm and to
`RigidDictionary.updateValue(forKey:with:)` (the latter exhibited
undefined behavior on removals).
- `RigidSet.insert(maximumCount:from:)` no longer spuriously reports a
capacity overflow due to incorrect accounting.
- The `UnstableHashedContainers` trait can now be enabled independently
of `UnstableContainersPreview`.

These types remain source-unstable for now.

##### Experimental sorted collections (`UnstableSortedCollections`
trait)

The `SortedCollections` module's [`SortedSet`][SortedSet] has gained the
following additions:

- `SortedSet` now supports value-range subscripts for the full variety
of standard range expression types, `ClosedRange`, `PartialRangeFrom`,
`PartialRangeThrough`, and `PartialRangeUpTo`.
- `SortedSet.firstIndex(after:)` and `SortedSet.lastIndex(before:)`
return the index to the nearest member following or preceding a given
value.

This release also fixes several underlying B-tree bugs that were
surfaced by these additions.

These types remain source-unstable; they have known API deficiencies
that will need to be addressed before they ship.

[SortedSet]:
https://redirect.github.com/apple/swift-collections/tree/main/Sources/SortedCollections/SortedSet

##### Experimental container protocols (`UnstableContainersPreview`
trait)

The `ContainersPreview` module's protocol hierarchy and associated types
continue to be developed. Several constructs have been renamed to follow
Swift Evolution proposals in flight.

| Old name                  | New name                    |
| ------------------------- | --------------------------- |
| `struct Box<T>`           | `struct UniqueBox<Value>`   |
| `struct Borrow<Target>`   | `struct Ref<Target>`        |
| `struct Inout<Target>`    | `struct MutableRef<Target>` |
| `Producer.ProducerError`  | `Producer.Failure`          |
| `Producer.generateNext()` | `Producer.next()`           |
| `Producer.skip(upTo:)`    | `Producer.skip(by:)`        |

For `UniqueBox`, `Ref` and `MutableRef`, there are deprecated
typealiases for the old names, preserving source compatibility.

Other changes to the experimental container model:

- `Container.Index` no longer needs to conform to `Comparable`. This
allows linked lists to become containers.
- `RigidArray`, `UniqueArray`, `RigidDeque`, and `UniqueDeque` now
conform to the container protocols.
- Added `Producer.collect(into:)` for collecting a producer's output
into a `RangeReplaceableContainer`.
- Added `BorrowingIteratorProtocol.copy()` for turning a borrowing
iterator into a producer.
- Added `filter` and `map` overloads for `BorrowingIteratorProtocol`,
`Producer`, and `Drain`.
- `BorrowingSequence.first` was removed.
- `BorrowingSequence`, `BorrowingIteratorProtocol` and their
requirements have temporarily gained trailing underscores to avoid
naming conflicts with the (provisional) protocol definition in the
Standard Library. We expect these definitions to be removed when these
protocols officially become part of the stdlib.

The protocol-based APIs in `ContainersPreview` now require a Swift 6.4
or later toolchain. `UniqueBox` is source-stable, therefore it continues
to require Swift 6.2.

##### Notable bug fixes

- `HashTreeCollections`: Fixed an invariant violation that could be
triggered by some operations on `TreeSet`/`TreeDictionary`.
- `_RopeModule`: Fixed an infinite loop when hashing the UTF-8 view of a
multi-chunk big substring.
- `BitCollections`: Fixed a bogus precondition in
`BitArray.insert(repeating:count:at:)`; fixed `BitSet.isSubset(of:
Range<Int>)` to correctly examine elements above the range's upper word.
- `HeapModule`: Fixed `Heap.insert(contentsOf:)` to use a wrapping
multiply in its Floyd-heuristic computation; added a missing bounds
assertion in `Heap._UnsafeHandle.swapAt(_:with:)`.
- `OrderedCollections`: Fixed `OrderedSet` crash on negative capacity
values; minor fixes in `_HashTable.UnsafeHandle`.
- `DequeModule`: Fixed sizing issue in
`UniqueDeque.replace(removing:addingCount:initializingWith:)`; fixed a
missing argument validation in
`RigidDeque.nextMutableSpan(after:maximumCount:)`;
`RigidDeque.consume(_:consumingWith:)` now closes the resulting gap
before returning; added zero-count fast-paths; replace/prepend
operations taking a `Collection` now verify that the source's count
matches its contents.
- `BasicContainers`: Fixed an overallocation issue in
`UniqueArray.replace(removing:copying:)`; fixed a partial-initialization
correctness issue in
`RigidArray.replace(removing:consumingWith:addingCount:initializingWith:)`.

#### What's Changed

- Add tests that build the ContainersPreview module by
[@&#8203;natecook1000](https://redirect.github.com/natecook1000) in
[#&#8203;610](https://redirect.github.com/apple/swift-collections/pull/610)
- Add a workflow that performs a CMake build by
[@&#8203;natecook1000](https://redirect.github.com/natecook1000) in
[#&#8203;612](https://redirect.github.com/apple/swift-collections/pull/612)
- Align `BorrowingSequence` implementation with proposal by
[@&#8203;natecook1000](https://redirect.github.com/natecook1000) in
[#&#8203;609](https://redirect.github.com/apple/swift-collections/pull/609)
- Bump
swiftlang/github-workflows/.github/workflows/swift\_package\_test.yml
from 0.0.8 to 0.0.9 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;615](https://redirect.github.com/apple/swift-collections/pull/615)
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from
0.0.8 to 0.0.9 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;614](https://redirect.github.com/apple/swift-collections/pull/614)
- Fix lifetime requirements rigidly enforced in the latest nightlies by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;617](https://redirect.github.com/apple/swift-collections/pull/617)
- Track array proposal by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;619](https://redirect.github.com/apple/swift-collections/pull/619)
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from
0.0.9 to 0.0.10 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;620](https://redirect.github.com/apple/swift-collections/pull/620)
- OrderedSet: Don't crash on negative capacity values by
[@&#8203;thisismanan](https://redirect.github.com/thisismanan) in
[#&#8203;622](https://redirect.github.com/apple/swift-collections/pull/622)
- \[ContainersPreview] Don’t require `Container.Index` to conform to
`Comparable` by [@&#8203;lorentey](https://redirect.github.com/lorentey)
in
[#&#8203;623](https://redirect.github.com/apple/swift-collections/pull/623)
- Adjust experimental workflows by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;626](https://redirect.github.com/apple/swift-collections/pull/626)
- [BitSet] Add `BitSet.makeIterator(from:)` by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;627](https://redirect.github.com/apple/swift-collections/pull/627)
- \[BasicContainers] RigidSet.insert(maximumCount:from:): Fix spurious
capacity overflow caused by incorrect accounting by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;628](https://redirect.github.com/apple/swift-collections/pull/628)
- \[BasicContainers]
RigidArray.replace(removing:consumingWith:addingCount:initializingWith:):
Fix correctness issue with partial initialization by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;629](https://redirect.github.com/apple/swift-collections/pull/629)
- \[BasicContainers] UniqueArray.replace(removing:copying): Fix
overallocation issue by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;630](https://redirect.github.com/apple/swift-collections/pull/630)
- Fix \_trim(first:) returning wrong buffer region by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;631](https://redirect.github.com/apple/swift-collections/pull/631)
- Bump swiftlang/github-workflows/.github/workflows/soundness.yml from
0.0.10 to 0.0.11 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;625](https://redirect.github.com/apple/swift-collections/pull/625)
- \[OrderedCollections] Add
OrderedDictionary.replaceElement(at:withKey:… by
[@&#8203;inju2403](https://redirect.github.com/inju2403) in
[#&#8203;616](https://redirect.github.com/apple/swift-collections/pull/616)
- \[ContainersPreview] Producer.ProducerError ⟹ Producer.Failure by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;634](https://redirect.github.com/apple/swift-collections/pull/634)
- fix: reserveCapacity DocC link in RigidArray by
[@&#8203;manojmahapatra](https://redirect.github.com/manojmahapatra) in
[#&#8203;633](https://redirect.github.com/apple/swift-collections/pull/633)
- \[BasicContainers, DequeModule]: Assorted fixes by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;632](https://redirect.github.com/apple/swift-collections/pull/632)
- \[Debugging] Add lldb data formatter for RigidArray by
[@&#8203;kastiglione](https://redirect.github.com/kastiglione) in
[#&#8203;607](https://redirect.github.com/apple/swift-collections/pull/607)
- \[HashTreeCollections] Fix invariant violation in
\_HashNode.\_regularNode by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;635](https://redirect.github.com/apple/swift-collections/pull/635)
- \[BitCollections] Fix small issues by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;637](https://redirect.github.com/apple/swift-collections/pull/637)
- \[HeapModule, SortedCollections] Assorted tool-assisted fixes and
adjustments by [@&#8203;lorentey](https://redirect.github.com/lorentey)
in
[#&#8203;639](https://redirect.github.com/apple/swift-collections/pull/639)
- \[BasicContainers] Enable APIs scheduled to ship in 1.5.0 by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;641](https://redirect.github.com/apple/swift-collections/pull/641)
- \[BasicContainers] Fix copypasta in `UniqueArray.edit`’s docs by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;642](https://redirect.github.com/apple/swift-collections/pull/642)
- Rename `Box` to `UniqueBox`; align API surface with SE-0517 by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;640](https://redirect.github.com/apple/swift-collections/pull/640)
- Bump
swiftlang/github-workflows/.github/workflows/swift\_package\_test.yml
from 0.0.9 to 0.0.11 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[#&#8203;624](https://redirect.github.com/apple/swift-collections/pull/624)
- Use the defines from traits directly by
[@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) in
[#&#8203;644](https://redirect.github.com/apple/swift-collections/pull/644)
- \[ContainersPreview] `struct Borrow` ⟹ `struct Ref` by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;643](https://redirect.github.com/apple/swift-collections/pull/643)
- \[ContainersPreview] `struct Inout` ⟹ `struct MutableRef` by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;646](https://redirect.github.com/apple/swift-collections/pull/646)
- 1.5.0 release preparations by
[@&#8203;lorentey](https://redirect.github.com/lorentey) in
[#&#8203;647](https://redirect.github.com/apple/swift-collections/pull/647)

#### New Contributors

- [@&#8203;thisismanan](https://redirect.github.com/thisismanan) made
their first contribution in
[#&#8203;622](https://redirect.github.com/apple/swift-collections/pull/622)
- [@&#8203;FranzBusch](https://redirect.github.com/FranzBusch) made
their first contribution in
[#&#8203;631](https://redirect.github.com/apple/swift-collections/pull/631)
- [@&#8203;inju2403](https://redirect.github.com/inju2403) made their
first contribution in
[#&#8203;616](https://redirect.github.com/apple/swift-collections/pull/616)
- [@&#8203;manojmahapatra](https://redirect.github.com/manojmahapatra)
made their first contribution in
[#&#8203;633](https://redirect.github.com/apple/swift-collections/pull/633)
- [@&#8203;kastiglione](https://redirect.github.com/kastiglione) made
their first contribution in
[#&#8203;607](https://redirect.github.com/apple/swift-collections/pull/607)

**Full Changelog**:
<https://github.com/apple/swift-collections/compare/1.4.1...1.5.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzkuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE3OS4zIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 01:02:39 +08:00
DarkSky 3416de1e4d fix(server): missing root cert (#14970)
#### PR Dependency Tree


* **PR #14970** 👈

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**
* Updated TLS library dependencies with pinned version constraints
across multiple packages
* Removed `tls-rustls` feature from sqlx configurations in backend and
frontend packages
  * Removed unused `sqlx` dependency from mobile native package
* Refined HTTPS client configuration with embedded certificate roots and
added validation test

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14970)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 01:02:07 +08:00
renovate[bot] d9cebdfc95 chore: bump up nestjs (#14968)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.20/11.1.21?slim=true)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.20/11.1.21?slim=true)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.20/11.1.21?slim=true)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.20/11.1.21?slim=true)
|
| [@nestjs/swagger](https://redirect.github.com/nestjs/swagger) |
[`11.4.2` →
`11.4.3`](https://renovatebot.com/diffs/npm/@nestjs%2fswagger/11.4.2/11.4.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fswagger/11.4.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fswagger/11.4.2/11.4.3?slim=true)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.20` →
`11.1.21`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.20/11.1.21)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.20/11.1.21?slim=true)
|

---

### Release Notes

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/core)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-express)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-socket.io)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

<details>
<summary>nestjs/swagger (@&#8203;nestjs/swagger)</summary>

###
[`v11.4.3`](https://redirect.github.com/nestjs/swagger/compare/11.4.2...0d79a3c9dea89236314609f8b18ec98b12c18692)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.4.2...11.4.3)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/websockets)</summary>

###
[`v11.1.21`](https://redirect.github.com/nestjs/nest/compare/v11.1.20...983dd52c4927753be3421162fc43e4fde8d3fcde)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.20...v11.1.21)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 23:39:14 +08:00
renovate[bot] 97d9ae3183 chore: bump up @opentelemetry/semantic-conventions version to v1.41.1 (#14962)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@opentelemetry/semantic-conventions](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`1.40.0` →
`1.41.1`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsemantic-conventions/1.40.0/1.41.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fsemantic-conventions/1.41.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fsemantic-conventions/1.40.0/1.41.1?slim=true)
|

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js
(@&#8203;opentelemetry/semantic-conventions)</summary>

###
[`v1.41.1`](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/ed6bd6d5f3a1f68b65ae25b1a8aae9c285ae83de...013c60085b84351a4c1e4e4f79e3dd67c56661cd)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/ed6bd6d5f3a1f68b65ae25b1a8aae9c285ae83de...013c60085b84351a4c1e4e4f79e3dd67c56661cd)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 18:25:55 +08:00
DarkSky c8cdc488db feat(server): entitlement primitive (#14964)
#### PR Dependency Tree


* **PR #14964** 👈

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 entitlement resolution to validate licenses and derive plan,
quotas, expiry and flags.
* Introduced persistent quota/entitlement state for users and workspaces
with legacy sync behavior.
* Real-time quota-state operations and change events for monitoring
usage.

* **Chores**
  * Updated workspace dependencies to add cryptography/hash crates.

* **Tests**
* Added native entitlement tests covering validation, quantity handling,
and signature/expiry cases.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14964)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 18:25:03 +08:00
Jachin 542da0b347 feat(editor): improve latex editing support (#14924)
## Summary
- support converting selected text into inline LaTeX equations
- support turning text blocks into LaTeX equation blocks
- add equation entries to editor toolbars while keeping inline equation
with text formatting actions

## Tests
- yarn tsc -b blocksuite/affine/inlines/latex/tsconfig.json
blocksuite/affine/blocks/note/tsconfig.json
blocksuite/affine/blocks/root/tsconfig.json
blocksuite/affine/rich-text/tsconfig.json
blocksuite/affine/widgets/keyboard-toolbar/tsconfig.json --pretty false
- git diff --check origin/canary...HEAD

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

* **New Features**
  * Equation block support with conversion from existing blocks.
  * Inline LaTeX insertion added to the inline formatting toolbar.
* Equation action added to the keyboard toolbar; Equation blocks
searchable via math/equation/latex aliases.

* **Improvements**
* Inline LaTeX editor opens and syncs more reliably; selection/convert
flow preserves distinct LaTeX values when converting in reverse order.

* **Tests**
  * New e2e tests for inline LaTeX conversions and value preservation.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14924)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 11:56:54 +08:00
renovate[bot] 7280fe33bc chore: bump up Node.js to v22.22.3 (#14961)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [node](https://nodejs.org)
([source](https://redirect.github.com/nodejs/node)) | patch | `22.22.2`
→ `22.22.3` |

---

### Release Notes

<details>
<summary>nodejs/node (node)</summary>

###
[`v22.22.3`](https://redirect.github.com/nodejs/node/releases/tag/v22.22.3):
2026-05-13, Version 22.22.3 'Jod' (LTS), @&#8203;marco-ippolito

[Compare
Source](https://redirect.github.com/nodejs/node/compare/v22.22.2...v22.22.3)

##### Commits

-
\[[`4f780905c5`](https://redirect.github.com/nodejs/node/commit/4f780905c5)]
- **crypto**: fix potential null pointer dereference when
BIO\_meth\_new() fails (Nora Dossche)
[#&#8203;61788](https://redirect.github.com/nodejs/node/pull/61788)
-
\[[`4a09efb947`](https://redirect.github.com/nodejs/node/commit/4a09efb947)]
- **crypto**: update root certificates to NSS 3.121 (Node.js GitHub Bot)
[#&#8203;62485](https://redirect.github.com/nodejs/node/pull/62485)
-
\[[`e4c0d99839`](https://redirect.github.com/nodejs/node/commit/e4c0d99839)]
- **deps**: update timezone to 2026a (Node.js GitHub Bot)
[#&#8203;62164](https://redirect.github.com/nodejs/node/pull/62164)
-
\[[`0226c8dd7a`](https://redirect.github.com/nodejs/node/commit/0226c8dd7a)]
- **deps**: update simdjson to 4.5.0 (Node.js GitHub Bot)
[#&#8203;62382](https://redirect.github.com/nodejs/node/pull/62382)
-
\[[`e742ab748c`](https://redirect.github.com/nodejs/node/commit/e742ab748c)]
- **deps**: update sqlite to 3.51.3 (Node.js GitHub Bot)
[#&#8203;62256](https://redirect.github.com/nodejs/node/pull/62256)
-
\[[`73cac0571a`](https://redirect.github.com/nodejs/node/commit/73cac0571a)]
- **deps**: update amaro to 1.1.8 (Node.js GitHub Bot)
[#&#8203;62151](https://redirect.github.com/nodejs/node/pull/62151)
-
\[[`ae5c162b93`](https://redirect.github.com/nodejs/node/commit/ae5c162b93)]
- **deps**: update amaro to 1.1.7 (Node.js GitHub Bot)
[#&#8203;61730](https://redirect.github.com/nodejs/node/pull/61730)
-
\[[`b819cb9977`](https://redirect.github.com/nodejs/node/commit/b819cb9977)]
- **deps**: update amaro to 1.1.6 (Node.js GitHub Bot)
[#&#8203;61603](https://redirect.github.com/nodejs/node/pull/61603)
-
\[[`bbcce09dc7`](https://redirect.github.com/nodejs/node/commit/bbcce09dc7)]
- **deps**: update sqlite to 3.52.0 (Node.js GitHub Bot)
[#&#8203;62150](https://redirect.github.com/nodejs/node/pull/62150)
-
\[[`22ff2d81ce`](https://redirect.github.com/nodejs/node/commit/22ff2d81ce)]
- **deps**: update simdjson to 4.3.1 (Node.js GitHub Bot)
[#&#8203;61930](https://redirect.github.com/nodejs/node/pull/61930)
-
\[[`f49b51d75c`](https://redirect.github.com/nodejs/node/commit/f49b51d75c)]
- **deps**: update acorn-walk to 8.3.5 (Node.js GitHub Bot)
[#&#8203;61928](https://redirect.github.com/nodejs/node/pull/61928)
-
\[[`1a5cec0d49`](https://redirect.github.com/nodejs/node/commit/1a5cec0d49)]
- **deps**: update acorn to 8.16.0 (Node.js GitHub Bot)
[#&#8203;61925](https://redirect.github.com/nodejs/node/pull/61925)
-
\[[`d339497688`](https://redirect.github.com/nodejs/node/commit/d339497688)]
- **deps**: update nbytes to 0.1.3 (Node.js GitHub Bot)
[#&#8203;61879](https://redirect.github.com/nodejs/node/pull/61879)
-
\[[`3ff8ffd459`](https://redirect.github.com/nodejs/node/commit/3ff8ffd459)]
- **deps**: remove stale OpenSSL arch configs (René)
[#&#8203;61834](https://redirect.github.com/nodejs/node/pull/61834)
-
\[[`b8ddbc1e9a`](https://redirect.github.com/nodejs/node/commit/b8ddbc1e9a)]
- **deps**: update llhttp to 9.3.1 (Node.js GitHub Bot)
[#&#8203;61827](https://redirect.github.com/nodejs/node/pull/61827)
-
\[[`ffda97afd4`](https://redirect.github.com/nodejs/node/commit/ffda97afd4)]
- **deps**: update googletest to
[`2461743`](https://redirect.github.com/nodejs/node/commit/2461743991f9aa53e9a3625eafcbacd81a3c74cd)
(Node.js GitHub Bot)
[#&#8203;62484](https://redirect.github.com/nodejs/node/pull/62484)
-
\[[`79aa32cf4f`](https://redirect.github.com/nodejs/node/commit/79aa32cf4f)]
- **deps**: update googletest to
[`73a63ea`](https://redirect.github.com/nodejs/node/commit/73a63ea05dc8ca29ec1d2c1d66481dd0de1950f1)
(Node.js GitHub Bot)
[#&#8203;61927](https://redirect.github.com/nodejs/node/pull/61927)
-
\[[`b6957e13b6`](https://redirect.github.com/nodejs/node/commit/b6957e13b6)]
- **deps**: update archs files for openssl-3.5.6 (Node.js GitHub Bot)
[#&#8203;62629](https://redirect.github.com/nodejs/node/pull/62629)
-
\[[`3a27669063`](https://redirect.github.com/nodejs/node/commit/3a27669063)]
- **deps**: upgrade openssl sources to openssl-3.5.6 (Node.js GitHub
Bot) [#&#8203;62629](https://redirect.github.com/nodejs/node/pull/62629)
-
\[[`d568a1bb53`](https://redirect.github.com/nodejs/node/commit/d568a1bb53)]
- **deps**: upgrade npm to 10.9.8 (npm team)
[#&#8203;62463](https://redirect.github.com/nodejs/node/pull/62463)
-
\[[`ec11f3c1d5`](https://redirect.github.com/nodejs/node/commit/ec11f3c1d5)]
- **deps**: V8: backport
[`85b3900`](https://redirect.github.com/nodejs/node/commit/85b390089e51)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`08609712ed`](https://redirect.github.com/nodejs/node/commit/08609712ed)]
- **deps**: V8: backport
[`1b27e46`](https://redirect.github.com/nodejs/node/commit/1b27e4674f11)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`dcc60d5ab2`](https://redirect.github.com/nodejs/node/commit/dcc60d5ab2)]
- **deps**: V8: backport
[`9997fc0`](https://redirect.github.com/nodejs/node/commit/9997fc013952)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`1d1f4451fb`](https://redirect.github.com/nodejs/node/commit/1d1f4451fb)]
- **deps**: V8: cherry-pick
[`b96e40d`](https://redirect.github.com/nodejs/node/commit/b96e40d5ac85)
(Clemens Backes)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`2268567237`](https://redirect.github.com/nodejs/node/commit/2268567237)]
- **deps**: V8: cherry-pick
[`7cb6188`](https://redirect.github.com/nodejs/node/commit/7cb6188cf913)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`92804cdbea`](https://redirect.github.com/nodejs/node/commit/92804cdbea)]
- **deps**: V8: cherry-pick
[`e7ccf0a`](https://redirect.github.com/nodejs/node/commit/e7ccf0af1bdd)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`eae2c27a40`](https://redirect.github.com/nodejs/node/commit/eae2c27a40)]
- **deps**: V8: cherry-pick
[`8e214ec`](https://redirect.github.com/nodejs/node/commit/8e214ec3ec8c)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`a1799a49bb`](https://redirect.github.com/nodejs/node/commit/a1799a49bb)]
- **deps**: V8: backport
[`63b8849`](https://redirect.github.com/nodejs/node/commit/63b8849d73ae)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`a2df2d8731`](https://redirect.github.com/nodejs/node/commit/a2df2d8731)]
- **deps**: V8: backport
[`3239427`](https://redirect.github.com/nodejs/node/commit/323942700cfe)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`e3d65c7dca`](https://redirect.github.com/nodejs/node/commit/e3d65c7dca)]
- **deps**: V8: backport
[`89dc6ea`](https://redirect.github.com/nodejs/node/commit/89dc6eab605c)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`5e7db133de`](https://redirect.github.com/nodejs/node/commit/5e7db133de)]
- **deps**: V8: backport
[`910cb91`](https://redirect.github.com/nodejs/node/commit/910cb91733dc)
(Jakob Kummerow)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`d0c24a28af`](https://redirect.github.com/nodejs/node/commit/d0c24a28af)]
- **deps**: V8: cherry-pick
[`b8f91e5`](https://redirect.github.com/nodejs/node/commit/b8f91e510e0f)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`d358687824`](https://redirect.github.com/nodejs/node/commit/d358687824)]
- **deps**: V8: cherry-pick
[`cf03d55`](https://redirect.github.com/nodejs/node/commit/cf03d55db2a0)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`67c8b2c349`](https://redirect.github.com/nodejs/node/commit/67c8b2c349)]
- **deps**: V8: cherry-pick
[`692f3d5`](https://redirect.github.com/nodejs/node/commit/692f3d526a38)
(Sébastien Doeraene)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`71e5a59ffd`](https://redirect.github.com/nodejs/node/commit/71e5a59ffd)]
- **deps**: V8: cherry-pick
[`c734674`](https://redirect.github.com/nodejs/node/commit/c734674e03f9)
(Manos Koukoutos)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`f0dbe81c7b`](https://redirect.github.com/nodejs/node/commit/f0dbe81c7b)]
- **deps**: V8: cherry-pick
[`b2f3aea`](https://redirect.github.com/nodejs/node/commit/b2f3aea23a01)
(Thibaud Michaud)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`d333f480c3`](https://redirect.github.com/nodejs/node/commit/d333f480c3)]
- **deps**: V8: cherry-pick
[`5f1342c`](https://redirect.github.com/nodejs/node/commit/5f1342c20b59)
(Matthias Liedtke)
[#&#8203;62783](https://redirect.github.com/nodejs/node/pull/62783)
-
\[[`db722725bb`](https://redirect.github.com/nodejs/node/commit/db722725bb)]
- **deps**: use npm undici\@&#8203;six tag in `update-undici.sh` (Matteo
Collina)
[#&#8203;63012](https://redirect.github.com/nodejs/node/pull/63012)
-
\[[`9b57979d9c`](https://redirect.github.com/nodejs/node/commit/9b57979d9c)]
- **doc**: add Rafael to last security release steward (Rafael Gonzaga)
[#&#8203;62423](https://redirect.github.com/nodejs/node/pull/62423)
-
\[[`d8075585bf`](https://redirect.github.com/nodejs/node/commit/d8075585bf)]
- **doc**: add path to vulnerabilities.json mention (Rafael Gonzaga)
[#&#8203;62355](https://redirect.github.com/nodejs/node/pull/62355)
-
\[[`6ec9a70204`](https://redirect.github.com/nodejs/node/commit/6ec9a70204)]
- **doc**: clarify fs.ReadStream and fs.WriteStream are not
constructable (Kit Dallege)
[#&#8203;62208](https://redirect.github.com/nodejs/node/pull/62208)
-
\[[`1fc86fcb6e`](https://redirect.github.com/nodejs/node/commit/1fc86fcb6e)]
- **doc**: add note (and caveat) for `mock.module` about customization
hooks (Jacob Smith)
[#&#8203;62075](https://redirect.github.com/nodejs/node/pull/62075)
-
\[[`491be80bd9`](https://redirect.github.com/nodejs/node/commit/491be80bd9)]
- **doc**: add efekrskl as triager (Efe)
[#&#8203;61876](https://redirect.github.com/nodejs/node/pull/61876)
-
\[[`18558293a3`](https://redirect.github.com/nodejs/node/commit/18558293a3)]
- **doc**: fix module.stripTypeScriptTypes indentation (René)
[#&#8203;61992](https://redirect.github.com/nodejs/node/pull/61992)
-
\[[`8e20976522`](https://redirect.github.com/nodejs/node/commit/8e20976522)]
- **doc**: explicitly mention Slack handle (Rafael Gonzaga)
[#&#8203;61986](https://redirect.github.com/nodejs/node/pull/61986)
-
\[[`70b8e6b4fb`](https://redirect.github.com/nodejs/node/commit/70b8e6b4fb)]
- **doc**: rename invalid `function` parameter (René)
[#&#8203;61942](https://redirect.github.com/nodejs/node/pull/61942)
-
\[[`4045c76f6c`](https://redirect.github.com/nodejs/node/commit/4045c76f6c)]
- **doc**: clarify status of feature request issues (Antoine du Hamel)
[#&#8203;61505](https://redirect.github.com/nodejs/node/pull/61505)
-
\[[`c54652f2aa`](https://redirect.github.com/nodejs/node/commit/c54652f2aa)]
- **doc**: remove incorrect mention of `module` in `typescript.md` (Rob
Palmer)
[#&#8203;61839](https://redirect.github.com/nodejs/node/pull/61839)
-
\[[`9fad6cedf5`](https://redirect.github.com/nodejs/node/commit/9fad6cedf5)]
- **doc**: clarify async caveats for `events.once()` (René)
[#&#8203;61572](https://redirect.github.com/nodejs/node/pull/61572)
-
\[[`2f1e5733fe`](https://redirect.github.com/nodejs/node/commit/2f1e5733fe)]
- **doc**: update Juan's security steward info (Juan José)
[#&#8203;61754](https://redirect.github.com/nodejs/node/pull/61754)
-
\[[`a64bdb5068`](https://redirect.github.com/nodejs/node/commit/a64bdb5068)]
- **doc**: fix overstated Date header requirement in response.sendDate
(Kit Dallege)
[#&#8203;62206](https://redirect.github.com/nodejs/node/pull/62206)
-
\[[`02797de923`](https://redirect.github.com/nodejs/node/commit/02797de923)]
- **doc**: fix small environment\_variables typo (chris)
[#&#8203;62279](https://redirect.github.com/nodejs/node/pull/62279)
-
\[[`f22ebdc809`](https://redirect.github.com/nodejs/node/commit/f22ebdc809)]
- **doc**: fix small logic error in DETECT\_MODULE\_SYNTAX (René)
[#&#8203;62025](https://redirect.github.com/nodejs/node/pull/62025)
-
\[[`9f4508062a`](https://redirect.github.com/nodejs/node/commit/9f4508062a)]
- **doc**: fix methods being documented as properties in `process.md`
(Antoine du Hamel)
[#&#8203;61765](https://redirect.github.com/nodejs/node/pull/61765)
-
\[[`3ea39ff135`](https://redirect.github.com/nodejs/node/commit/3ea39ff135)]
- **doc**: fix dropdown menu being obscured at <600px due to stacking
context (Jeff)
[#&#8203;61735](https://redirect.github.com/nodejs/node/pull/61735)
-
\[[`c22445079b`](https://redirect.github.com/nodejs/node/commit/c22445079b)]
- **doc**: fix spacing in process message event (Aviv Keller)
[#&#8203;61756](https://redirect.github.com/nodejs/node/pull/61756)
-
\[[`32831b5223`](https://redirect.github.com/nodejs/node/commit/32831b5223)]
- **doc**: fix broken links of net.md (YuSheng Chen)
[#&#8203;61673](https://redirect.github.com/nodejs/node/pull/61673)
-
\[[`005508d509`](https://redirect.github.com/nodejs/node/commit/005508d509)]
- **doc**: remove obsolete Boxstarter automated install (Mike McCready)
[#&#8203;61785](https://redirect.github.com/nodejs/node/pull/61785)
-
\[[`37c2fd6f7d`](https://redirect.github.com/nodejs/node/commit/37c2fd6f7d)]
- **esm**: fix path normalization in `finalizeResolution` (Antoine du
Hamel)
[#&#8203;62080](https://redirect.github.com/nodejs/node/pull/62080)
-
\[[`1769d74613`](https://redirect.github.com/nodejs/node/commit/1769d74613)]
- **esm**: populate separate cache for require(esm) in imported CJS
(Joyee Cheung)
[#&#8203;59679](https://redirect.github.com/nodejs/node/pull/59679)
-
\[[`ee02966ffc`](https://redirect.github.com/nodejs/node/commit/ee02966ffc)]
- **http**: fix keep-alive socket reuse race in requestOnFinish (Martin
Slota)
[#&#8203;61710](https://redirect.github.com/nodejs/node/pull/61710)
-
\[[`2fdb5ce6cc`](https://redirect.github.com/nodejs/node/commit/2fdb5ce6cc)]
- **http2**: fix FileHandle leak in respondWithFile (sangwook)
[#&#8203;61707](https://redirect.github.com/nodejs/node/pull/61707)
-
\[[`aa2c1eca04`](https://redirect.github.com/nodejs/node/commit/aa2c1eca04)]
- **lib**: fix source map url parse in dynamic imports (Chengzhong Wu)
[#&#8203;61990](https://redirect.github.com/nodejs/node/pull/61990)
-
\[[`785b00cbeb`](https://redirect.github.com/nodejs/node/commit/785b00cbeb)]
- **meta**: pass release version to release worker (flakey5)
[#&#8203;62777](https://redirect.github.com/nodejs/node/pull/62777)
-
\[[`447fb9a0b5`](https://redirect.github.com/nodejs/node/commit/447fb9a0b5)]
- **meta**: persist sccache daemon until end of build workflows (René)
[#&#8203;61639](https://redirect.github.com/nodejs/node/pull/61639)
-
\[[`5065a0acb3`](https://redirect.github.com/nodejs/node/commit/5065a0acb3)]
- **module**: do not invoke resolve hooks twice for imported cjs (Joyee
Cheung)
[#&#8203;61529](https://redirect.github.com/nodejs/node/pull/61529)
-
\[[`9a2e21305d`](https://redirect.github.com/nodejs/node/commit/9a2e21305d)]
- **module**: do not wrap module.\_load when tracing is not enabled
(Joyee Cheung)
[#&#8203;61479](https://redirect.github.com/nodejs/node/pull/61479)
-
\[[`b9240bc063`](https://redirect.github.com/nodejs/node/commit/b9240bc063)]
- **module**: fix sync resolve hooks for require with node: prefixes
(Joyee Cheung)
[#&#8203;61088](https://redirect.github.com/nodejs/node/pull/61088)
-
\[[`2e91b28aaf`](https://redirect.github.com/nodejs/node/commit/2e91b28aaf)]
- **module**: handle null source from async loader hooks in sync hooks
(Joyee Cheung)
[#&#8203;59929](https://redirect.github.com/nodejs/node/pull/59929)
-
\[[`39147c154e`](https://redirect.github.com/nodejs/node/commit/39147c154e)]
- **module**: use sync cjs when importing cts (Marco Ippolito)
[#&#8203;60072](https://redirect.github.com/nodejs/node/pull/60072)
-
\[[`12a2462b2c`](https://redirect.github.com/nodejs/node/commit/12a2462b2c)]
- **module**: only put directly require-d ESM into require.cache (Joyee
Cheung)
[#&#8203;59874](https://redirect.github.com/nodejs/node/pull/59874)
-
\[[`cf39566277`](https://redirect.github.com/nodejs/node/commit/cf39566277)]
- **src**: fix flags argument offset in JSUdpWrap (Weixie Cui)
[#&#8203;61948](https://redirect.github.com/nodejs/node/pull/61948)
-
\[[`578a9a9230`](https://redirect.github.com/nodejs/node/commit/578a9a9230)]
- **src**: clamp WriteUtf8 capacity to INT\_MAX in EncodeInto
(semimikoh)
[#&#8203;62621](https://redirect.github.com/nodejs/node/pull/62621)
-
\[[`57c3035fec`](https://redirect.github.com/nodejs/node/commit/57c3035fec)]
- **stream**: fix decoded fromList chunk boundary check (Thomas Watson)
[#&#8203;61884](https://redirect.github.com/nodejs/node/pull/61884)
-
\[[`57fb008bb8`](https://redirect.github.com/nodejs/node/commit/57fb008bb8)]
- **test**: update tls junk data error expectations (Filip Skokan)
[#&#8203;62629](https://redirect.github.com/nodejs/node/pull/62629)
-
\[[`363f9a9d18`](https://redirect.github.com/nodejs/node/commit/363f9a9d18)]
- **test**: skip `test-url` on `--shared-ada` builds (Antoine du Hamel)
[#&#8203;62019](https://redirect.github.com/nodejs/node/pull/62019)
-
\[[`daaead342b`](https://redirect.github.com/nodejs/node/commit/daaead342b)]
- **test**: simplify encodeInto large buffer regression test (semimikoh)
[#&#8203;62621](https://redirect.github.com/nodejs/node/pull/62621)
-
\[[`ecfa766b41`](https://redirect.github.com/nodejs/node/commit/ecfa766b41)]
- **tools**: fix auto-start-ci (Antoine du Hamel)
[#&#8203;61900](https://redirect.github.com/nodejs/node/pull/61900)
-
\[[`17c0a610af`](https://redirect.github.com/nodejs/node/commit/17c0a610af)]
- **tools**: fix parsing of commit trailers in `lint-release-proposal`
GHA (Antoine du Hamel)
[#&#8203;62077](https://redirect.github.com/nodejs/node/pull/62077)
-
\[[`89ad7dc63b`](https://redirect.github.com/nodejs/node/commit/89ad7dc63b)]
- **tools**: enforce removal of `lts-watch-*` labels on release
proposals (Antoine du Hamel)
[#&#8203;61672](https://redirect.github.com/nodejs/node/pull/61672)
-
\[[`5f9bb8ef0c`](https://redirect.github.com/nodejs/node/commit/5f9bb8ef0c)]
- **tools**: revert tools GHA workflow to ubuntu-latest (Richard Lau)
[#&#8203;62024](https://redirect.github.com/nodejs/node/pull/62024)
-
\[[`977ef80ac1`](https://redirect.github.com/nodejs/node/commit/977ef80ac1)]
- **url**: process crash via malformed UNC hostname in pathToFileURL()
(Nicola Del Gobbo)
[#&#8203;62574](https://redirect.github.com/nodejs/node/pull/62574)
-
\[[`ad8f518a81`](https://redirect.github.com/nodejs/node/commit/ad8f518a81)]
- **zlib**: fix use-after-free when reset() is called during write
(Matteo Collina)
[#&#8203;62325](https://redirect.github.com/nodejs/node/pull/62325)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:55:16 +08:00
DarkSky f626dbd590 fix(server): realtime loading (#14959)
#### PR Dependency Tree


* **PR #14959** 👈

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**
* Rewired realtime and copilot services to require their runtime
dependencies, improving reliability and removing nullable/optional
runtime paths.

* **Tests**
* Centralized service creation in tests with helper factories and added
checks ensuring realtime dependency injection is configured as expected.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14959)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 11:54:45 +08:00
renovate[bot] 419fc5d5e0 chore: bump up Recouse/EventSource version to from: "0.1.8" (#14960)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [Recouse/EventSource](https://redirect.github.com/Recouse/EventSource)
| patch | `from: "0.1.7"` → `from: "0.1.8"` |

---

### Release Notes

<details>
<summary>Recouse/EventSource (Recouse/EventSource)</summary>

###
[`v0.1.8`](https://redirect.github.com/Recouse/EventSource/releases/tag/0.1.8)

[Compare
Source](https://redirect.github.com/Recouse/EventSource/compare/0.1.7...0.1.8)

#### What's Changed

- Fix O(n²) performance in ServerEventParser.parse() by
[@&#8203;liefran-sim](https://redirect.github.com/liefran-sim) in
[#&#8203;49](https://redirect.github.com/Recouse/EventSource/pull/49)

#### New Contributors

- [@&#8203;liefran-sim](https://redirect.github.com/liefran-sim) made
their first contribution in
[#&#8203;49](https://redirect.github.com/Recouse/EventSource/pull/49)

**Full Changelog**:
<https://github.com/Recouse/EventSource/compare/0.1.7...0.1.8>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzMuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 10:15:11 +08:00
DarkSky 1201f7c350 chore: bump rspack (#14957)
#### PR Dependency Tree


* **PR #14957** 👈

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**
  * Updated minimum Node version requirement to 22.12.0 or later.
* Updated build tool dependencies including rspack and related packages.
  * Removed CI-specific logging behavior from development server.
* Migrated to native HTML plugin integration for improved build
efficiency.
* Simplified build configuration by removing unused experimental
options.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14957)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 04:18:49 +08:00
DarkSky 4b4def3a11 feat(server): gemini embedding 2 support (#14956)
#### PR Dependency Tree


* **PR #14956** 👈

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

* **Bug Fixes**
* Improved Gemini Vertex provider configuration validation logic for
enhanced reliability.
  * Refined Google Vertex publisher base URL construction handling.

* **Tests**
  * Added test coverage for Gemini Embedding 2 model resolution.
* Added test coverage for Gemini Vertex provider Google Cloud
integration.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14956)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-14 04:12:49 +08:00
renovate[bot] 2b22fe4692 chore: bump up nestjs (#13791)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@nestjs/apollo](https://redirect.github.com/nestjs/graphql) |
[`13.2.4` →
`13.4.0`](https://renovatebot.com/diffs/npm/@nestjs%2fapollo/13.2.4/13.4.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fapollo/13.4.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fapollo/13.2.4/13.4.0?slim=true)
|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.18/11.1.20?slim=true)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.18/11.1.20?slim=true)
|
| [@nestjs/graphql](https://redirect.github.com/nestjs/graphql) |
[`13.2.5` →
`13.4.0`](https://renovatebot.com/diffs/npm/@nestjs%2fgraphql/13.2.5/13.4.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fgraphql/13.4.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fgraphql/13.2.5/13.4.0?slim=true)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.18/11.1.20?slim=true)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.18/11.1.20?slim=true)
|
| [@nestjs/schedule](https://redirect.github.com/nestjs/schedule) |
[`6.1.1` →
`6.1.3`](https://renovatebot.com/diffs/npm/@nestjs%2fschedule/6.1.1/6.1.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fschedule/6.1.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fschedule/6.1.1/6.1.3?slim=true)
|
| [@nestjs/swagger](https://redirect.github.com/nestjs/swagger) |
[`11.2.7` →
`11.4.2`](https://renovatebot.com/diffs/npm/@nestjs%2fswagger/11.2.7/11.4.2)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fswagger/11.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fswagger/11.2.7/11.4.2?slim=true)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.18` →
`11.1.20`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.18/11.1.20)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.18/11.1.20?slim=true)
|

---

### Release Notes

<details>
<summary>nestjs/graphql (@&#8203;nestjs/apollo)</summary>

###
[`v13.4.0`](https://redirect.github.com/nestjs/graphql/releases/tag/v13.4.0)

[Compare
Source](https://redirect.github.com/nestjs/graphql/compare/v13.3.0...v13.4.0)

#### 13.4.0 (2026-04-30)

##### Features

- `apollo`, `graphql`, `mercurius`
- [#&#8203;3811](https://redirect.github.com/nestjs/graphql/pull/3811)
feat(graphql): Add registerIn option for module-scoped type filtering
([@&#8203;joe-re](https://redirect.github.com/joe-re))

##### Bug fixes

- `graphql`
- [#&#8203;3959](https://redirect.github.com/nestjs/graphql/pull/3959)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
stop double-registering PickType inputs
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3960](https://redirect.github.com/nestjs/graphql/pull/3960)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
de-duplicate per-target metadata in TargetMetadataCollection
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- `apollo`, `graphql`
- [#&#8203;3962](https://redirect.github.com/nestjs/graphql/pull/3962)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
restore Timestamp scalar parsers in federation factory
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Enhancements

- `graphql`
- [#&#8203;3963](https://redirect.github.com/nestjs/graphql/pull/3963)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
validate registerEnumType/createUnionType options eagerly
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Dependencies

- `graphql`
- [#&#8203;3954](https://redirect.github.com/nestjs/graphql/pull/3954)
fix(deps): update graphql-tools monorepo
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 3

- Masato Noguchi ([@&#8203;joe-re](https://redirect.github.com/joe-re))
- Mateus Welter Goettems
([@&#8203;mateuswgoettems](https://redirect.github.com/mateuswgoettems))
- Yogeshwaran C
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

###
[`v13.3.0`](https://redirect.github.com/nestjs/graphql/releases/tag/v13.3.0)

[Compare
Source](https://redirect.github.com/nestjs/graphql/compare/v13.2.5...v13.3.0)

#### 13.3.0 (2026-04-22)

##### Bug fixes

- `graphql`
- [#&#8203;3949](https://redirect.github.com/nestjs/graphql/pull/3949)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
count args for parenless arrow functions
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3952](https://redirect.github.com/nestjs/graphql/pull/3952)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
keep class directive when a field has the same SDL
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3946](https://redirect.github.com/nestjs/graphql/pull/3946)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
emit enum key for Args defaultValue in generated SDL
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3934](https://redirect.github.com/nestjs/graphql/pull/3934)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
treat single-key string enums as enums in plugin type detection
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3939](https://redirect.github.com/nestjs/graphql/pull/3939)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
preserve ResolveField options for all overloads
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- `apollo`
- [#&#8203;3940](https://redirect.github.com/nestjs/graphql/pull/3940)
fix(apollo): preserve HTTP 200 for execution-level GraphQL errors
([@&#8203;maruthang](https://redirect.github.com/maruthang))

##### Enhancements

- `graphql`
- [#&#8203;3838](https://redirect.github.com/nestjs/graphql/pull/3838)
perf(graphql): bypass ExternalContextCreator for scalar ResolveField
fast-path ([@&#8203;ArielSafar](https://redirect.github.com/ArielSafar))
- [#&#8203;3950](https://redirect.github.com/nestjs/graphql/pull/3950)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
forward specifiedByURL and extensions on custom scalars
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3951](https://redirect.github.com/nestjs/graphql/pull/3951)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
accept array of SDL strings in
[@&#8203;Directive](https://redirect.github.com/Directive)
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3944](https://redirect.github.com/nestjs/graphql/pull/3944)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
emit clearer error when nested object type is used in mapped input
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3943](https://redirect.github.com/nestjs/graphql/pull/3943)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
add conditional exports for browser shim
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3942](https://redirect.github.com/nestjs/graphql/pull/3942)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
default federation to v2.12 directives
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3936](https://redirect.github.com/nestjs/graphql/pull/3936)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
allow CustomScalar methods to return null
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- `apollo`, `graphql`
- [#&#8203;3948](https://redirect.github.com/nestjs/graphql/pull/3948)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
support directives on enums and unions
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Dependencies

- `graphql`
- [#&#8203;3925](https://redirect.github.com/nestjs/graphql/pull/3925)
chore(deps): update dependency ts-morph to v28
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3918](https://redirect.github.com/nestjs/graphql/pull/3918)
fix(deps): update graphql-tools monorepo
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- `mercurius`
- [#&#8203;3928](https://redirect.github.com/nestjs/graphql/pull/3928)
chore(deps): update dependency fastify to v5.8.5
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3858](https://redirect.github.com/nestjs/graphql/pull/3858)
chore(deps): update dependency
[@&#8203;mercuriusjs/gateway](https://redirect.github.com/mercuriusjs/gateway)
to v5.2.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3920](https://redirect.github.com/nestjs/graphql/pull/3920)
chore(deps): update dependency mercurius to v16.9.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 3

- Ariel Safar
([@&#8203;ArielSafar](https://redirect.github.com/ArielSafar))
- Maruthan G
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- Yogeshwaran C
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

###
[`v13.2.5`](https://redirect.github.com/nestjs/graphql/releases/tag/v13.2.5)

[Compare
Source](https://redirect.github.com/nestjs/graphql/compare/v13.2.4...v13.2.5)

##### 13.2.5 (2026-04-09)

##### Bug fixes

- `graphql`
- [#&#8203;3846](https://redirect.github.com/nestjs/graphql/pull/3846)
fix([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
handle definitions factory typename option
([@&#8203;NicolasGn](https://redirect.github.com/NicolasGn))

##### Enhancements

- `graphql`
- [#&#8203;3889](https://redirect.github.com/nestjs/graphql/pull/3889)
feat([@&#8203;nestjs/graphql](https://redirect.github.com/nestjs/graphql)):
add stopOnApplicationShutdown option for graceful shutdown
([@&#8203;dgfh0450](https://redirect.github.com/dgfh0450))

##### Dependencies

- `graphql`
- [#&#8203;3894](https://redirect.github.com/nestjs/graphql/pull/3894)
fix(deps): update dependency graphql-ws to v6.0.8
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3852](https://redirect.github.com/nestjs/graphql/pull/3852)
chore(deps): update dependency graphql to v16.13.2
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3888](https://redirect.github.com/nestjs/graphql/pull/3888)
fix(deps): update dependency ws to v8.20.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3901](https://redirect.github.com/nestjs/graphql/pull/3901)
fix(deps): update dependency
[@&#8203;nestjs/mapped-types](https://redirect.github.com/nestjs/mapped-types)
to v2.1.1
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3904](https://redirect.github.com/nestjs/graphql/pull/3904)
fix(deps): update dependency lodash to v4.18.1 \[security]
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- `apollo`
- [#&#8203;3902](https://redirect.github.com/nestjs/graphql/pull/3902)
fix(deps): update dependency lodash.omit to v4.18.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3897](https://redirect.github.com/nestjs/graphql/pull/3897)
chore(deps): update dependency
[@&#8203;apollo/server](https://redirect.github.com/apollo/server) to
v5.5.0 \[security]
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3881](https://redirect.github.com/nestjs/graphql/pull/3881)
chore(deps): update dependency
[@&#8203;apollo/gateway](https://redirect.github.com/apollo/gateway) to
v2.10.5 \[security]
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- `mercurius`
- [#&#8203;3899](https://redirect.github.com/nestjs/graphql/pull/3899)
chore(deps): update dependency
[@&#8203;mercuriusjs/federation](https://redirect.github.com/mercuriusjs/federation)
to v5.1.1
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3890](https://redirect.github.com/nestjs/graphql/pull/3890)
chore(deps): update dependency fastify to v5.8.4
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))
- [#&#8203;3868](https://redirect.github.com/nestjs/graphql/pull/3868)
chore(deps): update dependency mercurius to v16.8.0
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 2

- Nicolas Guégan
([@&#8203;NicolasGn](https://redirect.github.com/NicolasGn))
- YoonDH ([@&#8203;dgfh0450](https://redirect.github.com/dgfh0450))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/compare/v11.1.19...7caeb3fb70de81085c4c3e8502a2a0e62e4f8eda)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

#### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/core)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/compare/v11.1.19...7caeb3fb70de81085c4c3e8502a2a0e62e4f8eda)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

##### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-express)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/compare/v11.1.19...7caeb3fb70de81085c4c3e8502a2a0e62e4f8eda)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

##### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-socket.io)</summary>

###
[`v11.1.20`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.20)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.19...v11.1.20)

##### v11.1.20 (2026-05-13)

##### Bug fixes

- `core`, `testing`
- [#&#8203;16939](https://redirect.github.com/nestjs/nest/pull/16939)
fix(core): fix deeply nested transient providers resolution
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- `core`
- [#&#8203;16861](https://redirect.github.com/nestjs/nest/pull/16861)
fix(core): fix [@&#8203;Sse](https://redirect.github.com/Sse) losing
events on complete
([@&#8203;MatthiasBrehmer](https://redirect.github.com/MatthiasBrehmer))
- [#&#8203;16753](https://redirect.github.com/nestjs/nest/pull/16753)
fix(core): defer sse writehead until after lifecycle completes
([@&#8203;jkalberer](https://redirect.github.com/jkalberer))
- [#&#8203;16782](https://redirect.github.com/nestjs/nest/pull/16782)
fix(core): use strict null check for SSE message id
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- `microservices`
- [#&#8203;16850](https://redirect.github.com/nestjs/nest/pull/16850)
fix(microservices): ServerRMQ crashes at boot when
[@&#8203;MessagePattern](https://redirect.github.com/MessagePattern)(undefined)
is combined with wildcards: true
([@&#8203;lavieennoir](https://redirect.github.com/lavieennoir))
- `common`
- [#&#8203;16845](https://redirect.github.com/nestjs/nest/pull/16845)
fix(common): accept zero timestamp in parse date pipe
([@&#8203;Mysh3ll](https://redirect.github.com/Mysh3ll))
- `platform-socket.io`
- [#&#8203;16742](https://redirect.github.com/nestjs/nest/pull/16742)
fix(socket.io): Deduplicate disconnect listener in bindMessageHandlers
([@&#8203;fru1tworld](https://redirect.github.com/fru1tworld))

##### Enhancements

- `microservices`
- [#&#8203;16676](https://redirect.github.com/nestjs/nest/pull/16676)
feat(microservices): add return buffers option for binary data
([@&#8203;Forceres](https://redirect.github.com/Forceres))
- [#&#8203;16826](https://redirect.github.com/nestjs/nest/pull/16826)
feat(microservices): handle rmq blocked/unblocked connection events
([@&#8203;thisalihassan](https://redirect.github.com/thisalihassan))
- `common`
- [#&#8203;16902](https://redirect.github.com/nestjs/nest/pull/16902)
fix(common): filetype validator buffer message
([@&#8203;QusaiAlbonni](https://redirect.github.com/QusaiAlbonni))
- `platform-express`
- [#&#8203;16844](https://redirect.github.com/nestjs/nest/pull/16844)
feat(platform-express): add defParamCharset to MulterOptions
([@&#8203;starnayuta](https://redirect.github.com/starnayuta))

##### Dependencies

- `platform-ws`
- [#&#8203;16941](https://redirect.github.com/nestjs/nest/pull/16941)
chore(deps): bump ws from 8.20.0 to 8.20.1
([@&#8203;dependabot\[bot\]](https://redirect.github.com/apps/dependabot))

##### Committers: 13

- Ali Hassan
([@&#8203;thisalihassan](https://redirect.github.com/thisalihassan))
- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Dmytro Khyzhniak
([@&#8203;lavieennoir](https://redirect.github.com/lavieennoir))
- Harsh Rathod
([@&#8203;harshrathod50](https://redirect.github.com/harshrathod50))
- IlyaCredo ([@&#8203;Forceres](https://redirect.github.com/Forceres))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- Mysh3ll ([@&#8203;Mysh3ll](https://redirect.github.com/Mysh3ll))
- [@&#8203;MatthiasBrehmer](https://redirect.github.com/MatthiasBrehmer)
- [@&#8203;QusaiAlbonni](https://redirect.github.com/QusaiAlbonni)
- [@&#8203;jkalberer](https://redirect.github.com/jkalberer)
- [@&#8203;pazaderey](https://redirect.github.com/pazaderey)
- fru1tworld
([@&#8203;fru1tworld](https://redirect.github.com/fru1tworld))
- starnayuta
([@&#8203;starnayuta](https://redirect.github.com/starnayuta))

###
[`v11.1.19`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.19)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.18...v11.1.19)

#### v11.1.19 (2026-04-13)

##### Bug fixes

- `microservices`
- [#&#8203;16762](https://redirect.github.com/nestjs/nest/pull/16762)
fix(microservices): use backing field for consumer CRASH event listener
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- [#&#8203;16764](https://redirect.github.com/nestjs/nest/pull/16764)
fix(microservices): prevent stack overflow in jsonsocket.handledata()
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Committers: 2

- Burhan Haroon
([@&#8203;burhanharoon](https://redirect.github.com/burhanharoon))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

</details>

<details>
<summary>nestjs/schedule (@&#8203;nestjs/schedule)</summary>

###
[`v6.1.3`](https://redirect.github.com/nestjs/schedule/releases/tag/6.1.3)

[Compare
Source](https://redirect.github.com/nestjs/schedule/compare/6.1.2...6.1.3)

#### What's Changed

- feat(cron): add initialDelay option to defer first job execution by
[@&#8203;kyungseopk1m](https://redirect.github.com/kyungseopk1m) in
[#&#8203;2251](https://redirect.github.com/nestjs/schedule/pull/2251)

**Full Changelog**:
<https://github.com/nestjs/schedule/compare/6.1.2...6.1.3>

###
[`v6.1.2`](https://redirect.github.com/nestjs/schedule/releases/tag/6.1.2)

[Compare
Source](https://redirect.github.com/nestjs/schedule/compare/6.1.1...6.1.2)

- Merge pull request
[#&#8203;2247](https://redirect.github.com/nestjs/schedule/issues/2247)
from kyungseopk1m/feat/cron-initial-delay
([`a57ce2c`](https://redirect.github.com/nestjs/schedule/commit/a57ce2c))
- chore(deps): update dependency prettier to v3.8.3
([#&#8203;2248](https://redirect.github.com/nestjs/schedule/issues/2248))
([`bb3490d`](https://redirect.github.com/nestjs/schedule/commit/bb3490d))
- feat(cron): add initialDelay option to defer first job execution
([`1c5677f`](https://redirect.github.com/nestjs/schedule/commit/1c5677f))
- Merge pull request
[#&#8203;2245](https://redirect.github.com/nestjs/schedule/issues/2245)
from nestjs/renovate/nest-monorepo
([`59046bd`](https://redirect.github.com/nestjs/schedule/commit/59046bd))
- Merge pull request
[#&#8203;2246](https://redirect.github.com/nestjs/schedule/issues/2246)
from nestjs/renovate/oxlint-monorepo
([`be4eee3`](https://redirect.github.com/nestjs/schedule/commit/be4eee3))
- chore(deps): update dependency oxlint to v1.60.0
([`32a9ce2`](https://redirect.github.com/nestjs/schedule/commit/32a9ce2))
- chore(deps): update nest monorepo to v11.1.19
([`7d3844f`](https://redirect.github.com/nestjs/schedule/commit/7d3844f))
- chore: migrate to oxlint, vitest, ts6
([`29de71b`](https://redirect.github.com/nestjs/schedule/commit/29de71b))
- chore(deps): update dependency globals to v17.5.0
([#&#8203;2244](https://redirect.github.com/nestjs/schedule/issues/2244))
([`6c62cca`](https://redirect.github.com/nestjs/schedule/commit/6c62cca))
- chore(deps): update dependency sinon to v21.1.2
([#&#8203;2243](https://redirect.github.com/nestjs/schedule/issues/2243))
([`ee3b31a`](https://redirect.github.com/nestjs/schedule/commit/ee3b31a))
- chore(deps): update dependency sinon to v21.1.1
([#&#8203;2241](https://redirect.github.com/nestjs/schedule/issues/2241))
([`eba9799`](https://redirect.github.com/nestjs/schedule/commit/eba9799))
- Merge pull request
[#&#8203;2242](https://redirect.github.com/nestjs/schedule/issues/2242)
from nestjs/renovate/prettier-3.x
([`c3ad0f7`](https://redirect.github.com/nestjs/schedule/commit/c3ad0f7))
- chore(deps): update dependency prettier to v3.8.2
([`798e2a9`](https://redirect.github.com/nestjs/schedule/commit/798e2a9))
- Merge pull request
[#&#8203;2199](https://redirect.github.com/nestjs/schedule/issues/2199)
from nestjs/renovate/cimg-node-24.x
([`a05354a`](https://redirect.github.com/nestjs/schedule/commit/a05354a))
- chore(deps): update dependency typescript-eslint to v8.58.1
([#&#8203;2240](https://redirect.github.com/nestjs/schedule/issues/2240))
([`0367ac1`](https://redirect.github.com/nestjs/schedule/commit/0367ac1))
- chore(deps): update dependency eslint to v10.2.0
([#&#8203;2239](https://redirect.github.com/nestjs/schedule/issues/2239))
([`fa93e06`](https://redirect.github.com/nestjs/schedule/commit/fa93e06))
- chore(deps): update nest monorepo to v11.1.18
([#&#8203;2238](https://redirect.github.com/nestjs/schedule/issues/2238))
([`8cd4c02`](https://redirect.github.com/nestjs/schedule/commit/8cd4c02))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to v24.12.2
([#&#8203;2237](https://redirect.github.com/nestjs/schedule/issues/2237))
([`01482df`](https://redirect.github.com/nestjs/schedule/commit/01482df))
- chore(deps): update dependency
[@&#8203;types/sinon](https://redirect.github.com/types/sinon) to
v21.0.1
([#&#8203;2236](https://redirect.github.com/nestjs/schedule/issues/2236))
([`f05b5bd`](https://redirect.github.com/nestjs/schedule/commit/f05b5bd))
- chore(deps): update dependency ts-jest to v29.4.9
([#&#8203;2235](https://redirect.github.com/nestjs/schedule/issues/2235))
([`af545e6`](https://redirect.github.com/nestjs/schedule/commit/af545e6))
- chore(deps): update dependency typescript-eslint to v8.58.0
([#&#8203;2233](https://redirect.github.com/nestjs/schedule/issues/2233))
([`4dad22a`](https://redirect.github.com/nestjs/schedule/commit/4dad22a))
- chore(deps): update node.js to v24.14.1
([`28db9bc`](https://redirect.github.com/nestjs/schedule/commit/28db9bc))
- chore(deps): update dependency eslint to v10.1.0
([#&#8203;2232](https://redirect.github.com/nestjs/schedule/issues/2232))
([`413f390`](https://redirect.github.com/nestjs/schedule/commit/413f390))
- chore(deps): update nest monorepo to v11.1.17
([#&#8203;2230](https://redirect.github.com/nestjs/schedule/issues/2230))
([`46c2bc5`](https://redirect.github.com/nestjs/schedule/commit/46c2bc5))
- chore(deps): update dependency typescript-eslint to v8.57.1
([#&#8203;2231](https://redirect.github.com/nestjs/schedule/issues/2231))
([`8fd063b`](https://redirect.github.com/nestjs/schedule/commit/8fd063b))
- chore(deps): update dependency sinon to v21.0.3
([#&#8203;2229](https://redirect.github.com/nestjs/schedule/issues/2229))
([`1671ad9`](https://redirect.github.com/nestjs/schedule/commit/1671ad9))
- chore(deps): update commitlint monorepo to v20.5.0
([#&#8203;2228](https://redirect.github.com/nestjs/schedule/issues/2228))
([`2ecd2f1`](https://redirect.github.com/nestjs/schedule/commit/2ecd2f1))
- chore(deps): update dependency lint-staged to v16.4.0
([#&#8203;2227](https://redirect.github.com/nestjs/schedule/issues/2227))
([`aa0de01`](https://redirect.github.com/nestjs/schedule/commit/aa0de01))
- chore(deps): update commitlint monorepo to v20.4.4
([#&#8203;2226](https://redirect.github.com/nestjs/schedule/issues/2226))
([`75034fe`](https://redirect.github.com/nestjs/schedule/commit/75034fe))
- chore(deps): update dependency lint-staged to v16.3.3
([#&#8203;2225](https://redirect.github.com/nestjs/schedule/issues/2225))
([`f1c7d31`](https://redirect.github.com/nestjs/schedule/commit/f1c7d31))
- chore(deps): update dependency jest to v30.3.0
([#&#8203;2224](https://redirect.github.com/nestjs/schedule/issues/2224))
([`1a208d4`](https://redirect.github.com/nestjs/schedule/commit/1a208d4))
- chore(deps): update dependency typescript-eslint to v8.57.0
([#&#8203;2223](https://redirect.github.com/nestjs/schedule/issues/2223))
([`60dd2c9`](https://redirect.github.com/nestjs/schedule/commit/60dd2c9))
- chore(deps): update dependency eslint to v10.0.3
([#&#8203;2221](https://redirect.github.com/nestjs/schedule/issues/2221))
([`791b6ba`](https://redirect.github.com/nestjs/schedule/commit/791b6ba))
- chore(deps): update dependency
[@&#8203;eslint/eslintrc](https://redirect.github.com/eslint/eslintrc)
to v3.3.5
([#&#8203;2220](https://redirect.github.com/nestjs/schedule/issues/2220))
([`0da1ca7`](https://redirect.github.com/nestjs/schedule/commit/0da1ca7))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to v24.12.0
([#&#8203;2219](https://redirect.github.com/nestjs/schedule/issues/2219))
([`934a93e`](https://redirect.github.com/nestjs/schedule/commit/934a93e))
- chore(deps): update nest monorepo to v11.1.16
([#&#8203;2218](https://redirect.github.com/nestjs/schedule/issues/2218))
([`5f44e9b`](https://redirect.github.com/nestjs/schedule/commit/5f44e9b))
- chore(deps): update dependency sinon to v21.0.2
([#&#8203;2217](https://redirect.github.com/nestjs/schedule/issues/2217))
([`b807746`](https://redirect.github.com/nestjs/schedule/commit/b807746))
- chore(deps): update dependency lint-staged to v16.3.2
([#&#8203;2216](https://redirect.github.com/nestjs/schedule/issues/2216))
([`4ca32bd`](https://redirect.github.com/nestjs/schedule/commit/4ca32bd))
- chore(deps): update commitlint monorepo to v20.4.3
([#&#8203;2215](https://redirect.github.com/nestjs/schedule/issues/2215))
([`d3ceb76`](https://redirect.github.com/nestjs/schedule/commit/d3ceb76))
- chore(deps): update nest monorepo to v11.1.15
([#&#8203;2214](https://redirect.github.com/nestjs/schedule/issues/2214))
([`b084ffc`](https://redirect.github.com/nestjs/schedule/commit/b084ffc))
- chore(deps): update dependency lint-staged to v16.3.1
([#&#8203;2213](https://redirect.github.com/nestjs/schedule/issues/2213))
([`8a201b2`](https://redirect.github.com/nestjs/schedule/commit/8a201b2))
- chore(deps): update dependency globals to v17.4.0
([#&#8203;2212](https://redirect.github.com/nestjs/schedule/issues/2212))
([`6f61793`](https://redirect.github.com/nestjs/schedule/commit/6f61793))
- chore(deps): update dependency lint-staged to v16.3.0
([#&#8203;2211](https://redirect.github.com/nestjs/schedule/issues/2211))
([`aa9213a`](https://redirect.github.com/nestjs/schedule/commit/aa9213a))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to v24.11.0
([#&#8203;2210](https://redirect.github.com/nestjs/schedule/issues/2210))
([`c70b928`](https://redirect.github.com/nestjs/schedule/commit/c70b928))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.15
([#&#8203;2209](https://redirect.github.com/nestjs/schedule/issues/2209))
([`0f596b9`](https://redirect.github.com/nestjs/schedule/commit/0f596b9))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.14
([#&#8203;2208](https://redirect.github.com/nestjs/schedule/issues/2208))
([`dac8cca`](https://redirect.github.com/nestjs/schedule/commit/dac8cca))
- chore(deps): update dependency eslint to v10.0.2
([#&#8203;2207](https://redirect.github.com/nestjs/schedule/issues/2207))
([`abe6fce`](https://redirect.github.com/nestjs/schedule/commit/abe6fce))
- chore(deps): update dependency
[@&#8203;eslint/eslintrc](https://redirect.github.com/eslint/eslintrc)
to v3.3.4
([#&#8203;2206](https://redirect.github.com/nestjs/schedule/issues/2206))
([`cb32a40`](https://redirect.github.com/nestjs/schedule/commit/cb32a40))
- chore(deps): update dependency typescript-eslint to v8.56.1
([#&#8203;2205](https://redirect.github.com/nestjs/schedule/issues/2205))
([`88e1e6c`](https://redirect.github.com/nestjs/schedule/commit/88e1e6c))
- chore(deps): update dependency eslint to v10.0.1
([#&#8203;2204](https://redirect.github.com/nestjs/schedule/issues/2204))
([`55e5406`](https://redirect.github.com/nestjs/schedule/commit/55e5406))
- chore(deps): update commitlint monorepo to v20.4.2
([#&#8203;2203](https://redirect.github.com/nestjs/schedule/issues/2203))
([`4e55d62`](https://redirect.github.com/nestjs/schedule/commit/4e55d62))
- chore(deps): update nest monorepo to v11.1.14
([#&#8203;2202](https://redirect.github.com/nestjs/schedule/issues/2202))
([`d23ea1a`](https://redirect.github.com/nestjs/schedule/commit/d23ea1a))
- chore(deps): update eslint monorepo to v10
([#&#8203;2195](https://redirect.github.com/nestjs/schedule/issues/2195))
([`c2fcbc3`](https://redirect.github.com/nestjs/schedule/commit/c2fcbc3))
- chore(deps): update dependency typescript-eslint to v8.56.0
([#&#8203;2201](https://redirect.github.com/nestjs/schedule/issues/2201))
([`a93ebc4`](https://redirect.github.com/nestjs/schedule/commit/a93ebc4))
- chore(deps): update dependency rimraf to v6.1.3
([#&#8203;2200](https://redirect.github.com/nestjs/schedule/issues/2200))
([`1906e80`](https://redirect.github.com/nestjs/schedule/commit/1906e80))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.13
([#&#8203;2198](https://redirect.github.com/nestjs/schedule/issues/2198))
([`244cb84`](https://redirect.github.com/nestjs/schedule/commit/244cb84))
- chore(deps): update dependency typescript-eslint to v8.55.0
([#&#8203;2197](https://redirect.github.com/nestjs/schedule/issues/2197))
([`6b00083`](https://redirect.github.com/nestjs/schedule/commit/6b00083))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.12
([#&#8203;2196](https://redirect.github.com/nestjs/schedule/issues/2196))
([`b310c95`](https://redirect.github.com/nestjs/schedule/commit/b310c95))
- chore(deps): update dependency
[@&#8203;types/node](https://redirect.github.com/types/node) to
v24.10.11
([#&#8203;2194](https://redirect.github.com/nestjs/schedule/issues/2194))
([`d05dca5`](https://redirect.github.com/nestjs/schedule/commit/d05dca5))

</details>

<details>
<summary>nestjs/swagger (@&#8203;nestjs/swagger)</summary>

###
[`v11.4.2`](https://redirect.github.com/nestjs/swagger/compare/11.4.1...b0a35f3b20bedc6e6756f476cee182700a199b6e)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.4.1...11.4.2)

###
[`v11.4.1`](https://redirect.github.com/nestjs/swagger/compare/11.4.0...14bd8f58d6011a1be03e266e39e472be0d4d3795)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.4.0...11.4.1)

###
[`v11.4.0`](https://redirect.github.com/nestjs/swagger/releases/tag/11.4.0)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.3.2...11.4.0)

#### 11.4.0 (2026-04-22)

##### Features

- [#&#8203;3868](https://redirect.github.com/nestjs/swagger/pull/3868)
feat(plugin): auto-mark optional
[@&#8203;Query](https://redirect.github.com/Query) parameters as
required: false
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3725](https://redirect.github.com/nestjs/swagger/pull/3725)
feat(swagger): add OpenAPI 3.2 hierarchical tags support
([@&#8203;apt-bh](https://redirect.github.com/apt-bh))

##### Bug fixes

- [#&#8203;3874](https://redirect.github.com/nestjs/swagger/pull/3874)
fix(document-builder): accept multi-digit OpenAPI version segments
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3873](https://redirect.github.com/nestjs/swagger/pull/3873)
fix(plugin): strip regex delimiters and flags from
[@&#8203;Matches](https://redirect.github.com/Matches) patterns
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3870](https://redirect.github.com/nestjs/swagger/pull/3870)
fix(decorators): forward all OpenAPI parameter fields in
[@&#8203;ApiHeader](https://redirect.github.com/ApiHeader)
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3872](https://redirect.github.com/nestjs/swagger/pull/3872)
fix(plugin): emit [@&#8203;throws](https://redirect.github.com/throws)
descriptions as proper string literals
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [#&#8203;3782](https://redirect.github.com/nestjs/swagger/pull/3782)
fix(schema): preserve example metadata for non-body params with named
types ([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3761](https://redirect.github.com/nestjs/swagger/pull/3761)
fix(plugin): support boolean literal types and boolean enum values
([@&#8203;lucreiss](https://redirect.github.com/lucreiss))

##### Enhancements

- [#&#8203;3865](https://redirect.github.com/nestjs/swagger/pull/3865)
feat(schema-object-factory): include class name chain in circular
dependency errors
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))

##### Committers: 4

- Lu R A ([@&#8203;lucreiss](https://redirect.github.com/lucreiss))
- Maruthan G
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- Yogeshwaran C
([@&#8203;yogeshwaran-c](https://redirect.github.com/yogeshwaran-c))
- [@&#8203;apt-bh](https://redirect.github.com/apt-bh)

###
[`v11.3.2`](https://redirect.github.com/nestjs/swagger/compare/11.3.1...b16a1e19a8b7161e13c01c636acf3a187eabbd06)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.3.1...11.3.2)

###
[`v11.3.1`](https://redirect.github.com/nestjs/swagger/compare/11.3.0...93744af0bb923daeebcc2b674bc7957d778d3953)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.3.0...11.3.1)

###
[`v11.3.0`](https://redirect.github.com/nestjs/swagger/releases/tag/11.3.0)

[Compare
Source](https://redirect.github.com/nestjs/swagger/compare/11.2.7...11.3.0)

#### 11.3.0 (2026-04-15)

##### Bug fixes

- [#&#8203;3826](https://redirect.github.com/nestjs/swagger/pull/3826)
fix: support nullable field in
[@&#8203;ApiResponse](https://redirect.github.com/ApiResponse) decorator
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3784](https://redirect.github.com/nestjs/swagger/pull/3784)
fix(schema): include type field when nullable is used with allOf
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3774](https://redirect.github.com/nestjs/swagger/pull/3774)
fix enum issue
([@&#8203;SupunGeethanjana](https://redirect.github.com/SupunGeethanjana))
- [#&#8203;3798](https://redirect.github.com/nestjs/swagger/pull/3798)
fix(plugin): normalize workspace package import paths in metadata
generator ([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3821](https://redirect.github.com/nestjs/swagger/pull/3821)
fix(plugin): handle same-file type references in SWC readonly metadata
generation ([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3822](https://redirect.github.com/nestjs/swagger/pull/3822)
fix(type-helpers): eagerly apply plugin metadata properties in mapped
type helpers
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- [#&#8203;3840](https://redirect.github.com/nestjs/swagger/pull/3840)
fix: use child class type when re-declaring an inherited
[@&#8203;ApiProperty](https://redirect.github.com/ApiProperty)
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))

##### Enhancements

- [#&#8203;3449](https://redirect.github.com/nestjs/swagger/pull/3449)
feat(api-header): add example property to ApiHeader decorator
([@&#8203;leemhoon00](https://redirect.github.com/leemhoon00))
- [#&#8203;3787](https://redirect.github.com/nestjs/swagger/pull/3787)
feat(decorators): support RegExp instances in
[@&#8203;ApiProperty](https://redirect.github.com/ApiProperty)({ pattern
}) ([@&#8203;temrjan](https://redirect.github.com/temrjan))
- [#&#8203;3699](https://redirect.github.com/nestjs/swagger/pull/3699)
feat(api-body): add support for encoding in ApiBody decorator
([@&#8203;lamuertepeluda](https://redirect.github.com/lamuertepeluda))
- [#&#8203;3824](https://redirect.github.com/nestjs/swagger/pull/3824)
feat: support async patchDocumentOnRequest hook
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3834](https://redirect.github.com/nestjs/swagger/pull/3834)
feat: expose generateSchema utility for programmatic schema access
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3836](https://redirect.github.com/nestjs/swagger/pull/3836)
feat(plugin): add autoFillEnumName option to suppress duplicate enum
schemas
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3837](https://redirect.github.com/nestjs/swagger/pull/3837)
feat: merge descriptions when multiple decorators share the same HTTP
status code
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3839](https://redirect.github.com/nestjs/swagger/pull/3839)
feat: add excludeDynamicDefaults option to strip runtime-evaluated
schema defaults
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))
- [#&#8203;3841](https://redirect.github.com/nestjs/swagger/pull/3841)
feat: add DeepPartialType mapped-type helper for recursive optional
properties
([@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M))

##### Dependencies

- [#&#8203;3850](https://redirect.github.com/nestjs/swagger/pull/3850)
fix(deps): update dependency swagger-ui-dist to v5.32.4
([@&#8203;renovate\[bot\]](https://redirect.github.com/apps/renovate))

##### Committers: 7

- JongHun Lim
([@&#8203;leemhoon00](https://redirect.github.com/leemhoon00))
- Maruthan G
([@&#8203;maruthang](https://redirect.github.com/maruthang))
- Rajasekar Janakiraman
([@&#8203;rajasekar33](https://redirect.github.com/rajasekar33))
- Supun Geethanjana Jayasinghe
([@&#8203;SupunGeethanjana](https://redirect.github.com/SupunGeethanjana))
- Temrjan ([@&#8203;temrjan](https://redirect.github.com/temrjan))
- Vito Macchia
([@&#8203;lamuertepeluda](https://redirect.github.com/lamuertepeluda))
-
[@&#8203;Nedunchezhiyan-M](https://redirect.github.com/Nedunchezhiyan-M)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNTYuMSIsInVwZGF0ZWRJblZlciI6IjQzLjE3My42IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 01:25:23 +08:00
DarkSky 659072183c chore: bump deps 2026-05-13 22:26:02 +08:00
DarkSky e222f06e94 feat(editor): extract chat runtime (#14937)
#### PR Dependency Tree


* **PR #14937** 👈

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 AI event system and a runtime powering chat sessions and
actions.

* **Improvements**
* Chat UI (composer, messages, toolbar, tabs, panels) now syncs with
runtime snapshots for more consistent state.
* Improved session/tab lifecycle (create, fork, delete), context
embedding status, and history handling.
* More reliable send/stop/retry flows, better telemetry scoping, and
clearer upgrade/login/insert-template prompts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-13 21:57:50 +08:00
DarkSky 322f2ba986 fix(server): migrate old tables (#14954) 2026-05-13 21:57:28 +08:00
renovate[bot] f19a922793 chore: bump up @opentelemetry/sdk-node version to ^0.217.0 [SECURITY] (#14945)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@opentelemetry/sdk-node](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-sdk-node)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`^0.215.0` →
`^0.217.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsdk-node/0.215.0/0.217.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fsdk-node/0.217.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fsdk-node/0.215.0/0.217.0?slim=true)
|

---

### Prometheus exporter process crash via malformed HTTP request
[CVE-2026-44902](https://nvd.nist.gov/vuln/detail/CVE-2026-44902) /
[GHSA-q7rr-3cgh-j5r3](https://redirect.github.com/advisories/GHSA-q7rr-3cgh-j5r3)

<details>
<summary>More information</summary>

#### Details
##### Summary

A single malformed HTTP request crashes any Node.js process running the
OpenTelemetry JS Prometheus exporter. The metrics endpoint (default
`0.0.0.0:9464`) has no error handling around URL parsing, so a request
with an invalid URI causes an uncaught `TypeError` that terminates the
process.

**You are affected by this vulnerability if either of the following
apply to your application:**

* you directly use `@opentelemetry/exporter-prometheus` in your code
through its built-in server.
* your `OTEL_METRICS_EXPORTER` environment variable includes
`prometheus` **AND**
  * you use `@opentelemetry/sdk-node`
* you use `@opentelemetry/auto-instrumentations-node` via `--require
@&#8203;opentelemetry/auto-instrumentations-node/register`/`--import
@&#8203;opentelemetry/auto-instrumentations-node/register`

##### Impact

**Denial of service.** Any application using the OpenTelemetry
Prometheus exporter’s built-in server can be crashed by a single
unauthenticated network packet sent to the metrics port. No
authentication, special privileges, or prior access is required.

##### Remediation

##### Update to the fixed version

Update `@opentelemetry/exporter-prometheus` and
`@opentelemetry/sdk-node` to version **0.217.0** or later.
Update `@opentelemetry/auto-instrumentations-node` to version **0.75.0**
or later.

This release adds proper error handling around the URL constructor,
returning an HTTP `400` response on parse failure rather than allowing
the exception to propagate and crash the process.

```
npm install @&#8203;opentelemetry/exporter-prometheus@latest
```

##### Do Not Expose the Endpoint to Untrusted Users

> [!IMPORTANT] 
> The following mitigations reduce exposure but do not fully remediate
the vulnerability. Any client that *can* reach the metrics endpoint -
including your own Prometheus scraper host if compromised - could still
trigger the crash. Updating to **0.217.0** is the recommended
resolution.

If updating is not immediately feasible, restrict access to the metrics
endpoint so that it is not reachable by untrusted or unauthenticated
network clients. For example:

* **Bind to localhost only** by setting the `host` option to `127.0.0.1`
when configuring the `PrometheusExporter`, so the port is not exposed on
public or shared network interfaces

* **Use a firewall or network policy** to restrict access to port `9464`
(or whichever port you have configured) to only trusted Prometheus
scrape hosts

* **Place the endpoint behind a reverse proxy** that filters or
validates incoming requests before they reach the exporter

##### Details

In `PrometheusExporter.ts`, the `_requestHandler` calls `new
URL(request.url, this._baseUrl)` without any error handling. Node's HTTP
parser accepts absolute-form URIs (e.g. `http://`) for proxy
compatibility, including malformed ones. When `request.url` is
`"http://"`, the `URL` constructor throws `TypeError: Invalid URL`.
Since there is no try-catch in the handler, the exception propagates as
an uncaught exception and crashes the process.

The Prometheus metrics endpoint is unauthenticated by design (Prometheus
scrapes it) and binds to `0.0.0.0` by default, meaning it is reachable
by any network client that can connect to the metrics port.

##### Proof of Concept

Start any Node.js application with the Prometheus exporter running on
the default port `9464`, then send a single raw TCP packet:

```
echo -ne 'GET http:// HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 9464
```

The process crashes immediately with:

```
TypeError: Invalid URL
    at new URL (...)
    at PrometheusExporter._requestHandler (...)
```

#### Severity
- CVSS Score: 7.5 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H`

#### References
-
[https://github.com/open-telemetry/opentelemetry-js/security/advisories/GHSA-q7rr-3cgh-j5r3](https://redirect.github.com/open-telemetry/opentelemetry-js/security/advisories/GHSA-q7rr-3cgh-j5r3)
-
[https://github.com/advisories/GHSA-q7rr-3cgh-j5r3](https://redirect.github.com/advisories/GHSA-q7rr-3cgh-j5r3)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-q7rr-3cgh-j5r3)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js
(@&#8203;opentelemetry/sdk-node)</summary>

###
[`v0.217.0`](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7...74cde1b674508ccc0ed2601ac43a80ff2d35114c)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7...74cde1b674508ccc0ed2601ac43a80ff2d35114c)

###
[`v0.216.0`](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/a0476eef3cb973bfcc0c2e41f868dd7b484c2ed8...2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/a0476eef3cb973bfcc0c2e41f868dd7b484c2ed8...2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 18:55:30 +08:00
DarkSky a1d150a748 fix(server): realtime module not loaded (#14952)
#### PR Dependency Tree


* **PR #14952** 👈

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**
* Optimized workspace invite link fetching by separating it from general
workspace configuration queries for improved performance.
* Reorganized transcription-related backend modules to better separate
concerns and enable real-time functionality.

* **Chores**
* Updated generated GraphQL types and iOS query definitions to reflect
API changes.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14952)

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-12 18:54:42 +08:00
renovate[bot] ac6d0d35af chore: bump up @opentelemetry/exporter-prometheus version to ^0.217.0 [SECURITY] (#14944)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@opentelemetry/exporter-prometheus](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-exporter-prometheus)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`^0.215.0` →
`^0.217.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-prometheus/0.215.0/0.217.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fexporter-prometheus/0.217.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fexporter-prometheus/0.215.0/0.217.0?slim=true)
|

---

### Prometheus exporter process crash via malformed HTTP request
[CVE-2026-44902](https://nvd.nist.gov/vuln/detail/CVE-2026-44902) /
[GHSA-q7rr-3cgh-j5r3](https://redirect.github.com/advisories/GHSA-q7rr-3cgh-j5r3)

<details>
<summary>More information</summary>

#### Details
##### Summary

A single malformed HTTP request crashes any Node.js process running the
OpenTelemetry JS Prometheus exporter. The metrics endpoint (default
`0.0.0.0:9464`) has no error handling around URL parsing, so a request
with an invalid URI causes an uncaught `TypeError` that terminates the
process.

**You are affected by this vulnerability if either of the following
apply to your application:**

* you directly use `@opentelemetry/exporter-prometheus` in your code
through its built-in server.
* your `OTEL_METRICS_EXPORTER` environment variable includes
`prometheus` **AND**
  * you use `@opentelemetry/sdk-node`
* you use `@opentelemetry/auto-instrumentations-node` via `--require
@&#8203;opentelemetry/auto-instrumentations-node/register`/`--import
@&#8203;opentelemetry/auto-instrumentations-node/register`

##### Impact

**Denial of service.** Any application using the OpenTelemetry
Prometheus exporter’s built-in server can be crashed by a single
unauthenticated network packet sent to the metrics port. No
authentication, special privileges, or prior access is required.

##### Remediation

##### Update to the fixed version

Update `@opentelemetry/exporter-prometheus` and
`@opentelemetry/sdk-node` to version **0.217.0** or later.
Update `@opentelemetry/auto-instrumentations-node` to version **0.75.0**
or later.

This release adds proper error handling around the URL constructor,
returning an HTTP `400` response on parse failure rather than allowing
the exception to propagate and crash the process.

```
npm install @&#8203;opentelemetry/exporter-prometheus@latest
```

##### Do Not Expose the Endpoint to Untrusted Users

> [!IMPORTANT] 
> The following mitigations reduce exposure but do not fully remediate
the vulnerability. Any client that *can* reach the metrics endpoint -
including your own Prometheus scraper host if compromised - could still
trigger the crash. Updating to **0.217.0** is the recommended
resolution.

If updating is not immediately feasible, restrict access to the metrics
endpoint so that it is not reachable by untrusted or unauthenticated
network clients. For example:

* **Bind to localhost only** by setting the `host` option to `127.0.0.1`
when configuring the `PrometheusExporter`, so the port is not exposed on
public or shared network interfaces

* **Use a firewall or network policy** to restrict access to port `9464`
(or whichever port you have configured) to only trusted Prometheus
scrape hosts

* **Place the endpoint behind a reverse proxy** that filters or
validates incoming requests before they reach the exporter

##### Details

In `PrometheusExporter.ts`, the `_requestHandler` calls `new
URL(request.url, this._baseUrl)` without any error handling. Node's HTTP
parser accepts absolute-form URIs (e.g. `http://`) for proxy
compatibility, including malformed ones. When `request.url` is
`"http://"`, the `URL` constructor throws `TypeError: Invalid URL`.
Since there is no try-catch in the handler, the exception propagates as
an uncaught exception and crashes the process.

The Prometheus metrics endpoint is unauthenticated by design (Prometheus
scrapes it) and binds to `0.0.0.0` by default, meaning it is reachable
by any network client that can connect to the metrics port.

##### Proof of Concept

Start any Node.js application with the Prometheus exporter running on
the default port `9464`, then send a single raw TCP packet:

```
echo -ne 'GET http:// HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc localhost 9464
```

The process crashes immediately with:

```
TypeError: Invalid URL
    at new URL (...)
    at PrometheusExporter._requestHandler (...)
```

#### Severity
- CVSS Score: 7.5 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H`

#### References
-
[https://github.com/open-telemetry/opentelemetry-js/security/advisories/GHSA-q7rr-3cgh-j5r3](https://redirect.github.com/open-telemetry/opentelemetry-js/security/advisories/GHSA-q7rr-3cgh-j5r3)
-
[https://github.com/advisories/GHSA-q7rr-3cgh-j5r3](https://redirect.github.com/advisories/GHSA-q7rr-3cgh-j5r3)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-q7rr-3cgh-j5r3)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js
(@&#8203;opentelemetry/exporter-prometheus)</summary>

###
[`v0.217.0`](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7...74cde1b674508ccc0ed2601ac43a80ff2d35114c)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7...74cde1b674508ccc0ed2601ac43a80ff2d35114c)

###
[`v0.216.0`](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/a0476eef3cb973bfcc0c2e41f868dd7b484c2ed8...2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/a0476eef3cb973bfcc0c2e41f868dd7b484c2ed8...2400d8389a4469f7a81ccd3be2f0b2c2dd6faaf7)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 16:21:10 +08:00
renovate[bot] 6b720206c6 chore: bump up mermaid version to v11.15.0 [SECURITY] (#14946)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mermaid](https://redirect.github.com/mermaid-js/mermaid) | [`11.13.0`
→ `11.15.0`](https://renovatebot.com/diffs/npm/mermaid/11.13.0/11.15.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/mermaid/11.15.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mermaid/11.13.0/11.15.0?slim=true)
|

---

### Mermaid: Improper sanitization of `classDef` in state diagrams leads
to HTML injection
[CVE-2026-41149](https://nvd.nist.gov/vuln/detail/CVE-2026-41149) /
[GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)

<details>
<summary>More information</summary>

#### Details
##### Impact

Under the default configuration, Mermaid state diagram's `classDef`
allow DOM injection that escapes the SVG, although `<script>` tags are
removed, preventing XSS.

##### Proof-of-concept

```
stateDiagram-v2
  classDef xss fill:red</style></svg><style>*{x:x;y:y;overflow:visible!important;contain:none!important;transform:none!important;filter:none!important;clip-path:none!important}</style><div style="x:x;y:y;color:red;font:5em/1 monospace;display:grid;place-items:center;z-index:2147483647;width:100vw;height:100vh;position:fixed;top:0;left:0;background:black">HACKED</div><svg><style>a:b
  [*] --> A:::xss
```

##### Patches

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[37ff937f1da2e19f882fd1db01235db4d01f4056](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3](https://redirect.github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3))

##### Workarounds

If you can not update to a patched version, setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will prevent this, by rendering the mermaid diagram in a sandboxed
`<iframe>`.

##### Credits

Thanks to @&#8203;zsxsoft from @&#8203;KeenSecurityLab for reporting
this vulnerability.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-ghcm-xqfw-q4vr)
-
[https://github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056)
-
[https://github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3](https://redirect.github.com/mermaid-js/mermaid/commit/4e2d512bf5bf6f9de1a8f0a48da78dc4d09ac4f3)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://mermaid.js.org/config/schema-docs/config.html#securitylevel](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
-
[https://github.com/advisories/GHSA-ghcm-xqfw-q4vr](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-ghcm-xqfw-q4vr)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of `classDefs` in diagrams leads to
CSS injection
[CVE-2026-41148](https://nvd.nist.gov/vuln/detail/CVE-2026-41148) /
[GHSA-xcj9-5m2h-648r](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)

<details>
<summary>More information</summary>

#### Details
##### Details

The state diagram and any other diagram type that routes user-controlled
style strings through createCssStyles parser for Mermaid v11.14.0 and
earlier captures `classDef` values with an unrestricted regex:

```jison
// packages/mermaid/src/diagrams/state/parser/stateDiagram.jison:83
<CLASSDEFID>[^\n]*   { this.popState(); return 'CLASSDEF_STYLEOPTS' }
```

The value passes unsanitized through `addStyleClass()` ->
`createCssStyles()` -> `style.innerHTML` (mermaidAPI.ts:418). A `}` in
the value closes the generated CSS selector, and everything after
becomes a new CSS rule on the page.

##### PoC

```
stateDiagram-v2 
      classDef x }*{ background-image: url("http://media.giphy.com/media/SggILpMXO7Xt6/giphy.gif")}
```

Live demo:

<https://mermaid.live/edit#pako:eNpFjzFvgzAQhf-KdVNbEcBgMHhtlkqtOnSJKi8ONsYKBmRMlRTx3-skanvTfbp7996t0IxSAYPZC6_2Rmgn7O4rQ00v5nmvWnRG29OKjqI5aTcug9wZK7RiaHH9A4fO-4kliVXSiFibqbvEzWjvnHxo_fI6vR3e6cGXyX2qTcvhcYMItDMSmHeLisAqZ8UVYeUDQhx8p6ziwEIrhTtx4MNVM4nhcxztrywE0h2wVvRzoGWS_z_8rahBKvcckntgmN5OAFvhDIzUNCZZQXCR5nVaZkUEF2BVFpOcEkoxxhUuyRbB980yjStapKHqoKFlhvPtB7BFZEU>

##### Patches

This has been patched in:

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[e9b0f34d8d82a6260077764ee45e1d7d90957a0f](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[8fead23c59166b7bab6a39eac81acebee2859102](https://redirect.github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102))

##### Workarounds

Setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will prevent this, by rendering the mermaid diagram in a sandboxed
`<iframe>`.

##### Impact

Enables page defacement, user tracking via `url()` callbacks, and DOM
attribute exfiltration via CSS `:has()` selectors.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648r](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648r)
-
[https://github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102](https://redirect.github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102)
-
[https://github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://mermaid.js.org/config/schema-docs/config.html#securitylevel](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
-
[https://github.com/advisories/GHSA-xcj9-5m2h-648r](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-xcj9-5m2h-648r)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid: Improper sanitization of configuration leads to CSS
injection
[CVE-2026-41159](https://nvd.nist.gov/vuln/detail/CVE-2026-41159) /
[GHSA-87f9-hvmw-gh4p](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)

<details>
<summary>More information</summary>

#### Details
##### Impact

Mermaid's default configuration allows injecting CSS that applies
outside of the Mermaid diagram via the `fontFamily`, `themeCSS`, and
`altFontFamily` configuration options.

Live demo:
[mermaid.live](https://mermaid.live/edit#pako:eNpNjktLxDAUhf9KvFBR6JS-60QQfODKlUvJ5k6TtsEmKTHFGUP-u-mI6Nmdy3fOPR56wwVQSBIvtXSUeAaD0e4ZlZxPDChhcLxFfwiEauOuLq_9Afv30ZpVczpaITS5kGox1qF2gfSeBwYhJAnThAyz-ewntI68vG5-0z3Z7e7IA9OQwmglB-rsKlJQwircLPgNZeAmocTPAi4GXGfHgOkQYwvqN2PUbzJuGSegA84f0a0LRyeeJI4W_xChubCPcbQD2pwbgHo4Aq2aKmvbqq3zoiu7pizqFE6RybN9VFfFY1HWXRVS-Dr_zLObrt7_V_gGGXZlGg)

Example code:

```
%%{init: {"fontFamily": "x;a{b} :not(&){background:green !important} c{d}"}}%%
flowchart LR
    A --> B
```

The injected CSS exploits stylis's `&` (scope reference) handling.
`:not(&)` escapes the `#mermaid-xxx` automatic scoping, applying styles
to all page elements. Global at-rules (`@font-face`, `@keyframes`,
`@counter-style`) are also injectable as stylis hoists them to top
level.

This allows page defacement and DOM attribute exfiltration via CSS
`:has()` selectors.

##### Patches

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[64769738d5b59211e1decb471ffbaca8afec51aa](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[a9d9f0d8eb790349121508688cd338253fd80d76](https://redirect.github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76))

##### Workarounds

If you can't upgrade mermaid, you can set the
[`secure`](https://mermaid.js.org/config/schema-docs/config.html#secure)
config value in the mermaid config to avoid allowing diagrams to modify
`fontFamily`, `themeCSS`, `altFontFamily`, and `themeVariables`.

Setting [`"securityLevel":
"sandbox"`](https://mermaid.js.org/config/schema-docs/config.html#securitylevel)
will also prevent this.

##### Credits

Reported by @&#8203;zsxsoft on behalf of @&#8203;KeenSecurityLab

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-87f9-hvmw-gh4p](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-87f9-hvmw-gh4p)
-
[https://github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa)
-
[https://github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76](https://redirect.github.com/mermaid-js/mermaid/commit/a9d9f0d8eb790349121508688cd338253fd80d76)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://github.com/advisories/GHSA-87f9-hvmw-gh4p](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-87f9-hvmw-gh4p)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Mermaid Gantt Charts are vulnerable to an Infinite Loop DoS
[CVE-2026-41150](https://nvd.nist.gov/vuln/detail/CVE-2026-41150) /
[GHSA-6m6c-36f7-fhxh](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)

<details>
<summary>More information</summary>

#### Details
##### Impact

Mermaid v11.14.0 and earlier are vulnerable to a denial-of-service
attack when rendering gantt charts, if they use the [`excludes`
attribute](https://mermaid.js.org/syntax/gantt.html?#excludes) to
exclude all dates.

Example:

```
gantt
  excludes monday,tuesday,wednesday,thursday,friday,saturday,sunday
  DoS :2025-01-01, 1d
```

`mermaid.parse` is unaffected, unless you then call the
`ganttDb.getTasks()` (which is called when rendering a diagram).

##### Patches

This has been patched in:

-
[v11.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
(see
[faafb5d49106dd32c367f3882505f2dd625aa30e](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e))
-
[v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
(see
[a59ea56174712ee5430dfd5bc877cb5151f501a6](https://redirect.github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6))

##### Workarounds

There are no workarounds available without updating to a newer version
of mermaid.

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L`

#### References
-
[https://github.com/mermaid-js/mermaid/security/advisories/GHSA-6m6c-36f7-fhxh](https://redirect.github.com/mermaid-js/mermaid/security/advisories/GHSA-6m6c-36f7-fhxh)
-
[https://github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6](https://redirect.github.com/mermaid-js/mermaid/commit/a59ea56174712ee5430dfd5bc877cb5151f501a6)
-
[https://github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e)
-
[https://github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)
-
[https://github.com/mermaid-js/mermaid/releases/tag/v10.9.6](https://redirect.github.com/mermaid-js/mermaid/releases/tag/v10.9.6)
-
[https://github.com/advisories/GHSA-6m6c-36f7-fhxh](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-6m6c-36f7-fhxh)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>mermaid-js/mermaid (mermaid)</summary>

###
[`v11.15.0`](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0)

[Compare
Source](https://redirect.github.com/mermaid-js/mermaid/compare/mermaid@11.14.0...mermaid@11.15.0)

##### Minor Changes

-
[#&#8203;7174](https://redirect.github.com/mermaid-js/mermaid/pull/7174)
[`0aca217`](https://redirect.github.com/mermaid-js/mermaid/commit/0aca21739c0d1fcaaa206e04a6cd574ebc415483)
Thanks
[@&#8203;milesspencer35](https://redirect.github.com/milesspencer35)! -
feat(sequence): Add support for decimal start and increment values in
the `autonumber` directive

-
[#&#8203;7512](https://redirect.github.com/mermaid-js/mermaid/pull/7512)
[`8e17492`](https://redirect.github.com/mermaid-js/mermaid/commit/8e17492f7365ba50896382feb69a23efd9d8a22d)
Thanks [@&#8203;aruncveli](https://redirect.github.com/aruncveli)! -
feat(flowchart): add datastore shape

In Data flow diagrams, a datastore/warehouse/file/database is used to
represent data persistence. It is denoted by a rectangle with only top
and bottom borders, and can be used in flowcharts with `A@{ shape:
datastore, label: "Datastore" }`.

-
[#&#8203;6440](https://redirect.github.com/mermaid-js/mermaid/pull/6440)
[`9ad8dde`](https://redirect.github.com/mermaid-js/mermaid/commit/9ad8dde6d049adde85d8ed2d476c09b5820f3f4b)
Thanks [@&#8203;yordis](https://redirect.github.com/yordis),
[@&#8203;lgazo](https://redirect.github.com/lgazo)! - feat: add Event
Modeling diagram

-
[#&#8203;7707](https://redirect.github.com/mermaid-js/mermaid/pull/7707)
[`27db774`](https://redirect.github.com/mermaid-js/mermaid/commit/27db774627be1cee881961dfd0d2cb21cd01b79d)
Thanks [@&#8203;txmxthy](https://redirect.github.com/txmxthy)! -
feat(architecture): expose four fcose layout knobs for
`architecture-beta` diagrams (`nodeSeparation`,
`idealEdgeLengthMultiplier`, `edgeElasticity`, `numIter`) so authors can
tune layout density and spread overlapping siblings without changing
diagram source

-
[#&#8203;7604](https://redirect.github.com/mermaid-js/mermaid/pull/7604)
[`bf9502f`](https://redirect.github.com/mermaid-js/mermaid/commit/bf9502fb6012a4b724679b401ac928f5ee55161c)
Thanks [@&#8203;M-a-c](https://redirect.github.com/M-a-c)! -
feat(class): add nested namespace support for class diagrams via dot
notation and syntactic nesting

If you have namespaces in class diagrams that use `.`s already and want
to render them without nesting (≤v11.14.0 behaviour), you can use set
`class.hierarchicalNamespaces=false` in your mermaid config:

  ```yaml
  config:
    class:
      hierarchicalNamespaces: false
  ```

-
[#&#8203;7272](https://redirect.github.com/mermaid-js/mermaid/pull/7272)
[`88cdd3d`](https://redirect.github.com/mermaid-js/mermaid/commit/88cdd3dc0aab9577174561b04e14760c565a232b)
Thanks [@&#8203;xinbenlv](https://redirect.github.com/xinbenlv)! -
feat(sankey): add outlined label style, configurable
nodeWidth/nodePadding, and custom node colors

##### Patch Changes

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`e9b0f34`](https://redirect.github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0f)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: prevent unbalanced CSS styles in classDefs

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`37ff937`](https://redirect.github.com/mermaid-js/mermaid/commit/37ff937f1da2e19f882fd1db01235db4d01f4056)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: create CSS styles using the CSSOM

  This removes some invalid CSS and normalizes some CSS formatting.

-
[#&#8203;7508](https://redirect.github.com/mermaid-js/mermaid/pull/7508)
[`bfe60cc`](https://redirect.github.com/mermaid-js/mermaid/commit/bfe60cc67b9a6dec64f9161f58e4d24a06c42b65)
Thanks [@&#8203;biiab](https://redirect.github.com/biiab)! -
fix(stateDiagram): `end note` now only closes a note when used on a new
line

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`faafb5d`](https://redirect.github.com/mermaid-js/mermaid/commit/faafb5d49106dd32c367f3882505f2dd625aa30e)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix(gantt): add iteration limit for `excludes` field

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`65f8be2`](https://redirect.github.com/mermaid-js/mermaid/commit/65f8be2a42faf869b811469571983cba7eeeca99)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: disallow some CSS at-rules in custom CSS

-
[#&#8203;7726](https://redirect.github.com/mermaid-js/mermaid/pull/7726)
[`1502f32`](https://redirect.github.com/mermaid-js/mermaid/commit/1502f32f3c5fb944925b0c527fbbde3c4f041824)
Thanks [@&#8203;aloisklink](https://redirect.github.com/aloisklink)! -
fix(wardley): fix unnecessary sanitization of text

-
[#&#8203;7578](https://redirect.github.com/mermaid-js/mermaid/pull/7578)
[`1f98db8`](https://redirect.github.com/mermaid-js/mermaid/commit/1f98db8e326299ac97a2fa60abfd509d8f5f16e2)
Thanks [@&#8203;Gaston202](https://redirect.github.com/Gaston202)! -
fix(class): self-referential class multiplicity labels no longer
rendered multiple times

Fixes
[#&#8203;7560](https://redirect.github.com/mermaid-js/mermaid/issues/7560).
Resolves an issue where cardinality labels on self-referential class
relationships were rendered three times due to edge splitting in the
dagre layout. The fix ensures that each sub-edge only carries its
relevant label positions.

-
[#&#8203;7592](https://redirect.github.com/mermaid-js/mermaid/pull/7592)
[`2343e38`](https://redirect.github.com/mermaid-js/mermaid/commit/2343e38498a3b31f8ce5e79f1f009e0b56fbe086)
Thanks [@&#8203;knsv-bot](https://redirect.github.com/knsv-bot)! -
fix(sequence): add background box behind alt/else section title labels
in sequence diagrams

-
[#&#8203;7589](https://redirect.github.com/mermaid-js/mermaid/pull/7589)
[`7fb9509`](https://redirect.github.com/mermaid-js/mermaid/commit/7fb9509b8b5cb1dc48519dc60cf6cdc6afba0462)
Thanks [@&#8203;NYCU-Chung](https://redirect.github.com/NYCU-Chung)! -
fix(block): prevent column widths from shrinking when mixing different
column spans

-
[#&#8203;7632](https://redirect.github.com/mermaid-js/mermaid/pull/7632)
[`3f9e0f1`](https://redirect.github.com/mermaid-js/mermaid/commit/3f9e0f15bedc1e2c71ddb6b34192d1a21124cfc2)
Thanks [@&#8203;ekiauhce](https://redirect.github.com/ekiauhce)! -
fix(sequence): correct messageAlign label position for right-to-left
arrows in sequence diagrams

-
[#&#8203;7642](https://redirect.github.com/mermaid-js/mermaid/pull/7642)
[`7a8fb85`](https://redirect.github.com/mermaid-js/mermaid/commit/7a8fb8532c57ecc55b3711454ab0e505a4291445)
Thanks [@&#8203;tractorjuice](https://redirect.github.com/tractorjuice)!
- fix(wardley): allow hyphens in unquoted component names

Multi-word names containing hyphens — e.g. `real-time processing`,
`end-user`, `on-call engineer` — now parse without quoting, bringing the
grammar in line with the OnlineWardleyMaps (OWM) convention. `A->B`
(no-space arrow) still tokenises correctly.

-
[#&#8203;7523](https://redirect.github.com/mermaid-js/mermaid/pull/7523)
[`5144ed4`](https://redirect.github.com/mermaid-js/mermaid/commit/5144ed4b138ae0f4836bab4c163c575e0a767dd3)
Thanks [@&#8203;darshanr0107](https://redirect.github.com/darshanr0107)!
- fix(block): Arrow blocks in block-beta diagrams not spanning the
specified number of columns when using `:n` syntax.

-
[#&#8203;7262](https://redirect.github.com/mermaid-js/mermaid/pull/7262)
[`13d9bfa`](https://redirect.github.com/mermaid-js/mermaid/commit/13d9bfa4748e845a9eec7d6265ba496d2278f26e)
Thanks [@&#8203;darshanr0107](https://redirect.github.com/darshanr0107)!
- fix(block): Ensure block diagram hexagon blocks respect column
spanning syntax

-
[#&#8203;7684](https://redirect.github.com/mermaid-js/mermaid/pull/7684)
[`e14bb88`](https://redirect.github.com/mermaid-js/mermaid/commit/e14bb88bdb940124cdb0a107025653bf93745c99)
Thanks [@&#8203;aloisklink](https://redirect.github.com/aloisklink)! -
fix: loosen `uuid` dependency range to allow v14

  Mermaid does not use any of the vulnerable code in CVE-2026-41907,
  but this allows users to silence any `npm audit` alerts on it.

-
[#&#8203;7633](https://redirect.github.com/mermaid-js/mermaid/pull/7633)
[`9217c0d`](https://redirect.github.com/mermaid-js/mermaid/commit/9217c0d8b221b423af80e420b7adae901acf6c8c)
Thanks [@&#8203;Felix-Garci](https://redirect.github.com/Felix-Garci)! -
fix(block): add support for all arrow types in block diagrams

-
[#&#8203;7587](https://redirect.github.com/mermaid-js/mermaid/pull/7587)
[`5e7eb62`](https://redirect.github.com/mermaid-js/mermaid/commit/5e7eb62e3aba6b5df559f5c839a868e5b7f40e72)
Thanks
[@&#8203;MaddyGuthridge](https://redirect.github.com/MaddyGuthridge)! -
chore: drop lodash-es in favour of es-toolkit

-
[#&#8203;7693](https://redirect.github.com/mermaid-js/mermaid/pull/7693)
[`afaf306`](https://redirect.github.com/mermaid-js/mermaid/commit/afaf3062381d115d66744413151b642f124dd9ba)
Thanks [@&#8203;dull-bird](https://redirect.github.com/dull-bird)! -
fix(quadrant-chart): allow CJK, emoji, Latin-1 accented characters, and
other non-ASCII text in unquoted axis/quadrant/point labels.

Previously the lexer only matched ASCII `[A-Za-z]+` for text tokens,
even though the grammar referenced `UNICODE_TEXT`. Bare Chinese,
Japanese, Korean, emoji, and accented Latin characters in labels caused
a parse error. Added a `[^\x00-\x7F]+` lexer rule to emit `UNICODE_TEXT`
and included it in the `alphaNumToken` grammar rule.

Fixes
[#&#8203;7120](https://redirect.github.com/mermaid-js/mermaid/issues/7120).

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`4755553`](https://redirect.github.com/mermaid-js/mermaid/commit/4755553d5fb6d1217809e43ffb8fc54d6a73e482)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: improve D3 types for mermaidAPI funcs

-
[#&#8203;7737](https://redirect.github.com/mermaid-js/mermaid/pull/7737)
[`6476973`](https://redirect.github.com/mermaid-js/mermaid/commit/64769738d5b59211e1decb471ffbaca8afec51aa)
Thanks
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512)! -
fix: handle `&` when namespacing CSS rules

-
[#&#8203;7520](https://redirect.github.com/mermaid-js/mermaid/pull/7520)
[`8c1a0c1`](https://redirect.github.com/mermaid-js/mermaid/commit/8c1a0c1fd19587c6772d6966fe9d217e5cd1356c)
Thanks
[@&#8203;RodrigojndSantos](https://redirect.github.com/RodrigojndSantos)!
- fix(stateDiagram): comments starting with one `%` are no longer
treated as comments

  Switch to using two `%%` if you want to write a comment.

- Updated dependencies
\[[`7a8fb85`](https://redirect.github.com/mermaid-js/mermaid/commit/7a8fb8532c57ecc55b3711454ab0e505a4291445),
[`675a64c`](https://redirect.github.com/mermaid-js/mermaid/commit/675a64ca0e3cde8728ca715991623c3fc055ce88)]:
-
[@&#8203;mermaid-js/parser](https://redirect.github.com/mermaid-js/parser)@&#8203;1.1.1

###
[`v11.14.0`](https://redirect.github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.14.0)

[Compare
Source](https://redirect.github.com/mermaid-js/mermaid/compare/mermaid@11.13.0...mermaid@11.14.0)

Thanks to our awesome mermaid community that contributed to this
release:
[@&#8203;ashishjain0512](https://redirect.github.com/ashishjain0512),
[@&#8203;tractorjuice](https://redirect.github.com/tractorjuice),
[@&#8203;autofix-ci\[bot\]](https://redirect.github.com/autofix-ci%5Bbot%5D),
[@&#8203;aloisklink](https://redirect.github.com/aloisklink),
[@&#8203;knsv](https://redirect.github.com/knsv),
[@&#8203;kibanana](https://redirect.github.com/kibanana),
[@&#8203;chandershekhar22](https://redirect.github.com/chandershekhar22),
[@&#8203;khalil](https://redirect.github.com/khalil),
[@&#8203;ytatsuno](https://redirect.github.com/ytatsuno),
[@&#8203;sidharthv96](https://redirect.github.com/sidharthv96),
[@&#8203;github-actions\[bot\]](https://redirect.github.com/github-actions%5Bbot%5D),
[@&#8203;dripcoding](https://redirect.github.com/dripcoding),
[@&#8203;knsv-bot](https://redirect.github.com/knsv-bot),
[@&#8203;jeroensmink98](https://redirect.github.com/jeroensmink98),
[@&#8203;Alex9583](https://redirect.github.com/Alex9583),
[@&#8203;GhassenS](https://redirect.github.com/GhassenS),
[@&#8203;omkarht](https://redirect.github.com/omkarht),
[@&#8203;darshanr0107](https://redirect.github.com/darshanr0107),
[@&#8203;leentaylor](https://redirect.github.com/leentaylor),
[@&#8203;lee-treehouse](https://redirect.github.com/lee-treehouse),
[@&#8203;veeceey](https://redirect.github.com/veeceey),
[@&#8203;turntrout](https://redirect.github.com/turntrout),
[@&#8203;Mermaid-Chart](https://redirect.github.com/Mermaid-Chart),
[@&#8203;BambioGaming](https://redirect.github.com/BambioGaming), Claude

### Releases

####
[@&#8203;mermaid-js/examples](https://redirect.github.com/mermaid-js/examples)@&#8203;1.2.0

##### Minor Changes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- add new TreeView diagram

#### mermaid\@&#8203;11.14.0

##### Minor Changes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- Add Wardley Maps diagram type (beta)

Adds Wardley Maps as a new diagram type to Mermaid (available as
`wardley-beta`). Wardley Maps are visual representations of business
strategy that help map value chains and component evolution.

  Features:

- Component positioning with \[visibility, evolution] coordinates (OWM
format)
  - Anchors for users/customers
  - Multiple link types: dependencies, flows, labeled links
  - Evolution arrows and trend indicators
  - Custom evolution stages with optional dual labels
- Custom stage widths using
[@&#8203;boundary](https://redirect.github.com/boundary) notation
  - Pipeline components with visibility inheritance
  - Annotations, notes, and visual elements
  - Source strategy markers: build, buy, outsource, market
  - Inertia indicators
  - Theme integration

Implementation includes parser, D3.js renderer, unit tests, E2E tests,
and comprehensive documentation.

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for state diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look support for sequence diagrams with drop
shadows, and enhanced styling

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: add `randomize` config option for architecture diagrams,
defaulting to `false` for deterministic layout

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: Add option to change timeline direction

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- Fix duplicate SVG element IDs when rendering multiple diagrams on the
same page. Internal element IDs (nodes, edges, markers, clusters) are
now prefixed with the diagram's SVG element ID across all diagram types.
Custom CSS or JS using exact ID selectors like `#arrowhead` should use
attribute-ending selectors like `[id$="-arrowhead"]` instead.

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for ER diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for requirement diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: add theme support for data label colour in xy chart

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for mindmap diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look for mermaid flowchart diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look and themes for class diagram

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: add showDataLabelOutsideBar option for xy chart

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look support for timeline diagram with drop
shadows, additoinal redux themes and enhanced styling

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look and themes for gitGraph diagram

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- add new TreeView diagram

##### Patch Changes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- add link to ishikawa diagram on mermaid.js.org

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- docs: document valid duration token formats in gantt.md

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: ER diagram parsing when using "1" as entity identifier on right
side

The parser was incorrectly tokenizing the second "1" in patterns like `a
many to 1 1:` because the lookahead rule only checked for alphabetic
characters after whitespace, not digits. Added a new lookahead pattern
`"1"(?=\s+[0-9])` to correctly identify the cardinality alias before a
numeric entity name.

Fixes
[#&#8203;7472](https://redirect.github.com/mermaid-js/mermaid/issues/7472)

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: scope cytoscape label style mapping to edges with labels to
prevent console warnings

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: support inline annotation syntax in class diagrams (class Shape
<<interface>>)

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: Align branch label background with text for multi-line labels in
LR GitGraph layout

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: preserve cause hierarchy when ishikawa effect is indented more
than causes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- refactor: remove unused createGraphWithElements function and add
regression test for open edge arrowheads

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: Prevent long pie chart titles from being clipped by expanding the
viewBox

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: prevent sequence diagram hang when "as" is used without a
trailing space in participant declarations

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: warn when `style` statement targets a non-existent node in
flowcharts

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: group state diagram SVG children under single root <g> element

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: Allow :::className syntax inside composite state blocks

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
Thanks [@&#8203;aloisklink](https://redirect.github.com/aloisklink),
[@&#8203;BambioGaming](https://redirect.github.com/BambioGaming)! - fix:
prevent escaping `<` and `&` when `htmlLabels: false`

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: treemap title and labels use theme-aware colors for dark
backgrounds

- Updated dependencies
\[[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)]:
-
[@&#8203;mermaid-js/parser](https://redirect.github.com/mermaid-js/parser)@&#8203;1.1.0

####
[@&#8203;mermaid-js/parser](https://redirect.github.com/mermaid-js/parser)@&#8203;1.1.0

##### Minor Changes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- add new TreeView diagram

####
[@&#8203;mermaid-js/tiny](https://redirect.github.com/mermaid-js/tiny)@&#8203;11.14.0

##### Minor Changes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- Add Wardley Maps diagram type (beta)

Adds Wardley Maps as a new diagram type to Mermaid (available as
`wardley-beta`). Wardley Maps are visual representations of business
strategy that help map value chains and component evolution.

  Features:

- Component positioning with \[visibility, evolution] coordinates (OWM
format)
  - Anchors for users/customers
  - Multiple link types: dependencies, flows, labeled links
  - Evolution arrows and trend indicators
  - Custom evolution stages with optional dual labels
- Custom stage widths using
[@&#8203;boundary](https://redirect.github.com/boundary) notation
  - Pipeline components with visibility inheritance
  - Annotations, notes, and visual elements
  - Source strategy markers: build, buy, outsource, market
  - Inertia indicators
  - Theme integration

Implementation includes parser, D3.js renderer, unit tests, E2E tests,
and comprehensive documentation.

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for state diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look support for sequence diagrams with drop
shadows, and enhanced styling

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: add `randomize` config option for architecture diagrams,
defaulting to `false` for deterministic layout

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: Add option to change timeline direction

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- Fix duplicate SVG element IDs when rendering multiple diagrams on the
same page. Internal element IDs (nodes, edges, markers, clusters) are
now prefixed with the diagram's SVG element ID across all diagram types.
Custom CSS or JS using exact ID selectors like `#arrowhead` should use
attribute-ending selectors like `[id$="-arrowhead"]` instead.

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for ER diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for requirement diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: add theme support for data label colour in xy chart

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look styling for mindmap diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look for mermaid flowchart diagrams

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look and themes for class diagram

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: add showDataLabelOutsideBar option for xy chart

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look support for timeline diagram with drop
shadows, additoinal redux themes and enhanced styling

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- feat: implement neo look and themes for gitGraph diagram

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- add new TreeView diagram

##### Patch Changes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- add link to ishikawa diagram on mermaid.js.org

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- docs: document valid duration token formats in gantt.md

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: ER diagram parsing when using "1" as entity identifier on right
side

The parser was incorrectly tokenizing the second "1" in patterns like `a
many to 1 1:` because the lookahead rule only checked for alphabetic
characters after whitespace, not digits. Added a new lookahead pattern
`"1"(?=\s+[0-9])` to correctly identify the cardinality alias before a
numeric entity name.

Fixes
[#&#8203;7472](https://redirect.github.com/mermaid-js/mermaid/issues/7472)

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: scope cytoscape label style mapping to edges with labels to
prevent console warnings

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: support inline annotation syntax in class diagrams (class Shape
<<interface>>)

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: Align branch label background with text for multi-line labels in
LR GitGraph layout

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: preserve cause hierarchy when ishikawa effect is indented more
than causes

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- refactor: remove unused createGraphWithElements function and add
regression test for open edge arrowheads

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: Prevent long pie chart titles from being clipped by expanding the
viewBox

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: prevent sequence diagram hang when "as" is used without a
trailing space in participant declarations

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: warn when `style` statement targets a non-existent node in
flowcharts

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: group state diagram SVG children under single root <g> element

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: Allow :::className syntax inside composite state blocks

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
Thanks [@&#8203;aloisklink](https://redirect.github.com/aloisklink),
[@&#8203;BambioGaming](https://redirect.github.com/BambioGaming)! - fix:
prevent escaping `<` and `&` when `htmlLabels: false`

-
[#&#8203;7526](https://redirect.github.com/mermaid-js/mermaid/pull/7526)
[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)
- fix: treemap title and labels use theme-aware colors for dark
backgrounds

- Updated dependencies
\[[`efe218a`](https://redirect.github.com/mermaid-js/mermaid/commit/efe218a47fb5a4c2bd5489b48ce69213b141e519)]:
-
[@&#8203;mermaid-js/parser](https://redirect.github.com/mermaid-js/parser)@&#8203;1.1.0

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 16:20:41 +08:00
Abdul Rehman 76d57aa389 feat(editor): allow date picker to navigate back to year 1000 (#14942)
Fixes #14935

## Summary

The date picker had a hardcoded `_minYear = 1970` in
[`date-picker.ts`](blocksuite/affine/components/src/date-picker/date-picker.ts),
which prevented users from selecting dates earlier than 1970. This
blocked legitimate use cases like historical and genealogical research
(see the reporter's comment on #14935).

## Fix

Lower the date picker's `_minYear` from `1970` to `1000`. The underlying
storage is just a `zod.number()` (Unix timestamp in ms), which supports
negative values, so no data-layer or backend changes are required — this
is a UI-only constraint relaxation.

## Demo

<img width="2044" height="1250" alt="image"
src="https://github.com/user-attachments/assets/4b25b333-89c4-48e6-9f91-81781d680200"
/>

## Test plan

- [x] Insert a database in a doc → add a Date column
- [x] Click a date cell → open the picker → click the year label →
navigate back through decades
- [x] Confirm the calendar reaches years well before 1970 (verified at
May 1805)
- [x] Confirm the calendar correctly renders weekdays for historical
dates
- [x] Confirm picking a modern date still works as before

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

* **New Features**
* Date picker now allows selecting dates from year 1000 onward,
expanding historical date coverage.

* **Bug Fixes**
* Navigation (month switches and keyboard arrows) now keeps the
selection cursor within the allowed year range, preventing out-of-range
jumps.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14942)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-12 15:47:36 +08:00
DarkSky db0ff0a9df feat(core): migrate more pull to realtime (#14936)
#### PR Dependency Tree


* **PR #14936** 👈

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**
* Consolidated realtime subscription patterns for consistent, more
reliable live updates across comments, notifications, transcription
tasks, and embedding progress.
* Standardized realtime room naming and subscription keys for
deterministic delivery.

* **New Features**
* Introduced a reusable live-query mechanism powering realtime snapshot
+ event workflows used by comments, notifications, transcript tasks, and
embedding progress.

* **Tests**
* Added tests covering live-query behavior and deterministic
subscription key generation.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14936)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-11 00:33:25 +08:00
DarkSky 8cf00738c2 feat(server): realtime notification & task status (#14934)
#### PR Dependency Tree


* **PR #14934** 👈

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**
* Full realtime platform added: live notifications, comments, embedding
progress, and transcription task updates via realtime subscriptions.

* **Chores**
* Frontend switched from polling/GraphQL queries to realtime channels;
legacy query fields marked deprecated and client libs updated to use
realtime APIs.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14934)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #14934** 👈
  * **PR #14936**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2026-05-10 23:21:50 +08:00
DarkSky 417d31cabe fix(core): ui state (#14933)
#### PR Dependency Tree


* **PR #14933** 👈

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 draft tab option to AI chat interface
* Introduced "Current document" session history view in chat history
popover
  * Added control to show/hide "New Chat" button

* **Improvements**
  * Enhanced chat history preservation when switching between sessions
  * Prevented duplicate session creation requests
  * Improved message handling during session transitions and generation

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14933)

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-09 23:33:37 +08:00
DarkSky fcc45a3f44 fix(server): caldav compatibility (#14930)
fix #14411
fix #14909 

Some CalDAV servers do not implement standard responses; add
compatibility for these servers.


#### PR Dependency Tree


* **PR #14930** 👈

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 CalDAV discovery error handling to gracefully fall back when
the server returns certain error statuses.

* **New Features**
* CalDAV account linking now returns the number of discovered calendars
associated with the account.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-09 02:40:47 +08:00
DarkSky bcbde16c04 feat(server): native safe fetch (#14931) 2026-05-09 02:40:25 +08:00
DarkSky 32a94d68dc chore: add utils 2026-05-09 02:32:10 +08:00
DarkSky 5813e7dd77 chore: update i18n 2026-05-07 11:32:55 +08:00
karl-kaefer ac37d07e74 feat(editor): add Bear backup import and markdown zip folder hierarchy (#14599)
## Summary

- Add Bear `.bear2bk` backup importer (TextBundle-based zip format)
- Enhance markdown zip import to preserve folder structure from zip
paths
- Add colored highlight (`<mark data-color="...">`) support to HTML
adapter

### Bear Import Details

Bear backups are zip archives of TextBundle directories. The importer:
- Parses Bear-specific markdown (highlights `==text==`, callouts `>
[!NOTE]`, inline tags `#tag`)
- Extracts creation/modification dates from `info.json` metadata
- Filters out trashed notes
- Converts Bear tags to AFFiNE tags (consolidated by root segment)
- Builds folder hierarchy from nested tag paths (e.g.,
`#work/projects/alpha`)
- Uses JSZip for lazy decompression to handle large backups without OOM

### Markdown Zip Folder Hierarchy

`importMarkdownZip` now returns `{ docIds, folderHierarchy }` instead of
just `docIds[]`, enabling the UI to recreate the zip's directory
structure as AFFiNE folders.

## Related Issues

- Implements the TextBundle-based import approach suggested in #14115 /
Discussion #14142
- Addresses folder structure preservation requested in #10003
- Partially addresses frontmatter metadata import from #11286

## Test Plan

- [ ] Import a Bear `.bear2bk` backup file via the import dialog
- [ ] Verify tags are created and assigned to documents
- [ ] Verify folder hierarchy matches Bear's nested tag structure
- [ ] Verify creation/modification dates are preserved
- [ ] Verify highlighted text and callouts render correctly
- [ ] Verify images and attachments are imported
- [ ] Import a markdown zip with nested folders, verify folder structure
is recreated
- [ ] Verify trashed Bear notes are excluded

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

* **New Features**
* Bear (.bear2bk) backup import: bulk import notes, convert/dedupe tags,
create nested folders, and return imported doc IDs plus folder
hierarchy; UI import option and progress integrated.
* Markdown ZIP import now returns an optional folder hierarchy alongside
created doc IDs.

* **Bug Fixes / Improvements**
* Highlighting: mark elements validate color names, default safely, and
apply consistent background styling.

* **Chores**
  * Added runtime dependency for ZIP handling.

* **Documentation**
  * Added localization strings and i18n accessors for Bear import UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-05-07 11:29:40 +08:00
renovate[bot] 429e7f495d chore: bump up link-preview-js version to v4.0.1 [SECURITY] (#14917)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[link-preview-js](https://redirect.github.com/OP-Engineering/link-preview-js)
| [`4.0.0` →
`4.0.1`](https://renovatebot.com/diffs/npm/link-preview-js/4.0.0/4.0.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/link-preview-js/4.0.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/link-preview-js/4.0.0/4.0.1?slim=true)
|

---

### link-preview-js vulnerable to IPv6 and internal loopback attacks
[CVE-2026-43897](https://nvd.nist.gov/vuln/detail/CVE-2026-43897) /
[GHSA-4gp8-rjrq-ch6q](https://redirect.github.com/advisories/GHSA-4gp8-rjrq-ch6q)

<details>
<summary>More information</summary>

#### Details
##### Impact
The library did not check for IPv6 loopback attacks. There was also a
DNS attack, where an address could be resolved into an internal IP. This
could cause internal data leaks.

##### Patches
Problem has been patched in version 4.0.1. However, it cannot be
completely solved by the package alone. The regex used for validation
has been tightened for IPv6 addresses.

The DNS resolving, however, is more difficult. The regex has been
tightened to prohibit .internal, .local, .nip.io and .sslip.io
addresses, however there can be other services not on the list,
therefore it is imperative that users use the resolveDNSHost option to
do DNS resolution before fetching content. To that regard a (scary)
error message has been added when the option is not set.

##### Workarounds
Users can do their own validation before fetching content.

Reported by https://github.com/Andrew-most-likely

#### Severity
- CVSS Score: 8.7 / 10 (High)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N`

#### References
-
[https://github.com/OP-Engineering/link-preview-js/security/advisories/GHSA-4gp8-rjrq-ch6q](https://redirect.github.com/OP-Engineering/link-preview-js/security/advisories/GHSA-4gp8-rjrq-ch6q)
-
[https://github.com/OP-Engineering/link-preview-js/pull/179](https://redirect.github.com/OP-Engineering/link-preview-js/pull/179)
-
[https://github.com/OP-Engineering/link-preview-js/commit/4396d48909fab37553c0e93e26447fe218363ede](https://redirect.github.com/OP-Engineering/link-preview-js/commit/4396d48909fab37553c0e93e26447fe218363ede)
-
[https://github.com/OP-Engineering/link-preview-js/releases/tag/4.0.1](https://redirect.github.com/OP-Engineering/link-preview-js/releases/tag/4.0.1)
-
[https://github.com/advisories/GHSA-4gp8-rjrq-ch6q](https://redirect.github.com/advisories/GHSA-4gp8-rjrq-ch6q)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-4gp8-rjrq-ch6q)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>OP-Engineering/link-preview-js (link-preview-js)</summary>

###
[`v4.0.1`](https://redirect.github.com/OP-Engineering/link-preview-js/releases/tag/4.0.1)

[Compare
Source](https://redirect.github.com/OP-Engineering/link-preview-js/compare/4.0.0...4.0.1)

#### What's Changed

- Loopback fixes by
[@&#8203;ospfranco](https://redirect.github.com/ospfranco) in
[#&#8203;179](https://redirect.github.com/OP-Engineering/link-preview-js/pull/179)

**Full Changelog**:
<https://github.com/OP-Engineering/link-preview-js/compare/4.0.0...4.0.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-07 11:29:35 +08:00
Davide Conte 339f89220a fix(core): prevent navigation panel from reordering while typing (#14831) 2026-05-07 11:28:19 +08:00
Adarsh Singh 440ff0c342 fix(editor): resolve UX inconsistencies in the AI chat interface (#14850)
# Closes #14189.

Fixes the three UX issues reported in the original bug report, plus one
small
adjacent polish on the right-sidebar toggle that was requested during
review.

Each concern in the issue is addressed end-to-end, with the same
treatment
applied to both places the AI chat panel lives: the **sidebar chat
panel**
(right panel on a doc page) and the **standalone `/chat` page**.

---

## 1. `+` button → persistent multi-session tabs (issue point 1)

**Before:** clicking `+` called `createFreshSession()` (standalone) or
`newSession()` (sidebar), both of which tore down the current chat
content
and replaced it in place. There was no way to keep two chats open at
once.

**After:** a browser/IDE-style tab strip lives above the chat content.
Each
open session gets its own tab with a close `×`; the active tab is
highlighted; `+` now adds a tab rather than replacing the chat.

### Details
- New Lit component `ai-chat-tabs`
([packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts)).
- Tab title is derived from `session.title` → first user message → `"New
chat"`.
- Horizontal scroll when tabs overflow, with a `wheel` handler that
converts
    mouse wheel / trackpad vertical swipe into horizontal scroll (native
horizontal trackpad swipes also work natively via `overflow-x: auto`).
- Auto `scrollIntoView({ inline: 'nearest' })` on active tab change, so
a
newly created or newly selected tab slides into view instead of staying
    hidden behind the toolbar.
- Close `×` removes the tab from the strip but leaves the session on the
server (matches the existing **Chat history** dropdown semantics — the
session is still reachable there). Closing the active tab switches to an
    adjacent one; closing the last tab starts a fresh session.
- Persistence: open session IDs are saved per-workspace in
`localStorage`
under `ai-chat-open-tabs:{workspaceId}`. On mount, the React pages
hydrate
  those IDs via `AIProvider.session.getSession` /
  `CopilotClient.getSession` — no new backend or schema work.
- Wiring: identical effects on both variants
([chat.tsx
(sidebar)](packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx)
and
[chat/index.tsx
(standalone)](packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx))
  — hydrate → sync active session into tabs → persist.
- The tab strip sits on the same row as the existing toolbar icons
  (pin / history / `+`), separated by `flex: 1` + `min-width: 0` so the
  tabs scroll cleanly up to the toolbar boundary.
- The `ShadowlessElement` base class injects its static CSS globally,
and the
`:host` selector does not match in a React-rooted DOM — the component
uses
  tag-selector CSS (`ai-chat-tabs { display: flex; … }`) instead.

## 2. Drag-and-drop attachments (issue point 2)

**Before:** the chat input accepted no DnD. Attaching anything required
the
`+` → file-picker flow.

**After:** the chat input accepts OS files via native HTML5 DnD and
AFFiNE
documents via the repo's existing pragmatic-drag-and-drop
infrastructure.

### Details
- Native handlers (`dragenter/over/leave/drop`) on

[ai-chat-input.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts)
accept OS files: images go into the image preview grid, other files
become
  attachment chips, with the same 50 MB per-file cap as the `+` picker.
- Internal AFFiNE document drags from the nav panel land as doc chips,
  handled via `dropTargetForElements` from
  `@atlaskit/pragmatic-drag-and-drop` (same library the rest of the app
  already uses for internal DnD).
- A "Drop to attach" overlay appears during drag, reusing the existing
focused-border token (`--affine-v2-layer-insideBorder-primaryBorder`)
for
  visual consistency with the focused state.
- The image/file routing logic that previously lived inline in
  `add-popover.ts` was factored into a shared helper

[attachment-utils.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts)
  (`addFilesToChat`), so the `+` picker and the drop handler stay in
  lockstep.
- Analytics: extended the `addEmbeddingDoc.control` union in
[events.ts](packages/frontend/track/src/events.ts) with `'dragDrop'` so
  drag-originated attachments are distinguishable from button-initiated
  ones in telemetry.
- `@atlaskit/pragmatic-drag-and-drop` is promoted from a transitive
  dependency (via `@affine/component`) to a direct dependency of
  `@affine/core` and `yarn.lock` is refreshed accordingly.

## 3. Chat-history tooltip + icon (issue point 3)

**Before:** hovering the chat-history button showed a tooltip whose
background did not invert for dark theme (`--affine-tooltip` is not
theme-aware), and the icon was `ArrowDownSmallIcon` — a chevron that
does
not convey "history."

**After:** the tooltip primitive itself is theme-aware (every tooltip in
the app benefits, not just the chat one), and the icon is the
semantically-clear `HistoryIcon`.

### Details
- [tooltip.ts](blocksuite/affine/components/src/tooltip/tooltip.ts) now
uses
  `var(--affine-v2-tooltips-background, var(--affine-tooltip))` and
  `var(--affine-v2-tooltips-foreground, var(--affine-white))`. The V2
  tokens auto-invert with theme; the old vars remain as fallbacks so
  components that override via the existing `tooltipStyle` escape hatch
  continue to work.
- Triangle arrow colors updated to use the same V2 token.
-
[ai-chat-toolbar.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts):
  `ArrowDownSmallIcon` → `HistoryIcon`; added
  `data-testid="ai-panel-chat-history"` for future e2e coverage.

## 4. Right-sidebar toggle: tooltips + open-state icon *(adjacent
polish)*

Not part of the original issue, but surfaced while testing the tab strip
—
neither of the two right-sidebar toggle buttons had hover affordance,
and
both used the same icon regardless of the sidebar's state.

- Added `tooltip="Open sidebar"` on the route-container button shown
when
  the sidebar is hidden.
- Added `tooltip="Close sidebar"` on the sidebar-header button shown
when
  the sidebar is expanded.
- The close button now renders a small inline `RightSidebarOpenIcon`
  variant: same outline as `RightSidebarIcon`, but with the right panel
  filled in the AFFiNE accent color to convey the open state. Icon shape
  change is self-contained — no new icon asset added to
  `@blocksuite/icons`.

---

## Commits

- `2adc0c7` — fix(ai-chat): theme-aware tooltip + semantic chat-history
icon *(2 files)*
- `bf26974` — feat(ai-chat): drag-and-drop file and doc attachments in
chat input *(7 files)*
- `fca29c8` — feat(ai-chat): persistent multi-session tab strip *(8
files)*
- `7d5dffe` — feat(workbench): tooltips and open-state icon for the
right-sidebar toggle *(2 files)*

Kept ordered smallest → largest blast radius so the history is easy to
bisect.

---

## Test plan

Verified locally against a fresh server stack (postgres / redis /
mailpit via
compose, migrations run) signed in as `dev@affine.pro`, in both `/chat`
and
the sidebar chat on a doc page, in light and dark themes:

- [x] Tooltip: hover the chat-history icon in dark mode → tooltip is
dark-on-light; toggle to light mode → tooltip is light-on-dark. Existing
tooltips on other surfaces (slash menu, edgeless, linked-doc) still
render correctly.
- [x] Icon: chat-history button renders the history glyph (clock), not a
chevron.
- [x] Drag-and-drop (OS file): drop a PDF / PNG / TXT onto the input →
overlay shows → chips/images appear; file > 50 MB → rejected silently
(same as `+` picker).
- [x] Drag-and-drop (internal doc): drag an AFFiNE doc from the nav
panel → becomes a doc chip.
- [x] Pin-picker, `+` picker, paste-image — all unchanged.
- [x] Tab strip: first chat auto-becomes a tab on first message; `+`
adds tab; click tab switches chat; `×` removes tab and switches to
adjacent; close last tab → new fresh tab spawns.
- [x] Reload browser → tab strip rehydrates from localStorage with the
same sessions.
- [x] Tab overflow: 12+ tabs → horizontal scroll via trackpad vertical
swipe, trackpad horizontal swipe, and mouse wheel; active tab
auto-scrolls into view on `+` click.
- [x] Right-sidebar: hover both toggle buttons → tooltips appear; open
the sidebar → close button shows the filled right-panel icon.
- [x] `yarn lint:ox` and lint-staged both clean on every commit.

Not verified locally (no local model key configured): the assistant
actually
streams a response. Drop/chip flow is independent of that path.

## Out of scope / follow-ups

- No new unit or Playwright tests — the fixes are visually verifiable
and
  reuse existing reducer / state paths. Happy to add tests if reviewers
  prefer.
- `@affine/native` is not required for the web dev stack; I only built
  `@affine/server-native`. Irrelevant to the PR diff.


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

* **New Features**
* Multi-tab chat UI with a tabs component, open/close/switch actions,
and per-workspace persistence/restoration.
  * Drag-and-drop attachments into chat input (files and docs).

* **UI/UX**
  * Tooltip theming moved to v2 variables (includes arrow color).
  * Sidebar toggle/close buttons now show tooltips.
  * “Drop to attach” overlay and updated history icon.

* **Behavior**
  * Unified attachment handling with 50MB validation and toast notices.

* **Analytics**
  * Attachment events record drag-and-drop as a control method.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-05-07 04:04:43 +08:00
DarkSky eb9cc22502 feat(server): refactor for byok (#14911) 2026-05-07 04:03:14 +08:00
DarkSky 4e169ea5c7 fix(editor): cross browser test stability (#14897)
#### PR Dependency Tree


* **PR #14897** 👈

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 reliability of shape and connector detection by forcing full
DOM renders during waits.
* Fixed race conditions in code-block theme loading and cleanup when
components unmount.
* Refined viewport element discovery to correctly handle
rotated/canvas-layer elements and avoid stale DOM removal.

* **Tests**
  * Increased polling timeouts and retries to reduce flakiness.
* Disabled per-file parallelism and ensured test setup performs full
cleanup before starting; extended test timeout.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 20:07:40 +08:00
Ahsan Khaleeq 9e412f58ec feat(editor): add collapse/expand functionality to code block component (#14884)
This PR fixes #14040 

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

* **New Features**
* Code blocks can be collapsed and expanded via a toolbar toggle
(visible when the document is editable).
* Collapsed code blocks show a limited preview (~8 lines) with a bottom
fade overlay and reduced padding.
* Toolbar button updates icon and tooltip to reflect collapsed/expanded
state.
* Collapse state is preserved on the block so its current
collapsed/expanded setting is retained.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 05:07:42 +08:00
Aisha Roslan 5d234ad6a8 fix(editor): single-letter tags in select/multi-select table cell (#14808)
### Summary of Changes
Resolves #14715 and #14280.

When a user types into a **Select/Multi-Select** table cell to
create/choose a tag, that character is stashed on the cell container
(setTagDraft) instead of going through valueSetFromString. Opening the
tag picker reads it via consumeTagDraftFromTableCellHost.

### Verification
- Added unit test to check that single-character input doesn't
immediately call valueSetFromString.



https://github.com/user-attachments/assets/432b2693-52f9-4ab4-a694-8440aea007a3



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

## Summary by CodeRabbit

* **New Features**
* Tag selection popups now initialize with draft text from keypresses in
tag columns, improving user experience when editing tags.

* **Tests**
* Added comprehensive hotkey tests for single-select and multi-select
tag column behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 03:58:18 +08:00
DarkSky 1ad088398f fix(server): test & schema 2026-05-04 03:56:14 +08:00
Aisha Roslan 74d5ebad13 fix(editor): stretch latex preview content (#14857)
### Summary of Changes
Resolves #13340. Change align-items to stretch to full width to avoid
tag/label from overlapping with equation.

### Screenshot Verification
**Before**
<img width="661" height="256" alt="Screenshot 2026-04-19 at 5 58 03 PM"
src="https://github.com/user-attachments/assets/a99d0138-838f-4f91-bd63-cbd07710484c"
/>

**After**
<img width="614" height="275" alt="Screenshot 2026-04-19 at 5 58 16 PM"
src="https://github.com/user-attachments/assets/7e62ab09-f290-4b6e-9cd6-d20b8f990da3"
/>

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

## Summary by CodeRabbit

* **Style**
* Improved the vertical alignment of LaTeX block content to better
utilize container space.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 03:54:01 +08:00
Ahsan Khaleeq a1800cf8b2 feat(editor): remove max-height restriction from mermaid preview container (#14882)
This PR fixes #14874 

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

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
* Removed height limitation on Mermaid diagram previews in code blocks,
allowing larger diagrams to render at their full size without being
constrained by a fixed maximum height.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 03:46:59 +08:00
DarkSky fa66139230 feat(server): add flag for calendar enable (#14896)
#### PR Dependency Tree


* **PR #14896** 👈

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 configuration option to manage Google Calendar account linking
access. Administrators can now disable new account connections to
control calendar service integrations. When disabled, the Google
provider is hidden from available options and new linking attempts are
blocked, while existing accounts remain fully functional.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 03:45:49 +08:00
DarkSky 027d163921 fix(server): add embedding table repair (#14895)
fix #14894


#### PR Dependency Tree


* **PR #14895** 👈

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 database initialization for self-hosted deployments with
automatic creation and repair of embedding tables and indexes, applied
only when related base tables and extensions are present.
* Updated pre-deploy process to run Prisma migrations, perform
embedding-table maintenance, and execute additional data migrations as
part of setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 03:45:37 +08:00
Abdul Rehman 39abb936b8 fix(core): prevent Alt+Key shortcuts from hijacking macOS Option-key input (#14866)
Fixes #14519

## Summary

On macOS, the Option key combined with a letter produces locale input
characters (e.g. Polish layout: Option+S → `ś`, Option+L → `ł`). The
AFFiNE command registry registers shortcuts like `Alt+KeyS` (used for
Page ↔ Edgeless mode switch) via `tinykeys`, which matches on
`event.code` (the physical key) — so it fires even when the user was
actually typing a non-ASCII character.

Reported in #14519: Polish users cannot type `ś` inside AFFiNE because
Option+S triggers the mode switch instead.

## Fix

In the command registry handler
([registry.ts](packages/frontend/core/src/commands/registry/registry.ts)),
skip the command when Alt is the only modifier **and** the key produced
a non-ASCII character — the user intends to type the character, not
invoke the shortcut.

Matches the existing handling in blocksuite's `keymap.ts` (added for the
same class of issue in #14059).

## Demo



https://github.com/user-attachments/assets/eb6d2e69-39bf-4236-a886-9e2bde425626



## Verified locally (macOS)

- Switched input source to Polish
- Typed `właśnie` in an AFFiNE doc — all characters including `ś`
(Option+S), `ł` (Option+L) now produce the correct output
- Previously Option+S would toggle edgeless mode
- US layout (Option+S → `ß`) and other locale chars (ą, ń, ę) also now
pass through correctly
- Regular Cmd-based shortcuts (Cmd+K, Cmd+S, etc.) unaffected because
the guard excludes `metaKey`

## Test plan

- [x] On macOS, add Polish input source (System Settings → Keyboard →
Input Sources → +)
- [x] Switch to Polish layout
- [x] In any AFFiNE doc, type Option+S → `ś` appears (not mode switch)
- [x] Confirm other shortcuts (Cmd+K, Cmd+Enter, etc.) still work
- [x] Confirm on US layout that Option+S produces `ß` (OS default)
without firing the mode switch

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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed keyboard event handling with Alt key and non-ASCII characters to
prevent unintended command execution.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 02:35:35 +08:00
Abdul Rehman 9751cab16c fix(editor): native table column resize broken in edgeless mode (#14824)
Fixes #14717

## Summary

When a native `affine:table` block is placed in a note on the edgeless
canvas, dragging the column resize handle (or the column/row drag
handles) causes the canvas to pan instead of triggering the resize/drag,
because the edgeless `DragController` listens at the `pointerdown` level
— earlier than `SelectionController`'s existing `mousedown` handler.

## Fix

Two interception layers added to
`blocksuite/affine/blocks/table/src/selection-controller.ts`, matching
the working pattern in `affine:database`'s `database-header-column.ts`:

1. **DOM-level `pointerdown` `stopPropagation()`** in `dragListener()` —
prevents the edgeless `DragController` from capturing the event before
BlockSuite's event system sees it.
2. **`handleEvent('dragStart', ...)`** in `hostConnected()` — returns
`true` when the target is a resize/drag handle, so the BlockSuite event
dispatcher doesn't route to the edgeless tool controller.

Selectors guarded: `[data-width-adjust-column-id]`,
`[data-drag-column-id]`, `[data-drag-row-id]`.

Mobile and readonly states preserved (matching existing `dragListener()`
guards).

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved drag-and-drop interaction handling for table operations,
including column width adjustment and row/column dragging. Enhanced
event handling to prevent unintended drag actions and ensure proper
behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 02:34:29 +08:00
congzhou09 5e97e67ecd fix(editor): prevent connector label from breaking after click + move in empty label editor (#14830)
### Problem
●In edgeless mode, after clicking and moving in a connector's label
editor, if the label editor has empty content at the end of the editing,
the label editor for that connector can not be triggered again.

●The following video demonstrates this issue:


https://github.com/user-attachments/assets/8d300720-5ed8-4f9c-90fa-fbf059417ff8

### Root Cause
**Direct cause**
●The `labelOffset` property is **stashed** at drag-start, but is **not
properly popped** afterward. As a result, when
`mountConnectorLabelEditor()` is called the second time
(`packages/affine/gfx/connector/src/text/edgeless-connector-label-editor.ts`),
`connector.labelOffset` returns `undefined` instead of the default value
provided by the `@field` decorator.

**Why moving after clicking incorrectly triggers a drag-start**
●The root issue lies in the interaction between click and drag event
handling. Here's the actual flow:
1.`dispatcher.add('click', () => true)` is registered in
`EdgelessConnectorLabelEditor`.
2.On pointer-down, both `ClickController` and `DragController` receive
the event.
3.On pointer-up, `ClickController` fires a **synthetic click**. The
handler from step 1 returns `true`, triggering
`context.get('defaultState').event.stopPropagation()`.
4.This prevents the native pointer-up from bubbling to `DragController`.
However, a subsequent pointer-move still causes `DragController` to
**incorrectly synthesize a drag-start + drag-move**.

**Fundamental root cause**
●The line `context.get('defaultState').event.stopPropagation()` in
`UIEventDispatcher::run()` stops **both** synthetic and native event
bubbling. It should only stop synthetic event propagation.
●The synthetic event bubbling stopping is already properly handled by
the immediate `return` statement on the next line, because the runners
are prepared in strict order (current → parent → grandparent → ... →
global) by `UIEventDispatcher::_getEventScope()` and then **executed
sequentially** in `UIEventDispatcher::run()`.

### Fix
●Since I cannot rule out that other (current or future) event handlers
may rely on this native event bubbling stopping behavior, I chose not to
remove the `context.get('defaultState').event.stopPropagation()` line
completely. Instead, I added a new constant and now skip
`stopPropagation()` **only** for the following synthetic events:
```ts
const syntheticEventNames = new Set(['click', 'doubleClick', 'tripleClick']);
```
These currently represent all known synthetic click events triggered
from pointer-up.

### After
●The video below shows the behavior after this fix.


https://github.com/user-attachments/assets/65b8a3ce-0767-4d80-986b-8bc6081ddd4c
2026-05-04 02:33:09 +08:00
Adarsh Singh 7046ad7bf4 fix(editor): align selection/handle/remote/text overlays with blocks (#14862)
# Closes #14855.

## The bug

When an `affine:embed-synced-doc` is placed on an edgeless canvas and
resized which sets `model.props.scale` to a value ≠ 1 - the
block-selection frame rendered **inside** that embedded editor is drawn
offset from the actual block boundary. The reporter hit this in Safari,
but the root cause is platform-independent.

![reported
screenshot](https://github.com/user-attachments/assets/ce415528-1d01-4bfe-9d63-1e2884ca2f70)

## Root cause

`affine-embed-edgeless-synced-doc-block` applies `transform:
scale(modelScale)` to its `.affine-embed-synced-doc-container` so the
embedded editor visually fits inside its edgeless xywh
([embed-edgeless-synced-doc-block.ts#L48-L58](https://github.com/toeverything/AFFiNE/blob/canary/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts#L48-L58)).
The inner `Viewport` exposes that outer scale as `viewScale =
boundingClientRect.width / offsetWidth`.

PR #14015 and PR #14074 already taught the surface canvas and
`GfxBlockComponent.getCSSTransform` to compensate by dividing by
`viewScale`. But several selection-related overlays that render inside
the same scaled container were **not** updated in those PRs. They
either:

- read `viewport.toViewCoord(x, y)` - which returns `(x - viewportX) *
zoom * viewScale` and drop the result into CSS `left` / `top` inside the
scaled container, or
- hand-build a `translate(translateX, translateY) scale(zoom)` transform
without `viewScale` compensation.

The outer CSS `scale(viewScale)` then re-applies the scale, leaving the
overlays one factor of `viewScale` away from their blocks. That's
exactly the misalignment in the screenshot - the rect's size looks right
but its position is offset.

## The fix

Mirror the pattern shipped in #14074 everywhere the inner overlays are
placed:

- position: `(model - viewportX) * zoom / viewScale`
- transform scale: `zoom / viewScale`
- translate: `translateX / viewScale, translateY / viewScale`

This keeps the overlays in the same reference frame as
`GfxBlockComponent.getCSSTransform` so they line up with the block
they're framing. When `viewScale === 1` (normal edgeless canvas, outside
any embed) every `/ viewScale` is a no-op and behaviour is unchanged.

## Why this is safe

- When `viewScale === 1` - every existing caller outside
`embed-edgeless-synced-doc` - the math reduces to the original
expression byte-for-byte.
- The fix strictly mirrors the invariant already adopted by
`GfxBlockComponent.getCSSTransform` in #14074. It's the same division by
`viewScale` applied in the same place.
- No public API, type, or DOM structure changed.

## Scope / known limitations

- The `Viewport._cachedBoundingClientRect` cache is only invalidated by
its own `ResizeObserver`
([viewport.ts#L500-L505](https://github.com/toeverything/AFFiNE/blob/canary/blocksuite/framework/std/src/gfx/viewport.ts#L500-L505)).
A CSS-transform change on an ancestor (e.g. the user panning/zooming the
outer edgeless canvas) does not fire it, so in theory `viewScale` can go
stale between outer-viewport updates. In practice this hasn't come up in
repro - the inner viewport's shell is observed and fires whenever layout
shifts. If it turns out to matter I'm happy to add a
`viewport.onResize()` refresh hook off the existing
`GfxViewportInitializer` in a follow-up.
- No integration test added - the existing
`blocksuite/integration-test/edgeless/` suite has no `embed-synced-doc`
harness. Adding one is a larger scope; can follow up if requested.

## Test plan

- [x] `yarn typecheck` - passes
- [x] `yarn lint:ox` - `0 warnings, 0 errors`
- [x] `yarn prettier --write` on the 5 touched files - no changes
- [ ] Manual: on canary, create an edgeless canvas, drop an
embed-synced-doc, resize with `Shift` held so `model.props.scale` ≠ 1,
select any block inside, and verify the blue selection frame sits flush
with the block's boundary (confirm on Safari, Chrome, Firefox).
- [ ] Regression check: on a normal edgeless canvas (no embed), verify
element selection, drag handle, and text/shape inline editors still
render correctly (these code paths hit `viewScale === 1` and should be
unchanged).

## Related PRs

- #14015 - fixed surface canvas at non-1 `viewScale`.
- #14074 - fixed `GfxBlockComponent.getCSSTransform` at non-1
`viewScale`. This PR completes that series by covering the selection
overlays.

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

* **Bug Fixes**
* Fixed positioning and scaling of inline text editors, selection
rectangles, drag handles, and remote cursors so overlays and editors
remain correctly aligned and sized when the viewport uses an additional
outer scale/transform during zooming and panning.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 01:38:39 +08:00
DarkSky e90e3e537c fix(server): lint 2026-05-04 00:48:23 +08:00
DarkSky d64f368623 feat(server): refactor copilot (#14892)
#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2026-05-04 00:36:47 +08:00
Remi Huigen fa8f1a096c fix(server): allow custom R2 jurisdictional endpoint (#14848)
## Summary
This PR fixes `cloudflare-r2` storage configuration so jurisdictional R2
endpoints (for example EU buckets) work correctly.

Closes #14847

## Problem
`cloudflare-r2` currently ignores `config.endpoint` and always uses:

`https://<accountId>.r2.cloudflarestorage.com`

That breaks uploads for jurisdictional buckets that require endpoints
like:

`https://<accountId>.eu.r2.cloudflarestorage.com`

## Changes
- Updated `R2StorageProvider` endpoint resolution:
  - use `config.endpoint` when provided
- otherwise fall back to `https://${accountId}.r2.cloudflarestorage.com`
- Kept `forcePathStyle: true` behavior unchanged
- Updated validation to require `accountId` **or** `endpoint`
- Improved storage schema descriptions to mention jurisdiction endpoints
- Added focused unit tests for:
  - default account endpoint behavior
  - custom jurisdiction endpoint behavior

## Backward Compatibility
- Existing R2 configs that only provide `accountId` continue to work
exactly as before.
- New behavior only applies when a custom `config.endpoint` is
explicitly set.

## Tests
- Added: `packages/backend/server/src/base/storage/__tests__/r2.spec.ts`
- Verifies both default and custom endpoint selection paths.

_Disclaimer: parts of this PR were implemented with AI assistance._

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

* **New Features**
* Cloudflare R2 config adds an optional "jurisdiction" (EU) option and
consistent endpoint derivation for S3-compatible providers.

* **Documentation**
* Storage configuration schemas clarified: S3 endpoint is
optional/derived from region; R2 endpoint removed from schema and
jurisdiction documented.

* **Tests**
* Added tests validating R2 endpoint selection for default,
EU-jurisdiction, undefined-jurisdiction, and missing-account scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-05-04 00:22:51 +08:00
DarkSky fb6291cb15 fix: deps dedup 2026-05-03 23:35:57 +08:00
Whitewater 694158eea3 feat(playground): export Y.Doc from debug menu (#14893)
## Summary
- add an Export Y.Doc debug menu item
- encode the active store spaceDoc with Y.encodeStateAsUpdate
- download the update as a binary ydoc-update file

## Test
- yarn workspace @blocksuite/playground build

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

## Summary by CodeRabbit

* **New Features**
* Added Y.Doc export functionality to the debug menu, enabling users to
download the current space document state as a binary update file
through the "Test Operations" → "Export" menu.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-03 23:33:41 +08:00
DarkSky 207bd9387e fix(docs): redirect links 2026-04-29 19:56:44 +08:00
DarkSky 78a9942f19 fix: ci 2026-04-29 19:31:40 +08:00
DarkSky 0ccfacbc29 feat(docs): migrate bs docs 2026-04-29 17:23:23 +08:00
renovate[bot] bf6fc66943 chore: bump up postcss version to v8.5.10 [SECURITY] (#14877)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [postcss](https://postcss.org/)
([source](https://redirect.github.com/postcss/postcss)) | [`8.5.6` →
`8.5.10`](https://renovatebot.com/diffs/npm/postcss/8.5.6/8.5.10) |
![age](https://developer.mend.io/api/mc/badges/age/npm/postcss/8.5.10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/postcss/8.5.6/8.5.10?slim=true)
|

---

### PostCSS has XSS via Unescaped </style> in its CSS Stringify Output
[CVE-2026-41305](https://nvd.nist.gov/vuln/detail/CVE-2026-41305) /
[GHSA-qx2v-qp2m-jg93](https://redirect.github.com/advisories/GHSA-qx2v-qp2m-jg93)

<details>
<summary>More information</summary>

#### Details
##### PostCSS: XSS via Unescaped `</style>` in CSS Stringify Output

##### Summary

PostCSS v8.5.5 (latest) does not escape `</style>` sequences when
stringifying CSS ASTs. When user-submitted CSS is parsed and
re-stringified for embedding in HTML `<style>` tags, `</style>` in CSS
values breaks out of the style context, enabling XSS.

##### Proof of Concept

```javascript
const postcss = require('postcss');

// Parse user CSS and re-stringify for page embedding
const userCSS = 'body { content: "</style><script>alert(1)</script><style>"; }';
const ast = postcss.parse(userCSS);
const output = ast.toResult().css;
const html = `<style>${output}</style>`;

console.log(html);
// <style>body { content: "</style><script>alert(1)</script><style>"; }</style>
//
// Browser: </style> closes the style tag, <script> executes
```

**Tested output** (Node.js v22, postcss v8.5.5):
```
Input: body { content: "</style><script>alert(1)</script><style>"; }
Output: body { content: "</style><script>alert(1)</script><style>"; }
Contains </style>: true
```

##### Impact

Impact non-bundler use cases since bundlers for XSS on their own.
Requires some PostCSS plugin to have malware code, which can inject XSS
to website.

##### Suggested Fix

Escape `</style` in all stringified output values:
```javascript
output = output.replace(/<\/(style)/gi, '<\\/$1');
```

##### Credits
Discovered and reported by [Sunil Kumar](https://tharvid.in)
([@&#8203;TharVid](https://redirect.github.com/TharVid))

#### Severity
- CVSS Score: 6.1 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N`

#### References
-
[https://github.com/postcss/postcss/security/advisories/GHSA-qx2v-qp2m-jg93](https://redirect.github.com/postcss/postcss/security/advisories/GHSA-qx2v-qp2m-jg93)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-41305](https://nvd.nist.gov/vuln/detail/CVE-2026-41305)
-
[https://github.com/postcss/postcss/releases/tag/8.5.10](https://redirect.github.com/postcss/postcss/releases/tag/8.5.10)
-
[https://github.com/advisories/GHSA-qx2v-qp2m-jg93](https://redirect.github.com/advisories/GHSA-qx2v-qp2m-jg93)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-qx2v-qp2m-jg93)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>postcss/postcss (postcss)</summary>

###
[`v8.5.10`](https://redirect.github.com/postcss/postcss/blob/HEAD/CHANGELOG.md#8510)

[Compare
Source](https://redirect.github.com/postcss/postcss/compare/8.5.9...8.5.10)

- Fixed XSS via unescaped `</style>` in non-bundler cases (by
[@&#8203;TharVid](https://redirect.github.com/TharVid)).

###
[`v8.5.9`](https://redirect.github.com/postcss/postcss/blob/HEAD/CHANGELOG.md#859)

[Compare
Source](https://redirect.github.com/postcss/postcss/compare/8.5.8...8.5.9)

- Speed up source map encoding paring in case of the error.

###
[`v8.5.8`](https://redirect.github.com/postcss/postcss/blob/HEAD/CHANGELOG.md#858)

[Compare
Source](https://redirect.github.com/postcss/postcss/compare/8.5.7...8.5.8)

- Fixed `Processor#version`.

###
[`v8.5.7`](https://redirect.github.com/postcss/postcss/blob/HEAD/CHANGELOG.md#857)

[Compare
Source](https://redirect.github.com/postcss/postcss/compare/8.5.6...8.5.7)

- Improved source map annotation cleaning performance (by CodeAnt AI).

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDEuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE0MS4zIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 11:32:36 +08:00
renovate[bot] df482c9cf2 chore: bump up uuid version to v14 [SECURITY] (#14870)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [uuid](https://redirect.github.com/uuidjs/uuid) | [`^13.0.0` →
`^14.0.0`](https://renovatebot.com/diffs/npm/uuid/13.0.0/14.0.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/uuid/14.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/uuid/13.0.0/14.0.0?slim=true)
|

---

### uuid: Missing buffer bounds check in v3/v5/v6 when buf is provided

[GHSA-w5hq-g745-h8pq](https://redirect.github.com/advisories/GHSA-w5hq-g745-h8pq)

<details>
<summary>More information</summary>

#### Details
##### Summary

`v3`, `v5`, and `v6` accept external output buffers but do not reject
out-of-range writes (small `buf` or large `offset`).
By contrast, `v4`, `v1`, and `v7` explicitly throw `RangeError` on
invalid bounds.

This inconsistency allows **silent partial writes** into caller-provided
buffers.

##### Affected code

- `src/v35.ts` (`v3`/`v5` path) writes `buf[offset + i]` without bounds
validation.
- `src/v6.ts` writes `buf[offset + i]` without bounds validation.

##### Reproducible PoC

```bash
cd /home/StrawHat/uuid
npm ci
npm run build

node --input-type=module -e "
import {v4,v5,v6} from './dist-node/index.js';
const ns='6ba7b810-9dad-11d1-80b4-00c04fd430c8';
for (const [name,fn] of [
  ['v4',()=>v4({},new Uint8Array(8),4)],
  ['v5',()=>v5('x',ns,new Uint8Array(8),4)],
  ['v6',()=>v6({},new Uint8Array(8),4)],
]) {
  try { fn(); console.log(name,'NO_THROW'); }
  catch(e){ console.log(name,'THREW',e.name); }
}"
```

Observed:

- `v4 THREW RangeError`
- `v5 NO_THROW`
- `v6 NO_THROW`

Example partial overwrite evidence captured during audit:

```text
same true buf [
  170, 170, 170, 170,
   75, 224, 100,  63
]
v6 [
  187, 187, 187, 187,
   31,  19, 185,  64
]
```

##### Security impact

- **Primary**: integrity/robustness issue (silent partial output).
- If an application assumes full UUID writes into preallocated buffers,
this can produce malformed/truncated/partially stale identifiers without
error.
- In systems where caller-controlled offsets/buffer sizes are exposed
indirectly, this may become a security-relevant logic flaw.

##### Suggested fix

Add the same guard used by `v4`/`v1`/`v7`:

```ts
if (offset < 0 || offset + 16 > buf.length) {
  throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
}
```

Apply to:

- `src/v35.ts` (covers `v3` and `v5`)
- `src/v6.ts`

#### Severity
- CVSS Score: 6.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N`

#### References
-
[https://github.com/uuidjs/uuid/security/advisories/GHSA-w5hq-g745-h8pq](https://redirect.github.com/uuidjs/uuid/security/advisories/GHSA-w5hq-g745-h8pq)
-
[https://github.com/uuidjs/uuid/commit/3d2c5b0342f0fcb52a5ac681c3d47c13e7444b34](https://redirect.github.com/uuidjs/uuid/commit/3d2c5b0342f0fcb52a5ac681c3d47c13e7444b34)
-
[https://github.com/uuidjs/uuid/releases/tag/v14.0.0](https://redirect.github.com/uuidjs/uuid/releases/tag/v14.0.0)
-
[https://github.com/advisories/GHSA-w5hq-g745-h8pq](https://redirect.github.com/advisories/GHSA-w5hq-g745-h8pq)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-w5hq-g745-h8pq)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>uuidjs/uuid (uuid)</summary>

###
[`v14.0.0`](https://redirect.github.com/uuidjs/uuid/blob/HEAD/CHANGELOG.md#1400-2026-04-19)

[Compare
Source](https://redirect.github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0)

##### Security

- Fixes
[GHSA-w5hq-g745-h8pq](https://redirect.github.com/uuidjs/uuid/security/advisories/GHSA-w5hq-g745-h8pq):
`v3()`, `v5()`, and `v6()` did not validate that writes would remain
within the bounds of a caller-supplied buffer, allowing out-of-bounds
writes when an invalid `offset` was provided. A `RangeError` is now
thrown if `offset < 0` or `offset + 16 > buf.length`.

##### ⚠ BREAKING CHANGES

- `crypto` is now expected to be globally defined (requires
node\@&#8203;20+)
([#&#8203;935](https://redirect.github.com/uuidjs/uuid/issues/935))
- drop node\@&#8203;18 support
([#&#8203;934](https://redirect.github.com/uuidjs/uuid/issues/934))
- upgrade minimum supported TypeScript version to 5.4.3, in keeping with
the project's policy of supporting TypeScript versions released within
the last two years

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzkuNCIsInVwZGF0ZWRJblZlciI6IjQzLjEzOS40IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-26 00:48:53 +08:00
Abdul Rehman 2caf3c86f8 fix(editor): prevent popMenu overflow on constrained viewports (#14827)
Fixes #14722

## Summary

`popMenu()` in
`blocksuite/affine/components/src/context-menu/menu-renderer.ts` uses
`autoPlacement` + `offset` in its default middleware, but no `shift()` —
so when `autoPlacement` picks a placement that would overflow the
viewport (e.g. database column menu opening near the top of a short
viewport), the menu stays overflowing and top items get clipped above
the viewport.

## Fix

Add `shift({ padding: 8 })` to the default middleware chain.

This matches the behavior of the sibling helper `createPopup()` in the
same file, which already includes `shift()` in its defaults.

## Reproducing (as reported in #14722)

Viewport ~879×461 (Chrome, macOS). Create a database block near the top
of the viewport → click a column name → menu opens upward and the top
items ("Rename", "Filter") get clipped above the viewport.



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

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced context menu positioning on desktop to provide better
alignment and spacing adjustments near screen boundaries.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-21 18:00:33 +08:00
renovate[bot] 557b1e4dfc chore: bump up eslint-plugin-oxlint version to v1.60.0 (#14853)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[eslint-plugin-oxlint](https://redirect.github.com/oxc-project/eslint-plugin-oxlint)
| [`1.58.0` →
`1.60.0`](https://renovatebot.com/diffs/npm/eslint-plugin-oxlint/1.58.0/1.60.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-oxlint/1.60.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-oxlint/1.58.0/1.60.0?slim=true)
|

---

### Release Notes

<details>
<summary>oxc-project/eslint-plugin-oxlint
(eslint-plugin-oxlint)</summary>

###
[`v1.60.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.60.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.59.0...v1.60.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.59.0...v1.60.0)

###
[`v1.59.0`](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/releases/tag/v1.59.0)

[Compare
Source](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.58.0...v1.59.0)

*No significant changes*

#####     [View changes on
GitHub](https://redirect.github.com/oxc-project/eslint-plugin-oxlint/compare/v1.58.0...v1.59.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuOCIsInVwZGF0ZWRJblZlciI6IjQzLjEyMy44IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 19:18:29 +08:00
renovate[bot] cc79fa3c6d chore: bump up opentelemetry (#14844)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@opentelemetry/api](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/api)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`1.9.0` →
`1.9.1`](https://renovatebot.com/diffs/npm/@opentelemetry%2fapi/1.9.0/1.9.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fapi/1.9.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fapi/1.9.0/1.9.1?slim=true)
|
|
[@opentelemetry/core](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-core)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`2.6.0` →
`2.7.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fcore/2.6.0/2.7.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fcore/2.7.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fcore/2.6.0/2.7.0?slim=true)
|
|
[@opentelemetry/exporter-prometheus](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-exporter-prometheus)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`^0.213.0` →
`^0.215.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-prometheus/0.213.0/0.215.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fexporter-prometheus/0.215.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fexporter-prometheus/0.213.0/0.215.0?slim=true)
|
|
[@opentelemetry/exporter-zipkin](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-exporter-zipkin)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`2.6.0` →
`2.7.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fexporter-zipkin/2.6.0/2.7.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fexporter-zipkin/2.7.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fexporter-zipkin/2.6.0/2.7.0?slim=true)
|
|
[@opentelemetry/instrumentation](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`^0.213.0` →
`^0.215.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation/0.213.0/0.215.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation/0.215.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation/0.213.0/0.215.0?slim=true)
|
|
[@opentelemetry/instrumentation-graphql](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-graphql#readme)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-graphql))
| [`^0.61.0` →
`^0.63.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-graphql/0.61.0/0.63.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-graphql/0.63.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-graphql/0.61.0/0.63.0?slim=true)
|
|
[@opentelemetry/instrumentation-http](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-http)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`^0.213.0` →
`^0.215.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-http/0.213.0/0.215.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-http/0.215.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-http/0.213.0/0.215.0?slim=true)
|
|
[@opentelemetry/instrumentation-ioredis](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-ioredis#readme)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-ioredis))
| [`^0.61.0` →
`^0.63.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-ioredis/0.61.0/0.63.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-ioredis/0.63.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-ioredis/0.61.0/0.63.0?slim=true)
|
|
[@opentelemetry/instrumentation-nestjs-core](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-nestjs-core#readme)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-nestjs-core))
| [`^0.59.0` →
`^0.61.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-nestjs-core/0.59.0/0.61.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-nestjs-core/0.61.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-nestjs-core/0.59.0/0.61.0?slim=true)
|
|
[@opentelemetry/instrumentation-socket.io](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-socket.io#readme)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-socket.io))
| [`^0.60.0` →
`^0.62.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-socket.io/0.60.0/0.62.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-socket.io/0.62.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-socket.io/0.60.0/0.62.0?slim=true)
|
|
[@opentelemetry/resources](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-resources)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`2.6.0` →
`2.7.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fresources/2.6.0/2.7.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fresources/2.7.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fresources/2.6.0/2.7.0?slim=true)
|
|
[@opentelemetry/sdk-metrics](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/packages/sdk-metrics)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`2.6.0` →
`2.7.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsdk-metrics/2.6.0/2.7.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fsdk-metrics/2.7.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fsdk-metrics/2.6.0/2.7.0?slim=true)
|
|
[@opentelemetry/sdk-node](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-sdk-node)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`^0.213.0` →
`^0.215.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsdk-node/0.213.0/0.215.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fsdk-node/0.215.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fsdk-node/0.213.0/0.215.0?slim=true)
|
|
[@opentelemetry/sdk-trace-node](https://redirect.github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js))
| [`2.6.0` →
`2.7.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2fsdk-trace-node/2.6.0/2.7.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2fsdk-trace-node/2.7.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2fsdk-trace-node/2.6.0/2.7.0?slim=true)
|

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js
(@&#8203;opentelemetry/api)</summary>

###
[`v1.9.1`](https://redirect.github.com/open-telemetry/opentelemetry-js/blob/HEAD/CHANGELOG.md#191)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js/compare/v1.9.0...v1.9.1)

##### 🐛 (Bug Fix)

- fix: avoid grpc types dependency
[#&#8203;3551](https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3551)
[@&#8203;flarna](https://redirect.github.com/flarna)
- fix(otlp-proto-exporter-base): Match Accept header with Content-Type
in the proto exporter

[#&#8203;3562](https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3562)
[@&#8203;scheler](https://redirect.github.com/scheler)
- fix: include tracestate in export
[#&#8203;3569](https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3569)
[@&#8203;flarna](https://redirect.github.com/flarna)

##### 🏠 (Internal)

- chore: fix cross project links and missing implicitly exported types
[#&#8203;3533](https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3533)
[@&#8203;legendecas](https://redirect.github.com/legendecas)
- feat(sdk-metrics): add exponential histogram mapping functions
[#&#8203;3504](https://redirect.github.com/open-telemetry/opentelemetry-js/pull/3504)
[@&#8203;mwear](https://redirect.github.com/mwear)

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib
(@&#8203;opentelemetry/instrumentation-graphql)</summary>

###
[`v0.63.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-graphql/CHANGELOG.md#0630-2026-04-17)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/ed97091c9890dd18e52759f2ea98e9d7593b3ae4...bd017c86bcdf369d7bc1b490e455f95b25385779)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3479](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3479))
([8891261](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/8891261cb590efcb661bd9f8afec4d1adf885ad8))

###
[`v0.62.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-graphql/CHANGELOG.md#0620-2026-03-25)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/39f08c313dc4d929c110ab7c43771c3cdbf8aa4c...ed97091c9890dd18e52759f2ea98e9d7593b3ae4)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3450](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3450))
([c8df394](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/c8df394f02d68ae48a79a50258682c09dac13b8b))

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib
(@&#8203;opentelemetry/instrumentation-ioredis)</summary>

###
[`v0.63.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-ioredis/CHANGELOG.md#0630-2026-04-17)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/ed97091c9890dd18e52759f2ea98e9d7593b3ae4...bd017c86bcdf369d7bc1b490e455f95b25385779)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3479](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3479))
([8891261](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/8891261cb590efcb661bd9f8afec4d1adf885ad8))

##### Bug Fixes

- **redis-common:** expand redaction to include ACL, CONFIG, PSETEX,
GETSET
([#&#8203;3472](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3472))
([39193ca](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/39193cac4124eedc9e8fa5ae16ba960b5ab7a36b))

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
-
[@&#8203;opentelemetry/redis-common](https://redirect.github.com/opentelemetry/redis-common)
bumped from ^0.38.2 to ^0.38.3
  - devDependencies
-
[@&#8203;opentelemetry/contrib-test-utils](https://redirect.github.com/opentelemetry/contrib-test-utils)
bumped from ^0.61.0 to ^0.62.0

###
[`v0.62.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-ioredis/CHANGELOG.md#0620-2026-03-25)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/39f08c313dc4d929c110ab7c43771c3cdbf8aa4c...ed97091c9890dd18e52759f2ea98e9d7593b3ae4)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3450](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3450))
([c8df394](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/c8df394f02d68ae48a79a50258682c09dac13b8b))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
-
[@&#8203;opentelemetry/contrib-test-utils](https://redirect.github.com/opentelemetry/contrib-test-utils)
bumped from ^0.60.0 to ^0.61.0

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib
(@&#8203;opentelemetry/instrumentation-nestjs-core)</summary>

###
[`v0.61.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-nestjs-core/CHANGELOG.md#0610-2026-04-17)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/ed97091c9890dd18e52759f2ea98e9d7593b3ae4...bd017c86bcdf369d7bc1b490e455f95b25385779)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3479](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3479))
([8891261](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/8891261cb590efcb661bd9f8afec4d1adf885ad8))

###
[`v0.60.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-nestjs-core/CHANGELOG.md#0600-2026-03-25)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/39f08c313dc4d929c110ab7c43771c3cdbf8aa4c...ed97091c9890dd18e52759f2ea98e9d7593b3ae4)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3450](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3450))
([c8df394](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/c8df394f02d68ae48a79a50258682c09dac13b8b))

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib
(@&#8203;opentelemetry/instrumentation-socket.io)</summary>

###
[`v0.62.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-socket.io/CHANGELOG.md#0620-2026-04-17)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/ed97091c9890dd18e52759f2ea98e9d7593b3ae4...bd017c86bcdf369d7bc1b490e455f95b25385779)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3479](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3479))
([8891261](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/8891261cb590efcb661bd9f8afec4d1adf885ad8))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
-
[@&#8203;opentelemetry/contrib-test-utils](https://redirect.github.com/opentelemetry/contrib-test-utils)
bumped from ^0.61.0 to ^0.62.0

###
[`v0.61.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-socket.io/CHANGELOG.md#0610-2026-03-25)

[Compare
Source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/compare/39f08c313dc4d929c110ab7c43771c3cdbf8aa4c...ed97091c9890dd18e52759f2ea98e9d7593b3ae4)

##### Features

- **deps:** update deps matching '@&#8203;opentelemetry/\*'
([#&#8203;3450](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3450))
([c8df394](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/commit/c8df394f02d68ae48a79a50258682c09dac13b8b))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
-
[@&#8203;opentelemetry/contrib-test-utils](https://redirect.github.com/opentelemetry/contrib-test-utils)
bumped from ^0.60.0 to ^0.61.0

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuOCIsInVwZGF0ZWRJblZlciI6IjQzLjEyMy44IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 16:20:32 +08:00
Whitewater 3428ac478e chore: split i18n and bs-docs dirty checks in typecheck workflow (#14849)
## Summary

Split generated-file validation in the `typecheck` workflow so i18n
outputs and BS docs outputs are checked separately.

This fixes a misleading CI failure message: previously, CI could fail
due to i18n-generated changes like
`packages/frontend/i18n/src/i18n.gen.ts`, but only suggested running
`yarn typecheck && yarn affine bs-docs build`, which does not regenerate
those files.

## Changes

- validate i18n-generated changes immediately after `yarn affine
@affine/i18n build`
- keep ignoring `packages/frontend/i18n/src/i18n-completenesses.json` in
CI as before
- leave `yarn typecheck` as a separate step
- make the BS docs step only check for changes introduced by `yarn
affine bs-docs build`

## Result

CI now gives the correct remediation command depending on which
generated files are out of date:
- i18n issues: `yarn affine @affine/i18n build`
- docs/typecheck issues: `yarn typecheck && yarn affine bs-docs build`

## Testing

- verified the updated workflow YAML parses successfully


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

## Summary by CodeRabbit

* **Chores**
* Enhanced build validation for internationalization code generation to
ensure generated changes are properly committed before proceeding. The
validation now occurs immediately after code generation runs, providing
faster feedback during the build process.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-19 02:12:00 +08:00
Saurabh Pardeshi 0009f91d2a feat(editor): add "Copy as Markdown" option in context & export menus (#14705)
- Allow users to select text and copy it as Markdown via the context
menu
- Add "Copy as Markdown" under Export menu to copy entire document to
clipboard

Fixes #12983

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

* **New Features**
* Added "Copy as Markdown" to the toolbar clipboard More menu for
selected content.
* Added "Copy as Markdown" to the page export menu to copy entire pages
as Markdown.

* **Behavior**
* Export flow now returns success/failure so the UI shows a dedicated
success or error notification for clipboard exports.

* **Localization**
  * Added strings for "Copy as Markdown" and "Copied as Markdown".
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Whitewater <me@waterwater.moe>
Co-authored-by: lawvs <18554747+lawvs@users.noreply.github.com>
2026-04-18 20:39:20 +08:00
renovate[bot] f7d0f1d5ae chore: bump up Node.js to v22.22.2 (#14836)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [node](https://nodejs.org)
([source](https://redirect.github.com/nodejs/node)) | patch | `22.22.1`
→ `22.22.2` |

---

### Release Notes

<details>
<summary>nodejs/node (node)</summary>

###
[`v22.22.2`](https://redirect.github.com/nodejs/node/compare/v22.22.1...v22.22.2)

[Compare
Source](https://redirect.github.com/nodejs/node/compare/v22.22.1...v22.22.2)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjMuOCIsInVwZGF0ZWRJblZlciI6IjQzLjEyMy44IiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 23:59:24 +08:00
renovate[bot] 0849b342fa chore: bump up dompurify version to v3.4.0 [SECURITY] (#14833)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [dompurify](https://redirect.github.com/cure53/DOMPurify) | [`3.3.3` →
`3.4.0`](https://renovatebot.com/diffs/npm/dompurify/3.3.3/3.4.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/dompurify/3.4.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/dompurify/3.3.3/3.4.0?slim=true)
|

### GitHub Vulnerability Alerts

####
[GHSA-39q2-94rc-95cp](https://redirect.github.com/cure53/DOMPurify/security/advisories/GHSA-39q2-94rc-95cp)

## Summary
In `src/purify.ts:1117-1123`, `ADD_TAGS` as a function (via
`EXTRA_ELEMENT_HANDLING.tagCheck`) bypasses `FORBID_TAGS` due to
short-circuit evaluation.

The condition:
```
!(tagCheck(tagName)) && (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName])
```
When `tagCheck(tagName)` returns `true`, the entire condition is `false`
and the element is kept — `FORBID_TAGS[tagName]` is never evaluated.

## Inconsistency
This contradicts the attribute-side pattern at line 1214 where
`FORBID_ATTR` explicitly wins first:
```
if (FORBID_ATTR[lcName]) { continue; }
```
For tags, FORBID should also take precedence over ADD.

## Impact
Applications using both `ADD_TAGS` as a function and `FORBID_TAGS`
simultaneously get unexpected behavior — forbidden tags are allowed
through. Config-dependent but a genuine logic inconsistency.

## Suggested Fix
Check `FORBID_TAGS` before `tagCheck`:
```
if (FORBID_TAGS[tagName]) { /* remove */ }
else if (tagCheck(tagName) || ALLOWED_TAGS[tagName]) { /* keep */ }
```

## Affected Version
v3.3.3 (commit 883ac15)

##### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String:
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N`

---

### Release Notes

<details>
<summary>cure53/DOMPurify (dompurify)</summary>

###
[`v3.4.0`](https://redirect.github.com/cure53/DOMPurify/releases/tag/3.4.0):
DOMPurify 3.4.0

[Compare
Source](https://redirect.github.com/cure53/DOMPurify/compare/3.3.3...3.4.0)

**Most relevant changes:**

- Fixed a problem with `FORBID_TAGS` not winning over `ADD_TAGS`, thanks
[@&#8203;kodareef5](https://redirect.github.com/kodareef5)
- Fixed several minor problems and typos regarding MathML attributes,
thanks [@&#8203;DavidOliver](https://redirect.github.com/DavidOliver)
- Fixed `ADD_ATTR`/`ADD_TAGS` function leaking into subsequent
array-based calls, thanks
[@&#8203;1Jesper1](https://redirect.github.com/1Jesper1)
- Fixed a missing `SAFE_FOR_TEMPLATES` scrub in `RETURN_DOM` path,
thanks [@&#8203;bencalif](https://redirect.github.com/bencalif)
- Fixed a prototype pollution via `CUSTOM_ELEMENT_HANDLING`, thanks
[@&#8203;trace37labs](https://redirect.github.com/trace37labs)
- Fixed an issue with `ADD_TAGS` function form bypassing `FORBID_TAGS`,
thanks [@&#8203;eddieran](https://redirect.github.com/eddieran)
- Fixed an issue with `ADD_ATTR` predicates skipping URI validation,
thanks [@&#8203;christos-eth](https://redirect.github.com/christos-eth)
- Fixed an issue with `USE_PROFILES` prototype pollution, thanks
[@&#8203;christos-eth](https://redirect.github.com/christos-eth)
- Fixed an issue leading to possible mXSS via Re-Contextualization,
thanks
[@&#8203;researchatfluidattacks](https://redirect.github.com/researchatfluidattacks)
and others
- Fixed a problem with the type dentition patcher after Node version
bump
- Fixed freezing BS runs by reducing the tested browsers array
- Bumped several dependencies where possible
- Added needed files for OpenSSF scorecard checks

**Published Advisories are here:**

<https://github.com/cure53/DOMPurify/security/advisories?state=published>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMiIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 19:53:00 +08:00
renovate[bot] dc3b95c886 chore: bump up Rust crate rand to v0.9.3 [SECURITY] (#14832)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [rand](https://rust-random.github.io/book)
([source](https://redirect.github.com/rust-random/rand)) | dependencies
| patch | `0.9.1` → `0.9.3` |
| [rand](https://rust-random.github.io/book)
([source](https://redirect.github.com/rust-random/rand)) |
workspace.dependencies | patch | `0.9.2` → `0.9.3` |

### GitHub Vulnerability Alerts

####
[GHSA-cq8v-f236-94qc](https://redirect.github.com/rust-random/rand/pull/1763)

It has been reported (by @&#8203;lopopolo) that the `rand` library is
[unsound](https://rust-lang.github.io/unsafe-code-guidelines/glossary.html#soundness-of-code--of-a-library)
(i.e. that safe code using the public API can cause Undefined Behaviour)
when all the following conditions are met:

- The `log` and `thread_rng` features are enabled
- A [custom
logger](https://docs.rs/log/latest/log/#implementing-a-logger) is
defined
- The custom logger accesses `rand::rng()` (previously
`rand::thread_rng()`) and calls any `TryRng` (previously `RngCore`)
methods on `ThreadRng`
- The `ThreadRng` (attempts to) reseed while called from the custom
logger (this happens every 64 kB of generated data)
- Trace-level logging is enabled or warn-level logging is enabled and
the random source (the `getrandom` crate) is unable to provide a new
seed

`TryRng` (previously `RngCore`) methods for `ThreadRng` use `unsafe`
code to cast `*mut BlockRng<ReseedingCore>` to `&mut
BlockRng<ReseedingCore>`. When all the above conditions are met this
results in an aliased mutable reference, violating the Stacked Borrows
rules. Miri is able to detect this violation in sample code. Since
construction of [aliased mutable references is Undefined
Behaviour](https://doc.rust-lang.org/stable/nomicon/references.html),
the behaviour of optimized builds is hard to predict.

Affected versions of `rand` are `>= 0.7, < 0.9.3` and `0.10.0`.

##### Severity
Low

---

### Release Notes

<details>
<summary>rust-random/rand (rand)</summary>

###
[`v0.9.3`](https://redirect.github.com/rust-random/rand/compare/0.9.2...0.9.3)

[Compare
Source](https://redirect.github.com/rust-random/rand/compare/0.9.2...0.9.3)

###
[`v0.9.2`](https://redirect.github.com/rust-random/rand/blob/HEAD/CHANGELOG.md#092---2025-07-20)

[Compare
Source](https://redirect.github.com/rust-random/rand/compare/0.9.1...0.9.2)

##### Deprecated

- Deprecate `rand::rngs::mock` module and `StepRng` generator
([#&#8203;1634](https://redirect.github.com/rust-random/rand/issues/1634))

##### Additions

- Enable `WeightedIndex<usize>` (de)serialization
([#&#8203;1646](https://redirect.github.com/rust-random/rand/issues/1646))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMiIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4yIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 19:52:25 +08:00
Abdul Rehman 1d66e7e8ca fix(editor): allow hyperlink clicks in locked edgeless text blocks (#14829)
Fixes #14673

## Summary

When an edgeless text block is locked, `pointer-events: none` on the
inner content div (`edgeless-text-block.ts:308`) blocks all mouse
interaction — including clicking hyperlinks. Locking is intended to
prevent accidental edits, not to block navigation, so links should
remain clickable.

## Fix

Apply a `locked-content` class on the inner div when the block is locked
and not being edited, and add a targeted CSS rule restoring
`pointer-events: auto` on anchor elements within locked content.

## Context

Re-implements the fix from PR #14692 (authored by @moktamd, reverted per
@darkskygit's comment on #14673 because the original contributor had not
signed the CLA). The CLA is signed for this PR.

## Test plan

- [ ] On edgeless canvas, create a text block with a hyperlink (e.g.
`[link](https://affine.pro)`)
- [ ] Lock the block via the shape toolbar
- [ ] Hover the link → cursor shows pointer
- [ ] Click the link → navigation occurs
- [ ] Unlock and confirm editing still works as before

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

## Summary by CodeRabbit

* **Bug Fixes**
* Links within locked text blocks are now interactive and clickable with
proper visual cursor feedback.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-15 17:31:48 +08:00
Hana B c5b0057778 fix(core): resolve remaining untranslated doc title case in detail page header (#14820)
fix #14735

This PR fixes a remaining desktop case related to #14467.

The previous fix resolved incorrect translation in navigation panels,
but the detail page header tab title was still passing custom document
titles through `i18n.t()`, causing user-defined titles to be
unexpectedly translated.

### Results


https://github.com/user-attachments/assets/4abad3b9-d5d7-442f-b643-6d9ea63fa741

After:
<img width="2100" height="1722" alt="After"
src="https://github.com/user-attachments/assets/0770eae2-e5c5-4816-8d53-e40a4b52800c"
/>

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

## Summary by CodeRabbit

* **Refactor**
* Updated page title retrieval mechanism in workspace detail page
headers. The title is now sourced directly from the document display
metadata service instead of using the previous derivation method.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-15 14:49:41 +08:00
DarkSky a109f069b0 chore: bump deps 2026-04-10 11:46:14 +08:00
879 changed files with 96632 additions and 29877 deletions
+68 -49
View File
@@ -135,17 +135,17 @@
},
"throttlers.default": {
"type": "object",
"description": "The config for the default throttler.\n@default {\"ttl\":60,\"limit\":120}",
"description": "The config for the default throttler.\n@default {\"ttl\":60000,\"limit\":120}",
"default": {
"ttl": 60,
"ttl": 60000,
"limit": 120
}
},
"throttlers.strict": {
"type": "object",
"description": "The config for the strict throttler.\n@default {\"ttl\":60,\"limit\":20}",
"description": "The config for the strict throttler.\n@default {\"ttl\":60000,\"limit\":20}",
"default": {
"ttl": 60,
"ttl": 60000,
"limit": 20
}
}
@@ -300,6 +300,22 @@
}
}
},
"permission": {
"type": "object",
"description": "Configuration for permission module",
"properties": {
"readModel": {
"type": "string",
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
"default": "projection"
},
"fallbackLegacyLoader": {
"type": "boolean",
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
"default": false
}
}
},
"storages": {
"type": "object",
"description": "Configuration for storages module",
@@ -353,7 +369,7 @@
"properties": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
"description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region."
},
"region": {
"type": "string",
@@ -420,10 +436,6 @@
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"properties": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
@@ -473,6 +485,13 @@
"type": "string",
"description": "The account id for the cloudflare r2 storage provider."
},
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
},
"usePresignedURL": {
"type": "object",
"description": "The presigned url config for the cloudflare r2 storage provider.",
@@ -548,7 +567,7 @@
"properties": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
"description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region."
},
"region": {
"type": "string",
@@ -615,10 +634,6 @@
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"properties": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
@@ -668,6 +683,13 @@
"type": "string",
"description": "The account id for the cloudflare r2 storage provider."
},
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
},
"usePresignedURL": {
"type": "object",
"description": "The presigned url config for the cloudflare r2 storage provider.",
@@ -855,11 +877,14 @@
"properties": {
"google": {
"type": "object",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"allowNewAccounts\":true,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
"properties": {
"enabled": {
"type": "boolean"
},
"allowNewAccounts": {
"type": "boolean"
},
"clientId": {
"type": "string"
},
@@ -878,6 +903,7 @@
},
"default": {
"enabled": false,
"allowNewAccounts": true,
"clientId": "",
"clientSecret": "",
"externalWebhookUrl": "",
@@ -985,23 +1011,25 @@
"description": "Whether to enable the copilot plugin. <br> Document: <a href=\"https://docs.affine.pro/self-host-affine/administer/ai\" target=\"_blank\">https://docs.affine.pro/self-host-affine/administer/ai</a>\n@default false",
"default": false
},
"scenarios": {
"type": "object",
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-5-mini\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
"default": {
"override_enabled": false,
"scenarios": {
"audio_transcribing": "gemini-2.5-flash",
"chat": "gemini-2.5-flash",
"embedding": "gemini-embedding-001",
"image": "gpt-image-1",
"coding": "claude-sonnet-4-5@20250929",
"complex_text_generation": "gpt-5-mini",
"quick_decision_making": "gpt-5-mini",
"quick_text_generation": "gemini-2.5-flash",
"polish_and_summarize": "gemini-2.5-flash"
}
}
"byok.enabled": {
"type": "boolean",
"description": "Whether to enable workspace BYOK.\n@default true",
"default": true
},
"byok.allowedProviders": {
"type": "array",
"description": "The allowlist for workspace BYOK providers.\n@default [\"openai\",\"anthropic\",\"gemini\",\"fal\"]",
"default": [
"openai",
"anthropic",
"gemini",
"fal"
]
},
"byok.allowCustomEndpoint": {
"type": "boolean",
"description": "Whether workspace BYOK custom endpoints are accepted.\n@default false",
"default": false
},
"providers.profiles": {
"type": "array",
@@ -1079,13 +1107,6 @@
},
"default": {}
},
"providers.perplexity": {
"type": "object",
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\"}",
"default": {
"apiKey": ""
}
},
"providers.anthropic": {
"type": "object",
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.anthropic.com/v1\"}",
@@ -1129,11 +1150,6 @@
},
"default": {}
},
"providers.morph": {
"type": "object",
"description": "The config for the morph provider.\n@default {}",
"default": {}
},
"unsplash": {
"type": "object",
"description": "The config for the unsplash key.\n@default {\"key\":\"\"}",
@@ -1192,7 +1208,7 @@
"properties": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
"description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region."
},
"region": {
"type": "string",
@@ -1259,10 +1275,6 @@
"type": "object",
"description": "The config for the S3 compatible storage provider.",
"properties": {
"endpoint": {
"type": "string",
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
},
"region": {
"type": "string",
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
@@ -1312,6 +1324,13 @@
"type": "string",
"description": "The account id for the cloudflare r2 storage provider."
},
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
},
"usePresignedURL": {
"type": "object",
"description": "The presigned url config for the cloudflare r2 storage provider.",
+9 -2
View File
@@ -114,13 +114,20 @@ jobs:
electron-install: false
full-cache: true
- name: Run i18n codegen
run: yarn affine @affine/i18n build
run: |
yarn affine @affine/i18n build
git checkout packages/frontend/i18n/src/i18n-completenesses.json
if git status --porcelain | grep -q .; then
echo "Run 'yarn affine @affine/i18n build' and make sure all generated i18n changes are submitted"
exit 1
else
echo "All generated i18n changes are submitted"
fi
- name: Run Type Check
run: yarn typecheck
- name: Run BS Docs Build
run: |
yarn affine bs-docs build
git checkout packages/frontend/i18n/src/i18n-completenesses.json
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
+1 -1
View File
@@ -1 +1 @@
22.22.1
22.22.3
+3 -1
View File
@@ -23,7 +23,7 @@
".github/helm",
".git",
".vscode",
".context/**/*.js",
".context",
".yarnrc.yml",
".docker",
"**/.storybook",
@@ -52,6 +52,8 @@
"packages/frontend/apps/ios/App/**",
"tests/blocksuite/snapshots",
"blocksuite/docs/api/**",
"blocksuite/docs-site/.vitepress/.temp/**",
"blocksuite/docs-site/api/**",
"packages/frontend/admin/src/config.json",
"**/test-docs.json",
"**/test-blocks.json"
+3 -1
View File
@@ -4,7 +4,7 @@
.github/helm
.git
.vscode
.context/**/*.js
.context
.yarnrc.yml
.docker
**/.storybook
@@ -39,6 +39,8 @@ packages/frontend/apps/android/App/**
packages/frontend/apps/ios/App/**
tests/blocksuite/snapshots
blocksuite/docs/api/**
blocksuite/docs-site/.vitepress/.temp/**
blocksuite/docs-site/api/**
packages/frontend/admin/src/config.json
**/test-docs.json
**/test-blocks.json
Generated
+922 -40
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -16,6 +16,7 @@ resolver = "3"
edition = "2024"
[workspace.dependencies]
aes-gcm = "0.10"
affine_common = { path = "./packages/common/native" }
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
ahash = "0.8"
@@ -39,6 +40,7 @@ resolver = "3"
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
hex = "0.4"
homedir = "0.3"
image = { version = "0.25.9", default-features = false, features = [
"bmp",
@@ -53,7 +55,8 @@ resolver = "3"
libc = "0.2"
libwebp-sys = "0.14.2"
little_exif = "0.6.23"
llm_adapter = { version = "0.1.4", default-features = false }
llm_adapter = { version = "0.2", default-features = false }
llm_runtime = { version = "0.2", default-features = false }
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
@@ -79,6 +82,7 @@ resolver = "3"
ogg = "0.9"
once_cell = "1"
ordered-float = "5"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
parking_lot = "0.12"
path-ext = "0.1.2"
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
@@ -93,9 +97,11 @@ resolver = "3"
readability = { version = "0.3.0", default-features = false }
regex = "1.10"
rubato = "0.16"
schemars = "0.8"
screencapturekit = "0.3"
serde = "1"
serde_json = "1"
sha2 = "0.10"
sha3 = "0.10"
smol_str = "0.3"
sqlx = { version = "0.8", default-features = false, features = [
@@ -104,7 +110,6 @@ resolver = "3"
"migrate",
"runtime-tokio",
"sqlite",
"tls-rustls",
] }
strum_macros = "0.27.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
@@ -165,3 +170,7 @@ strip = "symbols"
# android uniffi bindgen requires symbols
[profile.release.package.affine_mobile_native]
strip = "none"
# [patch.crates-io]
# llm_adapter = { path = "../llm_adapter/crates/llm_adapter" }
# llm_runtime = { path = "../llm_adapter/crates/llm_runtime" }
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'vitest';
import { bilibiliConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/bilibili.js';
import { excalidrawConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/excalidraw.js';
import { genericConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/generic.js';
import { googleDocsConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-docs.js';
import { googleDriveConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-drive.js';
import { miroConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/miro.js';
import { spotifyConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/spotify.js';
describe('embed iframe provider config', () => {
test('validates final iframe URLs from oEmbed providers', () => {
expect(
spotifyConfig.validateIframeUrl?.(
'https://open.spotify.com/embed/track/0TK2YIli7K1leLovkQiNik'
)
).toBe(true);
expect(
spotifyConfig.validateIframeUrl?.(
'https://example.com/embed/track/0TK2YIli7K1leLovkQiNik'
)
).toBe(false);
});
test('validates provider-specific iframe URL shapes', () => {
expect(
googleDriveConfig.validateIframeUrl?.(
'https://drive.google.com/file/d/file-id/preview?usp=embed_googleplus'
)
).toBe(true);
expect(
googleDriveConfig.validateIframeUrl?.(
'https://drive.google.com/drive/folders/folder-id?usp=sharing'
)
).toBe(false);
expect(
bilibiliConfig.validateIframeUrl?.(
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
)
).toBe(true);
expect(
bilibiliConfig.match(
'https://player.bilibili.com/player.html?aid=123&autoplay=0'
)
).toBe(true);
expect(
bilibiliConfig.buildOEmbedUrl(
'https://player.bilibili.com/video/BV1xx411c7mD'
)
).toBe(
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
);
expect(
bilibiliConfig.validateIframeUrl?.(
'https://www.bilibili.com/video/BV1xx411c7mD'
)
).toBe(false);
expect(
googleDocsConfig.validateIframeUrl?.(
'https://docs.google.com/document/d/doc-id/edit?usp=sharing'
)
).toBe(true);
expect(
miroConfig.validateIframeUrl?.(
'https://miro.com/app/live-embed/board-id/'
)
).toBe(true);
expect(
excalidrawConfig.validateIframeUrl?.('https://excalidraw.com/#room-id')
).toBe(true);
});
test('generic iframe validation excludes affine and non-https URLs', () => {
expect(genericConfig.validateIframeUrl?.('https://example.com/embed')).toBe(
true
);
expect(genericConfig.validateIframeUrl?.('http://example.com/embed')).toBe(
false
);
expect(
genericConfig.validateIframeUrl?.('https://app.affine.pro/embed')
).toBe(false);
expect(genericConfig.validateIframeUrl?.('https://127.0.0.1/embed')).toBe(
false
);
expect(genericConfig.validateIframeUrl?.('https://localhost/embed')).toBe(
false
);
});
});
@@ -39,10 +39,7 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
private readonly _loadTheme = async (
highlighter: HighlighterCore
): Promise<void> => {
// It is possible that by the time the highlighter is ready all instances
// have already been unmounted. In that case there is no need to load
// themes or update state.
if (CodeBlockHighlighter._refCount === 0) {
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
return;
}
@@ -51,7 +48,17 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
this._darkThemeKey = (await normalizeGetter(darkTheme)).name;
this._lightThemeKey = (await normalizeGetter(lightTheme)).name;
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
return;
}
await highlighter.loadTheme(darkTheme, lightTheme);
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
return;
}
this.highlighter$.value = highlighter;
};
@@ -83,30 +90,18 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
}
override unmounted(): void {
CodeBlockHighlighter._refCount--;
CodeBlockHighlighter._refCount = Math.max(
0,
CodeBlockHighlighter._refCount - 1
);
this.highlighter$.value = null;
}
// Dispose the shared highlighter **after** any in-flight creation finishes.
if (CodeBlockHighlighter._refCount !== 0) {
return;
}
const doDispose = (highlighter: HighlighterCore | null) => {
if (highlighter) {
highlighter.dispose();
}
CodeBlockHighlighter._sharedHighlighter = null;
CodeBlockHighlighter._highlighterPromise = null;
};
if (CodeBlockHighlighter._sharedHighlighter) {
// Highlighter already created dispose immediately.
doDispose(CodeBlockHighlighter._sharedHighlighter);
} else if (CodeBlockHighlighter._highlighterPromise) {
// Highlighter still being created wait for it, then dispose.
CodeBlockHighlighter._highlighterPromise
.then(doDispose)
.catch(console.error);
}
private static _isHighlighterInUse(highlighter: HighlighterCore) {
return (
CodeBlockHighlighter._refCount > 0 &&
CodeBlockHighlighter._sharedHighlighter === highlighter
);
}
}
@@ -51,6 +51,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
return modelPreview;
});
collapsed$: Signal<boolean> = computed(
() => !!this.model.props.collapsed$.value
);
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
languageName$: Signal<string> = computed(() => {
@@ -417,6 +421,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
);
const shouldRenderPreview = preview && previewContext;
const collapsed = this.collapsed$.value;
return html`
<div
@@ -426,6 +431,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
mobile: IS_MOBILE,
wrap: this.model.props.wrap,
'disable-line-numbers': !showLineNumbers,
collapsed,
})}
>
<rich-text
@@ -453,9 +459,12 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
}}
>
</rich-text>
${collapsed
? html`<div class="code-collapsed-fade" aria-hidden="true"></div>`
: nothing}
<div
style=${styleMap({
display: shouldRenderPreview ? undefined : 'none',
display: shouldRenderPreview && !collapsed ? undefined : 'none',
})}
contenteditable="false"
class="affine-code-block-preview"
@@ -471,6 +480,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
this.store.updateBlock(this.model, { wrap });
}
setCollapsed(collapsed: boolean) {
this.store.updateBlock(this.model, { collapsed });
}
@query('rich-text')
private accessor _richTextElement: RichText | null = null;
@@ -9,6 +9,7 @@ import { WithDisposable } from '@blocksuite/global/lit';
import { noop } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
@@ -108,6 +109,17 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
this.closeCurrentMenu();
}
override connectedCallback() {
super.connectedCallback();
// Mirror the collapsed$ signal from the block component into local @state
// so this LitElement re-renders when it changes.
this.disposables.add(
effect(() => {
this._collapsed = this.context.blockComponent.collapsed$.value;
})
);
}
override render() {
return html`
<editor-toolbar class="code-toolbar-container" data-without-bg>
@@ -136,6 +148,9 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
@state()
private accessor _moreMenuOpen = false;
@state()
private accessor _collapsed = false;
@property({ attribute: false })
accessor context!: CodeBlockToolbarContext;
@@ -1,9 +1,11 @@
import {
CancelWrapIcon,
CaptionIcon,
CollapseCodeIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandCodeIcon,
WrapIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
@@ -85,6 +87,38 @@ export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
};
},
},
{
type: 'collapse',
when: ({ doc }) => !doc.readonly,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.setCollapsed(!blockComponent.collapsed$.value);
},
render: item => {
const collapsed = blockComponent.collapsed$.value;
const icon = collapsed ? ExpandCodeIcon : CollapseCodeIcon;
const label = collapsed ? 'Expand code' : 'Collapse code';
return html`
<editor-icon-button
class="code-toolbar-button collapse"
aria-label=${label}
.tooltip=${label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${icon}
</editor-icon-button>
`;
},
};
},
},
{
type: 'caption',
label: 'Caption',
@@ -80,4 +80,35 @@ export const codeBlockStyles = css`
affine-code .affine-code-block-preview {
padding: 12px;
}
/* ── Collapsed state ──────────────────────────────────────────────── */
/* Clamp the rich-text to the first 8 lines */
.affine-code-block-container.collapsed rich-text {
display: block;
max-height: calc(8 * var(--affine-line-height));
overflow: hidden;
}
/* Reduce bottom padding so the fade sits flush with the border */
.affine-code-block-container.collapsed {
padding-bottom: 0;
}
/* Gradient overlay that fades to the block background */
.affine-code-block-container .code-collapsed-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background: linear-gradient(
to bottom,
transparent,
var(--affine-background-code-block)
);
border-radius: 0 0 10px 10px;
pointer-events: none;
z-index: 1;
}
`;
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
dataSource: this.dataSource,
headerWidget: this.headerWidget,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
import {
DatabaseKanbanViewIcon,
DatabaseTableViewIcon,
TodayIcon,
} from '@blocksuite/icons/lit';
import { insertDatabaseBlockCommand } from '../commands';
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
},
},
{
name: 'Calendar View',
description: 'Display items by date in a calendar.',
searchAlias: ['database', 'calendar'],
icon: TodayIcon(),
group: '7_Database@1',
when: ({ model }) =>
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertDatabaseBlockCommand, {
viewType: viewPresets.calendarViewMeta.type,
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedDatabaseBlockId }) => {
if (insertedDatabaseBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:database',
});
}
})
.run();
},
},
{
name: 'Kanban View',
description: 'Visualize data in a dashboard.',
@@ -34,6 +34,7 @@ import {
type SingleView,
uniMap,
} from '@blocksuite/data-view';
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
import { IS_MOBILE } from '@blocksuite/global/env';
import { Rect } from '@blocksuite/global/gfx';
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
config
);
});
this.std.provider
.getAll(CalendarExternalSourceProvider)
.forEach(source => {
dataSource.serviceSet(
CalendarExternalSourceProvider(source.id),
source
);
});
});
const id = currentViewStorage.getCurrentView(this.model.id);
if (id && dataSource.viewManager.viewGet(id)) {
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
calendar: [
widgetPresets.tools.filter,
widgetPresets.tools.search,
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
});
private readonly viewSelection$ = computed(() => {
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
headerWidget: this.headerWidget,
onDrag: this.onDrag,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
export const databaseBlockViews: ViewMeta[] = [
viewPresets.tableViewMeta,
viewPresets.kanbanViewMeta,
viewPresets.calendarViewMeta,
];
export const databaseBlockViewMap = Object.fromEntries(
@@ -43,6 +43,11 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
font-weight: var(--edgeless-text-font-weight);
text-align: var(--edgeless-text-text-align);
}
.edgeless-text-block-container .locked-content a[href] {
pointer-events: auto;
cursor: pointer;
}
`;
private readonly _resizeObserver = new ResizeObserver(() => {
@@ -304,6 +309,7 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
style=${styleMap(containerStyle)}
>
<div
class=${!editing && this.model.isLocked() ? 'locked-content' : ''}
style=${styleMap({
pointerEvents: editing ? 'auto' : 'none',
userSelect: editing ? 'auto' : 'none',
@@ -35,7 +35,7 @@ const extractBvid = (url: string) => {
const buildBiliPlayerEmbedUrl = (url: string) => {
// If the user pasted the embed URL directly, keep it
if (validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
if (isValidBiliPlayerUrl(url)) {
return url;
}
const avid = extractAvid(url);
@@ -57,13 +57,31 @@ const buildBiliPlayerEmbedUrl = (url: string) => {
return undefined;
};
const bilibiliConfig = {
function isValidBiliPlayerUrl(url: string) {
try {
if (!validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
return false;
}
const parsedUrl = new URL(url);
return (
parsedUrl.pathname === '/player.html' &&
(!!parsedUrl.searchParams.get('aid') ||
!!parsedUrl.searchParams.get('bvid'))
);
} catch {
return false;
}
}
export const bilibiliConfig = {
name: 'bilibili',
match: (url: string) =>
validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
(!!extractAvid(url) || !!extractBvid(url)),
isValidBiliPlayerUrl(url) ||
(validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
(!!extractAvid(url) || !!extractBvid(url))),
buildOEmbedUrl: buildBiliPlayerEmbedUrl,
useOEmbedUrlDirectly: true,
validateIframeUrl: (iframeUrl: string) => isValidBiliPlayerUrl(iframeUrl),
options: {
widthInSurface: BILIBILI_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: BILIBILI_DEFAULT_HEIGHT_IN_SURFACE,
@@ -15,7 +15,7 @@ const excalidrawUrlValidationOptions: EmbedIframeUrlValidationOptions = {
hostnames: ['excalidraw.com'],
};
const excalidrawConfig = {
export const excalidrawConfig = {
name: 'excalidraw',
match: (url: string) =>
validateEmbedIframeUrl(url, excalidrawUrlValidationOptions),
@@ -27,6 +27,8 @@ const excalidrawConfig = {
return url;
},
useOEmbedUrlDirectly: true,
validateIframeUrl: (iframeUrl: string) =>
validateEmbedIframeUrl(iframeUrl, excalidrawUrlValidationOptions),
options: {
widthInSurface: EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE,
@@ -1,5 +1,10 @@
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
import {
type EmbedIframeUrlValidationOptions,
validateEmbedIframeUrl,
} from '../../utils';
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
@@ -17,6 +22,11 @@ const AFFINE_DOMAINS = [
'apple.getaffineapp.com', // Cloud domain for Apple app
];
const genericUrlValidationOptions: EmbedIframeUrlValidationOptions = {
protocols: ['https:'],
hostnames: [],
};
/**
* Validates if a URL is suitable for generic iframe embedding
* Allows HTTPS URLs but excludes AFFiNE domains
@@ -27,8 +37,12 @@ function isValidGenericEmbedUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
// Only allow HTTPS for security
if (parsedUrl.protocol !== 'https:') {
if (
!validateEmbedIframeUrl(url, {
...genericUrlValidationOptions,
hostnames: [parsedUrl.hostname],
})
) {
return false;
}
@@ -49,7 +63,7 @@ function isValidGenericEmbedUrl(url: string): boolean {
}
}
const genericConfig = {
export const genericConfig = {
name: 'generic',
match: (url: string) => isValidGenericEmbedUrl(url),
buildOEmbedUrl: (url: string) => {
@@ -59,6 +73,7 @@ const genericConfig = {
return url;
},
useOEmbedUrlDirectly: true,
validateIframeUrl: (iframeUrl: string) => isValidGenericEmbedUrl(iframeUrl),
options: {
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
@@ -57,7 +57,7 @@ function isValidGoogleDocsUrl(url: string, strictMode = true): boolean {
}
}
const googleDocsConfig = {
export const googleDocsConfig = {
name: 'google-docs',
match: (url: string) => isValidGoogleDocsUrl(url),
buildOEmbedUrl: (url: string) => {
@@ -67,6 +67,7 @@ const googleDocsConfig = {
return url;
},
useOEmbedUrlDirectly: true,
validateIframeUrl: (iframeUrl: string) => isValidGoogleDocsUrl(iframeUrl),
options: {
widthInSurface: GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE,
@@ -113,6 +113,29 @@ function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
}
}
function isValidGoogleDriveIframeUrl(url: string): boolean {
try {
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
return false;
}
const parsedUrl = new URL(url);
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
if (isValidGoogleDriveFileUrl(parsedUrl)) {
return pathSegments[3] === 'preview';
}
return (
parsedUrl.pathname === '/embeddedfolderview' &&
!!parsedUrl.searchParams.get('id')
);
} catch (e) {
console.warn('Invalid Google Drive iframe URL:', e);
return false;
}
}
/**
* Build embed URL for Google Drive files
* @param fileId File ID
@@ -171,7 +194,7 @@ function buildGoogleDriveEmbedUrl(url: string): string | undefined {
}
}
const googleDriveConfig = {
export const googleDriveConfig = {
name: 'google-drive',
match: (url: string) => isValidGoogleDriveUrl(url),
buildOEmbedUrl: (url: string) => {
@@ -183,6 +206,8 @@ const googleDriveConfig = {
return buildGoogleDriveEmbedUrl(url);
},
useOEmbedUrlDirectly: true,
validateIframeUrl: (iframeUrl: string) =>
isValidGoogleDriveIframeUrl(iframeUrl),
options: {
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
@@ -18,7 +18,7 @@ const miroUrlValidationOptions: EmbedIframeUrlValidationOptions = {
hostnames: ['miro.com'],
};
const miroConfig = {
export const miroConfig = {
name: 'miro',
match: (url: string) => validateEmbedIframeUrl(url, miroUrlValidationOptions),
buildOEmbedUrl: (url: string) => {
@@ -31,6 +31,12 @@ const miroConfig = {
return oEmbedUrl;
},
useOEmbedUrlDirectly: false,
validateIframeUrl: (iframeUrl: string) => {
if (!validateEmbedIframeUrl(iframeUrl, miroUrlValidationOptions)) {
return false;
}
return new URL(iframeUrl).pathname.startsWith('/app/live-embed/');
},
options: {
widthInSurface: MIRO_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: MIRO_DEFAULT_HEIGHT_IN_SURFACE,
@@ -18,7 +18,12 @@ const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
hostnames: ['open.spotify.com', 'spotify.link'],
};
const spotifyConfig = {
const spotifyIframeUrlValidationOptions: EmbedIframeUrlValidationOptions = {
protocols: ['https:'],
hostnames: ['open.spotify.com'],
};
export const spotifyConfig = {
name: 'spotify',
match: (url: string) =>
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
@@ -32,6 +37,13 @@ const spotifyConfig = {
return oEmbedUrl;
},
useOEmbedUrlDirectly: false,
validateIframeUrl: (iframeUrl: string) => {
if (!validateEmbedIframeUrl(iframeUrl, spotifyIframeUrlValidationOptions)) {
return false;
}
const parsedUrl = new URL(iframeUrl);
return parsedUrl.pathname.split('/').find(Boolean) === 'embed';
},
options: {
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
@@ -141,7 +141,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
});
return;
}
window.open(link, '_blank');
window.open(link, '_blank', 'noopener,noreferrer');
};
refreshData = async () => {
@@ -183,6 +183,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
// update model
const iframeUrl = this._getIframeUrl(embedData) ?? currentIframeUrl;
if (!this._validateIframeUrl(url, iframeUrl)) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'Invalid embed iframe url'
);
}
this.store.updateBlock(this.model, {
iframeUrl,
title: embedData?.title || previewData?.title,
@@ -291,6 +297,19 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
}
};
private readonly _validateIframeUrl = (url: string, iframeUrl?: string) => {
if (!iframeUrl) {
return false;
}
const config = this.embedIframeService?.getConfig(url);
if (!config) {
return false;
}
return config.validateIframeUrl
? config.validateIframeUrl(iframeUrl, url)
: config.match(iframeUrl);
};
private readonly _handleDoubleClick = () => {
this.open();
};
@@ -329,6 +348,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
private readonly _renderIframe = () => {
const { iframeUrl } = this.model.props;
if (!iframeUrl || !this._isIframeUrlAllowed(iframeUrl)) {
return html`<embed-iframe-error-card
.error=${new Error('Invalid iframe URL')}
.model=${this.model}
.onRetry=${this._handleRetry}
.std=${this.std}
.inSurface=${this.inSurface}
.options=${this._statusCardOptions}
></embed-iframe-error-card>`;
}
const {
widthPercent,
heightInNote,
@@ -368,6 +397,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
: nothing}`;
};
private readonly _isIframeUrlAllowed = (iframeUrl: string) => {
return this._validateIframeUrl(this.model.props.url, iframeUrl);
};
private readonly _getSourceHost = () => {
const url = this.model.props.url ?? this.model.props.iframeUrl;
if (!url) return null;
@@ -437,7 +470,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
} else {
// update iframe options, to ensure the iframe is rendered with the correct options
this._updateIframeOptions(this.model.props.url);
this.status$.value = 'success';
this.status$.value = this._validateIframeUrl(
this.model.props.url,
this.model.props.iframeUrl
)
? 'success'
: 'error';
}
// refresh data when original url changes
@@ -9,6 +9,25 @@ export interface EmbedIframeUrlValidationOptions {
hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
}
function isLocalOrIpHostname(hostname: string): boolean {
const lower = hostname.toLowerCase();
if (
lower === 'localhost' ||
lower.endsWith('.localhost') ||
lower === '0.0.0.0' ||
lower === '::' ||
lower === '::1'
) {
return true;
}
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(lower)) {
return true;
}
return lower.startsWith('[') && lower.endsWith(']');
}
/**
* Validate the url is allowed to embed in the iframe
* @param url URL to validate
@@ -23,6 +42,15 @@ export function validateEmbedIframeUrl(
const parsedUrl = new URL(url);
const { protocols, hostnames } = options;
if (
parsedUrl.username ||
parsedUrl.password ||
parsedUrl.port ||
isLocalOrIpHostname(parsedUrl.hostname)
) {
return false;
}
return (
protocols.includes(parsedUrl.protocol) &&
hostnames.includes(parsedUrl.hostname)
+1 -1
View File
@@ -9,7 +9,7 @@ export const latexBlockStyles = css`
height: 100%;
padding: 10px 24px;
flex-direction: column;
align-items: center;
align-items: stretch;
justify-content: center;
border-radius: 4px;
overflow-x: auto;
@@ -121,6 +121,38 @@ export const updateBlockType: Command<
}
return next({ updatedBlocks: [newModel] });
};
const transformToLatex: Command<{}, { updatedBlocks: BlockModel[] }> = (
_,
next
) => {
if (flavour !== 'affine:latex') return;
const newModels: BlockModel[] = [];
blockModels.forEach(model => {
if (
!matchModels(model, [
ParagraphBlockModel,
ListBlockModel,
CodeBlockModel,
])
) {
return;
}
const latex = model.text?.toString() ?? '';
const newId = transformModel(model, 'affine:latex', { latex });
if (!newId) {
return;
}
const newModel = doc.getModelById(newId);
if (newModel) {
newModels.push(newModel);
}
});
if (newModels.length === 0) return;
return next({ updatedBlocks: newModels });
};
const focusText: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => {
const { updatedBlocks } = ctx;
@@ -185,6 +217,27 @@ export const updateBlockType: Command<
});
return next();
};
const selectBlocks: Command<{ updatedBlocks: BlockModel[] }> = (
ctx,
next
) => {
const { updatedBlocks } = ctx;
if (!updatedBlocks || updatedBlocks.length === 0) {
return false;
}
requestAnimationFrame(() => {
host.selection.setGroup(
'note',
updatedBlocks.map(model =>
host.selection.create(BlockSelection, {
blockId: model.id,
})
)
);
});
return next();
};
const [result, resultCtx] = std.command
.chain()
@@ -196,6 +249,7 @@ export const updateBlockType: Command<
.try<{ updatedBlocks: BlockModel[] }>(chain => [
chain.pipe(mergeToCode),
chain.pipe(appendDivider),
chain.pipe(transformToLatex),
chain.pipe((_, next) => {
const newModels: BlockModel[] = [];
blockModels.forEach(model => {
@@ -227,6 +281,14 @@ export const updateBlockType: Command<
])
// focus
.try(chain => [
chain
.pipe((_, next) => {
if (flavour === 'affine:latex') {
return next();
}
return false;
})
.pipe(selectBlocks),
chain.pipe((_, next) => {
if (['affine:code', 'affine:divider'].includes(flavour)) {
return next();
@@ -30,6 +30,7 @@
"@blocksuite/affine-gfx-pointer": "workspace:*",
"@blocksuite/affine-gfx-shape": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-inline-latex": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
@@ -15,6 +15,7 @@ import {
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
import { toast } from '@blocksuite/affine-components/toast';
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
import {
deleteTextCommand,
formatBlockCommand,
@@ -61,6 +62,7 @@ import {
DeleteIcon,
DuplicateIcon,
LinkedPageIcon,
TeXIcon,
} from '@blocksuite/icons/lit';
import {
type BlockComponent,
@@ -199,9 +201,9 @@ const alignActionGroup = {
const inlineTextActionGroup = {
id: 'b.inline-text',
when: ({ chain }) => isFormatSupported(chain).run()[0],
actions: textFormatConfigs.map(
actions: textFormatConfigs.flatMap(
({ id, name, action, activeWhen, icon }, score) => {
return {
const textAction: ToolbarAction = {
id,
icon,
score,
@@ -209,6 +211,28 @@ const inlineTextActionGroup = {
run: ({ host }) => action(host),
active: ({ host }) => activeWhen(host),
};
if (id !== 'underline') {
return [textAction];
}
return [
textAction,
{
id: 'inline-latex',
icon: TeXIcon(),
score: score + 0.5,
tooltip: 'Inline Equation',
run: ({ host }) => {
host.std.command
.chain()
.pipe(getTextSelectionCommand)
.pipe(insertInlineLatex)
.run();
},
active: () => false,
},
];
}
),
} as const satisfies ToolbarActionGroup;
@@ -27,6 +27,7 @@
{ "path": "../../gfx/pointer" },
{ "path": "../../gfx/shape" },
{ "path": "../../gfx/text" },
{ "path": "../../inlines/latex" },
{ "path": "../../inlines/preset" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
@@ -168,6 +168,8 @@ export class DomRenderer {
pendingUpdates: new Map(),
};
private readonly _pendingElements = new Map<string, SurfaceElementModel>();
private _lastViewportBounds: Bound | null = null;
private _lastZoom: number | null = null;
private _lastUsePlaceholder: boolean = false;
@@ -184,6 +186,8 @@ export class DomRenderer {
provider: Partial<EnvProvider>;
private readonly _surfaceModel: SurfaceBlockModel;
usePlaceholder = false;
viewport: Viewport;
@@ -204,6 +208,7 @@ export class DomRenderer {
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
this._surfaceModel = options.surfaceModel;
this._turboEnabled = () => {
const featureFlagService = options.std.get(FeatureFlagService);
@@ -367,7 +372,11 @@ export class DomRenderer {
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this._markElementDirty(
payload.id,
UpdateType.ELEMENT_ADDED,
payload as unknown as SurfaceElementModel
);
this._markViewportDirty();
this.refresh();
})
@@ -381,7 +390,11 @@ export class DomRenderer {
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(payload => {
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
this._markElementDirty(
payload.model.id,
UpdateType.ELEMENT_UPDATED,
payload.model as unknown as SurfaceElementModel
);
if (payload.props['index'] || payload.props['groupId']) {
this._markViewportDirty();
}
@@ -522,8 +535,22 @@ export class DomRenderer {
this.refresh();
};
private _markElementDirty(elementId: string, updateType: UpdateType) {
private _markElementDirty(
elementId: string,
updateType: UpdateType,
elementModel?: SurfaceElementModel
) {
this._updateState.dirtyElementIds.add(elementId);
if (updateType === UpdateType.ELEMENT_REMOVED) {
this._pendingElements.delete(elementId);
} else {
const model =
elementModel ?? this._surfaceModel.getElementById(elementId);
if (model) {
this._pendingElements.set(elementId, model as SurfaceElementModel);
}
}
const currentUpdates =
this._updateState.pendingUpdates.get(elementId) || [];
if (!currentUpdates.includes(updateType)) {
@@ -572,6 +599,51 @@ export class DomRenderer {
return this._lastUsePlaceholder !== this.usePlaceholder;
}
private _elementInViewport(
elementModel: SurfaceElementModel,
viewportBounds: Bound
) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
return (
display && intersects(getBoundWithRotation(elementModel), viewportBounds)
);
}
private _getPendingElementsInViewport(viewportBounds: Bound) {
const elements: SurfaceElementModel[] = [];
for (const [id, elementModel] of this._pendingElements) {
this._pendingElements.delete(id);
if (this._elementInViewport(elementModel, viewportBounds)) {
elements.push(elementModel);
}
}
return elements;
}
private _getElementsInViewport(viewportBounds: Bound) {
const elements = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const elementsById = new Map<string, SurfaceElementModel>();
for (const elementModel of elements) {
if (this._elementInViewport(elementModel, viewportBounds)) {
elementsById.set(elementModel.id, elementModel);
this._pendingElements.delete(elementModel.id);
}
}
for (const elementModel of this._getPendingElementsInViewport(
viewportBounds
)) {
elementsById.set(elementModel.id, elementModel);
}
return Array.from(elementsById.values());
}
private _updateLastState() {
const { viewportBounds, zoom } = this.viewport;
this._lastViewportBounds = {
@@ -604,41 +676,33 @@ export class DomRenderer {
}
// Only update dirty elements
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const elementsInViewport = this._getElementsInViewport(viewportBounds);
const visibleElementIds = new Set<string>();
// 1. Update dirty elements
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
for (const elementModel of elementsInViewport) {
visibleElementIds.add(elementModel.id);
// Only update dirty elements
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
// Only update dirty elements
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
}
@@ -677,59 +741,32 @@ export class DomRenderer {
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
// Step 1: Handle elements whose models are deleted from the surface
const prevRenderedElementIds = Array.from(this._elementsMap.keys());
for (const id of prevRenderedElementIds) {
const modelExists = this.layerManager.layers.some(layer =>
layer.elements.some(elem => (elem as SurfaceElementModel).id === id)
);
if (!modelExists) {
const domElem = this._elementsMap.get(id);
if (domElem) {
domElem.remove();
this._elementsMap.delete(id);
elementsToRemove.push(domElem);
}
}
}
// Step 2: Render elements in the current viewport
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const elementsInViewport = this._getElementsInViewport(viewportBounds);
const visibleElementIds = new Set<string>();
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
for (const elementModel of elementsInViewport) {
visibleElementIds.add(elementModel.id);
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
// Full render
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
// Step 3: Remove DOM elements that are in _elementsMap but were not processed in Step 2
const currentRenderedElementIds = Array.from(this._elementsMap.keys());
for (const id of currentRenderedElementIds) {
if (!visibleElementIds.has(id)) {
@@ -744,7 +781,6 @@ export class DomRenderer {
}
}
// Step 4: Notify about changes
if (addedElements.length > 0 || elementsToRemove.length > 0) {
this.elementsUpdated.next({
elements: Array.from(this._elementsMap.values()),
@@ -33,6 +33,22 @@ export class SelectionController implements ReactiveController {
this.host.handleEvent('copy', this.onCopy);
this.host.handleEvent('cut', this.onCut);
this.host.handleEvent('paste', this.onPaste);
this.host.handleEvent('dragStart', context => {
if (IS_MOBILE || this.dataManager.readonly$.value) return false;
const event = context.get('pointerState').raw;
const target = event.target;
if (
target instanceof Element &&
target.closest(
'[data-width-adjust-column-id], [data-drag-column-id], [data-drag-row-id]'
)
) {
event.preventDefault();
event.stopPropagation();
return true;
}
return false;
});
}
private get dataManager() {
return this.host.dataManager;
@@ -84,6 +100,17 @@ export class SelectionController implements ReactiveController {
if (IS_MOBILE || this.dataManager.readonly$.value) {
return;
}
this.host.disposables.addFromEvent(this.host, 'pointerdown', event => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (
target.closest(
'[data-width-adjust-column-id], [data-drag-column-id], [data-drag-row-id]'
)
) {
event.stopPropagation();
}
});
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.inputRef.select();
if (!this.data.disableAutoFocus) {
this.inputRef.select();
}
});
});
}
@@ -223,6 +225,7 @@ export const menuInputItems = {
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
disableAutoFocus?: boolean;
class?: string;
style?: Readonly<StyleInfo>;
}) =>
@@ -237,6 +240,7 @@ export const menuInputItems = {
onComplete: config.onComplete,
onChange: config.onChange,
onBlur: config.onBlur,
disableAutoFocus: config.disableAutoFocus,
};
const style = styleMap({
display: 'flex',
@@ -111,8 +111,10 @@ export class MenuComponent
}
const onBack = this.menu.options.title?.onBack;
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
this.menu.close();
onBack(this.menu);
const result = onBack(this.menu);
if (result !== false) {
this.menu.close();
}
return;
}
if (e.key === 'Enter' && !e.isComposing) {
@@ -214,8 +216,10 @@ export class MenuComponent
${title.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
const result = title.onBack?.(this.menu);
if (result !== false) {
this.menu.close();
}
}}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
@@ -555,6 +559,7 @@ export const popMenu = (
],
}),
offset(4),
shift({ padding: 8 }),
],
container: props.container,
placement: props.placement,
@@ -15,7 +15,7 @@ export type MenuOptions = {
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => void;
onBack?: (menu: Menu) => boolean | void;
onClose?: () => void;
postfix?: () => TemplateResult;
};
@@ -57,7 +57,7 @@ export class DatePicker extends WithDisposable(LitElement) {
private readonly _maxYear = 2099;
private readonly _minYear = 1970;
private readonly _minYear = 1000;
get _cardStyle() {
return {
@@ -286,8 +286,18 @@ export class DatePicker extends WithDisposable(LitElement) {
</div>`;
}
private _clampCursorYear() {
const year = this._cursor.getFullYear();
if (year < this._minYear) {
this._cursor = new Date(this._minYear, 0, 1);
} else if (year > this._maxYear) {
this._cursor = new Date(this._maxYear, 11, 31);
}
}
private _moveMonth(offset: number) {
this._cursor.setMonth(this._cursor.getMonth() + offset);
this._clampCursorYear();
this._getMatrix();
}
@@ -420,6 +430,7 @@ export class DatePicker extends WithDisposable(LitElement) {
} else if (e.key === 'ArrowDown') {
this._cursor.setDate(this._cursor.getDate() + 7);
}
this._clampCursorYear();
this._getMatrix();
setTimeout(this.focusDateCell.bind(this));
}
@@ -265,6 +265,16 @@ export const CancelWrapIcon = icons.CancelWrapIcon({
height: '20',
});
export const CollapseCodeIcon = icons.CollapseIcon({
width: '20',
height: '20',
});
export const ExpandCodeIcon = icons.ToggleRightIcon({
width: '20',
height: '20',
});
// Attachment
export const ViewIcon = icons.ViewIcon({
@@ -24,8 +24,8 @@ const styles = css`
font-size: var(--affine-font-sm);
border-radius: 4px;
padding: 6px 12px;
color: var(--affine-white);
background: var(--affine-tooltip);
color: var(--affine-v2-tooltips-foreground, var(--affine-white));
background: var(--affine-v2-tooltips-background, var(--affine-tooltip));
overflow-wrap: anywhere;
white-space: normal;
@@ -40,6 +40,9 @@ const styles = css`
}
`;
const TOOLTIP_ARROW_COLOR =
'var(--affine-v2-tooltips-background, var(--affine-tooltip))';
// See http://apps.eky.hk/css-triangle-generator/
const TRIANGLE_HEIGHT = 6;
const triangleMap = {
@@ -47,25 +50,25 @@ const triangleMap = {
bottom: '-6px',
borderStyle: 'solid',
borderWidth: '6px 5px 0 5px',
borderColor: 'var(--affine-tooltip) transparent transparent transparent',
borderColor: `${TOOLTIP_ARROW_COLOR} transparent transparent transparent`,
},
right: {
left: '-6px',
borderStyle: 'solid',
borderWidth: '5px 6px 5px 0',
borderColor: 'transparent var(--affine-tooltip) transparent transparent',
borderColor: `transparent ${TOOLTIP_ARROW_COLOR} transparent transparent`,
},
bottom: {
top: '-6px',
borderStyle: 'solid',
borderWidth: '0 5px 6px 5px',
borderColor: 'transparent transparent var(--affine-tooltip) transparent',
borderColor: `transparent transparent ${TOOLTIP_ARROW_COLOR} transparent`,
},
left: {
right: '-6px',
borderStyle: 'solid',
borderWidth: '5px 0 5px 6px',
borderColor: 'transparent transparent transparent var(--affine-tooltip)',
borderColor: `transparent transparent transparent ${TOOLTIP_ARROW_COLOR}`,
},
};
@@ -0,0 +1,371 @@
import { describe, expect, it } from 'vitest';
import {
type CalendarEntry,
createCalendarMonthLayout,
getCalendarDayContentSlots,
getCalendarVisibleMonthRange,
} from '../view-presets/calendar/index.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
describe('calendar month layout', () => {
it('buckets single day entries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('splits range external entries across weeks', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Trip',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 6, span: 1 },
{ weekIndex: 2, startIndex: 0, span: 3 },
]);
});
it('treats all-day external midnight end as exclusive', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'All day',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
allDay: true,
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('treats row midnight end date as inclusive', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 2, startIndex: 5, span: 2 },
]);
});
it('clips range entries to visible month range', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Long trip',
startAt: day('2026-04-01'),
endAt: day('2026-06-30'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments[0]).toMatchObject({
weekIndex: 0,
startIndex: 0,
span: 7,
});
expect(layout.segments.at(-1)).toMatchObject({
weekIndex: layout.weeks.length - 1,
startIndex: 0,
span: 7,
});
});
it('pads month view to full weeks', () => {
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [],
});
expect(new Date(range.from).getDay()).toBe(0);
expect(new Date(range.to).getDay()).toBe(6);
expect(layout.days).toHaveLength(layout.weeks.length * 7);
});
it('keeps day buckets on local midnight across DST boundaries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'DST task',
startAt: day('2026-03-09'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(
layout.days.every(item => {
const date = new Date(item.date);
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
})
).toBe(true);
expect(
layout.days.find(item => item.date === day('2026-03-09'))?.entries
).toEqual([entry]);
});
it('keeps range segment offsets across DST boundaries', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'DST range',
startAt: day('2026-03-09'),
endAt: new Date('2026-03-10T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 1, span: 2 },
]);
});
it('keeps all same-day entries in the day bucket', () => {
const entries = Array.from(
{ length: 4 },
(_, index) =>
({
kind: 'row',
id: `database:row-${index}`,
sourceId: 'database',
rowId: `row-${index}`,
title: `Task ${index}`,
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
}) satisfies CalendarEntry
);
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toHaveLength(4);
});
it('assigns each overlapping range segment to its own slot', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:full-${index}`,
sourceId: 'workspace-calendar',
externalId: `full-${index}`,
title: `Full ${index}`,
startAt: day('2026-05-15'),
endAt: new Date('2026-05-17T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'external',
id: 'external:short',
sourceId: 'workspace-calendar',
externalId: 'short',
title: 'Short',
startAt: day('2026-05-18'),
endAt: new Date('2026-05-19T12:00:00').getTime(),
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
expect(getCalendarDayContentSlots(may15)).toBe(3);
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
expect(getCalendarDayContentSlots(may18)).toBe(1);
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
});
it('counts segment and same-day slots for drag preview placement', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:range-${index}`,
sourceId: 'workspace-calendar',
externalId: `range-${index}`,
title: `Range ${index}`,
startAt: day('2026-05-08'),
endAt: new Date('2026-05-09T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'row',
id: 'database:moving',
sourceId: 'database',
rowId: 'moving',
title: 'Moving',
startAt: day('2026-05-06'),
endAt: new Date('2026-05-08T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
},
{
kind: 'row',
id: 'database:single',
sourceId: 'database',
rowId: 'single',
title: 'Single',
startAt: day('2026-05-08'),
cardProperties: [],
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
});
it('splits row range entries across weeks with continuation metadata', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Project',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{
weekIndex: 1,
startIndex: 6,
span: 1,
startsBeforeWeek: false,
endsAfterWeek: true,
},
{
weekIndex: 2,
startIndex: 0,
span: 3,
startsBeforeWeek: true,
endsAfterWeek: false,
},
]);
});
it('skips range entries completely outside the visible month range', () => {
const entry = {
kind: 'external',
id: 'external:outside',
sourceId: 'workspace-calendar',
externalId: 'outside',
title: 'Outside',
startAt: day('2026-06-10'),
endAt: day('2026-06-12'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toEqual([]);
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
});
});
@@ -0,0 +1,812 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import { signal } from '@preact/signals-core';
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from '../core/data-source/base.js';
import {
CalendarSingleView,
type CalendarStoredViewData,
calendarViewModel,
} from '../view-presets/calendar/index.js';
import {
formatEntryTime,
openCalendarEntry,
} from '../view-presets/calendar/pc/actions.js';
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
import { viewConverts } from '../view-presets/convert.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
const createCalendarView = (options?: {
startColumnId?: string;
endColumnId?: string;
datePropertyType?: string;
rows?: string[];
filterValue?: string;
titleValue?: unknown;
linkedDocTitles?: Record<string, string>;
visiblePropertyIds?: string[];
externalFactories?: Map<unknown, unknown>;
}) => {
const rows = signal(options?.rows ?? ['row-1']);
const columns = signal(['title', 'date', 'end-date', 'status']);
const viewData = signal<CalendarStoredViewData>({
id: 'view-1',
name: 'Calendar',
mode: 'calendar',
filter: options?.filterValue
? {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: { type: 'ref', name: 'status' },
function: 'is',
args: [{ type: 'literal', value: options.filterValue }],
},
],
}
: {
type: 'group',
op: 'and',
conditions: [],
},
date: {
startColumnId: options?.startColumnId,
endColumnId: options?.endColumnId,
},
card: {
titleColumnId: 'title',
visiblePropertyIds: options?.visiblePropertyIds ?? [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
});
const values = new Map<string, unknown>([
['row-1:date', day('2026-05-15')],
['row-1:end-date', day('2026-05-17')],
['row-1:status', 'Done'],
['row-1:title', options?.titleValue ?? 'Task'],
['row-2:date', day('2026-05-16')],
['row-2:end-date', day('2026-05-14')],
['row-2:status', 'Todo'],
['row-2:title', 'Hidden'],
]);
const types = new Map<string, string>([
['title', 'title'],
['date', options?.datePropertyType ?? 'date'],
['end-date', 'date'],
['status', 'text'],
]);
const dataSource = {
rows$: rows,
properties$: columns,
readonly$: signal(false),
featureFlags$: signal({ enable_table_virtual_scroll: false }),
provider: {
getAll: () => options?.externalFactories ?? new Map(),
},
viewDataGet: () => viewData.value,
viewDataUpdate: (
_id: string,
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
) => {
viewData.value = { ...viewData.value, ...updater(viewData.value) };
},
cellValueGet: (rowId: string, propertyId: string) =>
values.get(`${rowId}:${propertyId}`),
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
values.set(`${rowId}:${propertyId}`, value);
},
rowAdd: () => {
const rowId = `row-${rows.value.length + 1}`;
rows.value = [...rows.value, rowId];
return rowId;
},
propertyTypeGet: (propertyId: string) => types.get(propertyId),
propertyNameGet: (propertyId: string) => propertyId,
propertyDataGet: () => ({}),
propertyReadonlyGet: () => false,
serviceGet: (key: unknown) => {
if (key !== DocDisplayMetaProvider) {
return null;
}
return {
title: (pageId: string, referenceInfo?: { title?: string }) =>
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
};
},
propertyMetaGet: (type: string) => ({
type,
config: {
rawValue: {
toJson: ({ value }: { value: unknown }) => {
const deltas =
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
if (!Array.isArray(deltas)) {
return value;
}
return deltas
.map(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
};
};
};
const pageId = item.attributes?.reference?.pageId;
if (
item.attributes?.reference?.type === 'LinkedPage' &&
typeof pageId === 'string'
) {
return (
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
);
}
return item.insert ?? '';
})
.join('');
},
fromJson: ({ value }: { value: unknown }) => value,
toString: ({ value }: { value: unknown }) =>
typeof value === 'string' ? value : '',
},
jsonValue: {
schema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
isEmpty: () => false,
type: () => undefined,
},
},
renderer: {},
}),
propertyAdd: () => {
columns.value = [...columns.value, 'created-date'];
types.set('created-date', 'date');
return 'created-date';
},
propertyCanDelete: () => true,
propertyCanDuplicate: () => true,
propertyTypeCanSet: () => true,
} as unknown as DataSource;
const manager = {
dataSource,
readonly$: signal(false),
};
return {
view: new CalendarSingleView(manager as any, 'view-1'),
viewData,
values,
types,
columns,
};
};
describe('CalendarSingleView', () => {
it('creates default view data without selecting a start date', () => {
const data = calendarViewModel.model.defaultData({
dataSource: {
properties$: signal(['title', 'date']),
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
},
} as any);
expect(data.date).toEqual({});
expect(data.card).toEqual({
titleColumnId: 'title',
visiblePropertyIds: [],
});
});
it('enters setup state without a start date property', () => {
const { view } = createCalendarView();
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state when start date column is not date', () => {
const { view } = createCalendarView({
startColumnId: 'date',
datePropertyType: 'text',
});
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state after date property deletion', () => {
const { view, columns } = createCalendarView({ startColumnId: 'date' });
columns.value = ['title', 'status'];
expect(view.dateMapping$.value.status).toBe('setup');
});
it('creates row entries after filtering rows', () => {
const { view } = createCalendarView({
startColumnId: 'date',
rows: ['row-1', 'row-2'],
filterValue: 'Done',
});
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
});
it('updates entry date after row date value changes', () => {
const { view, values } = createCalendarView({ startColumnId: 'date' });
values.set('row-1:date', day('2026-05-20'));
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
});
it('creates row range entries and falls back when end date is invalid', () => {
const { view } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
rows: ['row-1', 'row-2'],
});
expect(
view.rowEntries$.value.map(entry => [
entry.rowId,
entry.startAt,
entry.endAt,
])
).toEqual([
['row-1', day('2026-05-15'), day('2026-05-17')],
['row-2', day('2026-05-16'), undefined],
]);
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
});
it('moves row range while preserving duration', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.moveRowToDate('row-1', day('2026-05-20'));
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
});
it('resizes row range without crossing start and end', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
});
it('creates a row with default filter values and target date', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createRowOnDate(day('2026-05-25'));
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(view.emptyMonthHintDismissed$.value).toBe(true);
});
it('creates a dated linked-doc row', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
const title = values.get('row-2:title') as
| { toDelta?: () => unknown[] }
| undefined;
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(title?.toDelta?.()).toEqual([
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
]);
});
it('dismisses the empty month hint on the current calendar view', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
expect(view.emptyMonthHintDismissed$.value).toBe(false);
view.dismissEmptyMonthHint();
expect(view.emptyMonthHintDismissed$.value).toBe(true);
expect('ui' in viewData.value && viewData.value.ui).toEqual({
emptyMonthHintDismissed: true,
});
});
it('updates workspace calendar settings when legacy view data has no sources', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
viewData.value = {
...viewData.value,
sources: undefined as unknown as CalendarStoredViewData['sources'],
};
view.setWorkspaceCalendarEnabled(false);
expect(viewData.value.sources.workspaceCalendar).toEqual({
enabled: false,
});
});
it('enters setup state when legacy view data has no date config', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
viewData.value = {
...viewData.value,
date: undefined as unknown as CalendarStoredViewData['date'],
};
expect(view.dateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
expect(view.endDateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
});
it('generates card properties from visible property ids', () => {
const { view } = createCalendarView({
startColumnId: 'date',
visiblePropertyIds: ['status'],
});
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
{
propertyId: 'status',
value: 'Done',
},
]);
});
it('parses single linked doc id from title cell', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Linked doc title',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Linked doc title', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
});
it('uses normal title text for multiple linked doc titles', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc 1',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: 'Doc 2',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
});
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
});
it('merges linked doc placeholders with the following plain title text', () => {
const { view } = createCalendarView({
startColumnId: 'date',
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: { type: 'LinkedPage', pageId: 'doc-1' },
},
},
{ insert: 'How to use folder and Tags' },
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'How to use folder and Tags', linkedDoc: true },
]);
});
it('updates date mapping through setup APIs', () => {
const { view, viewData, values } = createCalendarView({
startColumnId: 'date',
});
view.moveRowToDate('row-1', day('2026-05-21'));
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
view.setDateColumn('date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'date'
);
expect(view.createDateColumn()).toBe('created-date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'created-date'
);
});
it('aggregates external source entries without mutating view data', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const anotherExternalEntry = {
kind: 'external',
id: 'external:2',
sourceId: 'another-source',
externalId: '2',
title: 'Another external',
startAt: day('2026-05-16'),
canResizeRange: false,
} as const;
const { view, viewData } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'another-source',
{
create: () => ({
id: 'another-source',
getEntries: () => Promise.resolve([anotherExternalEntry]),
}),
},
],
]),
});
const viewDataBefore = JSON.stringify(viewData.value);
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry, anotherExternalEntry]);
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
});
it('keeps successful external entries when another source fails', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'failing-source',
{
create: () => ({
id: 'failing-source',
getEntries: () => Promise.reject(new Error('denied')),
}),
},
],
]),
});
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry]);
});
it('does not let stale external entry loads overwrite newer entries', async () => {
const oldEntry = {
kind: 'external',
id: 'external:old',
sourceId: 'source',
externalId: 'old',
title: 'Old',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const newEntry = {
kind: 'external',
id: 'external:new',
sourceId: 'source',
externalId: 'new',
title: 'New',
startAt: day('2026-06-15'),
canResizeRange: false,
} as const;
let resolveOld!: (entries: [typeof oldEntry]) => void;
let resolveNew!: (entries: [typeof newEntry]) => void;
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
resolveOld = resolve;
});
const newRequest = new Promise<[typeof newEntry]>(resolve => {
resolveNew = resolve;
});
const getEntries = vi
.fn()
.mockReturnValueOnce(oldRequest)
.mockReturnValueOnce(newRequest);
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries,
}),
},
],
]),
});
const firstLoad = view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
});
const secondLoad = view.loadExternalEntries({
from: day('2026-06-01'),
to: day('2026-06-30'),
});
resolveNew([newEntry]);
await expect(secondLoad).resolves.toEqual([newEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
resolveOld([oldEntry]);
await expect(firstLoad).resolves.toEqual([oldEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
});
});
describe('calendar entry actions', () => {
it('formats external event popover time ranges with end time', () => {
const label = formatEntryTime({
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Planning',
startAt: new Date('2026-05-15T10:00:00').getTime(),
endAt: new Date('2026-05-15T11:00:00').getTime(),
canResizeRange: false,
});
expect(label).toContain(' - ');
expect(label).toContain('2026');
});
it('opens row entries through the detail panel hook', () => {
const openDetailPanel = vi.fn();
const { view } = createCalendarView({ startColumnId: 'date' });
const target = {} as HTMLElement;
openCalendarEntry(
{ openDetailPanel } as any,
view,
{
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Doc',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
},
target
);
expect(openDetailPanel).toHaveBeenCalledWith(
expect.objectContaining({ view, rowId: 'row-1' })
);
});
});
describe('calendar view converts', () => {
it('converts header/card semantics without date mapping', () => {
const tableToCalendar = viewConverts.find(
convert => convert.from === 'table' && convert.to === 'calendar'
);
const calendarToKanban = viewConverts.find(
convert => convert.from === 'calendar' && convert.to === 'kanban'
);
const filter = { type: 'group', op: 'and', conditions: [] } as const;
const sort = { columns: [] };
const header = { titleColumn: 'title' };
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: [] },
});
expect(
calendarToKanban?.convert({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
date: { startColumnId: 'date' },
} as any)
).toEqual({ filter, sort, header });
});
});
describe('calendar dnd payload', () => {
it('reads calendar entry payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
})
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
});
it('normalizes affine doc entities for future document drops', () => {
expect(
getCalendarDndEntity({
entity: { type: 'doc', id: 'doc-1' },
})
).toEqual({ type: 'doc', docId: 'doc-1' });
});
it('reads document payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
).toEqual({ type: 'doc', docId: 'doc-1' });
});
});
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { multiSelectPropertyType } from '../property-presets/multi-select/define.js';
import { selectPropertyType } from '../property-presets/select/define.js';
import { TableHotkeysController } from '../view-presets/table/pc/controller/hotkeys.js';
import { TableHotkeysController as VirtualHotkeysController } from '../view-presets/table/pc-virtual/controller/hotkeys.js';
import {
@@ -7,6 +9,11 @@ import {
TableViewRowSelection,
} from '../view-presets/table/selection';
const TAG_COLUMN_TYPES = [
selectPropertyType.type,
multiSelectPropertyType.type,
] as const;
function createLogic() {
const view = {
rowsDelete: vi.fn(),
@@ -66,7 +73,10 @@ describe('TableHotkeysController', () => {
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column: { valueSetFromString: vi.fn() },
column: {
valueSetFromString: vi.fn(),
type$: { value: 'text' },
},
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
@@ -85,6 +95,41 @@ describe('TableHotkeysController', () => {
expect(selectionController.selection.isEditing).toBe(true);
expect(evt.preventDefault).toHaveBeenCalled();
});
it.each(TAG_COLUMN_TYPES)(
'stages draft for %s column instead of valueSetFromString',
columnType => {
const { logic, selectionController } = createLogic();
const ctrl = new TableHotkeysController(logic as any);
ctrl.hostConnected();
const setTagDraft = vi.fn();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column: {
valueSetFromString: vi.fn(),
type$: { value: columnType },
},
setTagDraft,
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 0, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'C',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column.valueSetFromString).not.toHaveBeenCalled();
expect(setTagDraft).toHaveBeenCalledWith('C');
expect(selectionController.selection.isEditing).toBe(true);
}
);
});
describe('Virtual TableHotkeysController', () => {
@@ -95,7 +140,12 @@ describe('Virtual TableHotkeysController', () => {
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column$: { value: { valueSetFromString: vi.fn() } },
column$: {
value: {
valueSetFromString: vi.fn(),
type$: { value: 'text' },
},
},
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
@@ -117,4 +167,41 @@ describe('Virtual TableHotkeysController', () => {
expect(selectionController.selection.isEditing).toBe(true);
expect(evt.preventDefault).toHaveBeenCalled();
});
it.each(TAG_COLUMN_TYPES)(
'stages draft for %s column instead of valueSetFromString',
columnType => {
const { logic, selectionController } = createLogic();
const ctrl = new VirtualHotkeysController(logic as any);
ctrl.hostConnected();
const setTagDraft = vi.fn();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column$: {
value: {
valueSetFromString: vi.fn(),
type$: { value: columnType },
},
},
setTagDraft,
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 1, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'C',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column$.value.valueSetFromString).not.toHaveBeenCalled();
expect(setTagDraft).toHaveBeenCalledWith('C');
expect(selectionController.selection.isEditing).toBe(true);
}
);
});
@@ -69,8 +69,20 @@ export type TagManagerOptions = {
options: ReadonlySignal<SelectTag[]>;
onOptionsChange: (options: SelectTag[]) => void;
onComplete?: () => void;
initialDraftText?: string;
};
// parent elements that can consume tag draft
const TABLE_CELL_HOST_SELECTOR =
'dv-table-view-cell-container, affine-database-virtual-cell-container';
export function consumeTagDraftFromTableCellHost(
fromElement: Element
): string | undefined {
const host = fromElement.closest(TABLE_CELL_HOST_SELECTOR) as any;
return host?.consumeTagDraft?.();
}
class TagManager {
changeTag = (option: Partial<SelectTag>) => {
this.ops.onOptionsChange(
@@ -427,6 +439,15 @@ export class MultiTagSelect extends SignalWatcher(
);
}
override connectedCallback() {
super.connectedCallback();
const draft = this.initialDraftText;
if (draft != null && draft !== '') {
this.tagManager.text$.value = draft;
this.initialDraftText = undefined;
}
}
protected override firstUpdated() {
const disposables = this.disposables;
this.classList.add(tagSelectContainerStyle);
@@ -471,6 +492,9 @@ export class MultiTagSelect extends SignalWatcher(
@property({ attribute: false })
accessor value!: ReadonlySignal<string[]>;
@property({ attribute: false })
accessor initialDraftText: string | undefined;
}
declare global {
@@ -481,6 +505,9 @@ declare global {
const popMobileTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
const tagManager = new TagManager(ops);
if (ops.initialDraftText) {
tagManager.text$.value = ops.initialDraftText;
}
const onInput = (e: InputEvent) => {
tagManager.text$.value = (e.target as HTMLInputElement).value;
};
@@ -604,6 +631,7 @@ export const popTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
component.onChange = ops.onChange;
component.options = ops.options;
component.onOptionsChange = ops.onOptionsChange;
component.initialDraftText = ops.initialDraftText;
component.onComplete = () => {
ops.onComplete?.();
remove();
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type Clipboard,
type DndController,
type EventName,
ShadowlessElement,
type UIEventHandler,
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
export type DataViewRendererConfig = {
clipboard: Clipboard;
dnd?: DndController;
onDrag?: (evt: MouseEvent, id: string) => () => void;
notification: {
toast: (message: string) => void;
@@ -2,15 +2,10 @@ import {
dropdownSubMenuMiddleware,
menu,
type MenuConfig,
type MenuOptions,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit';
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
}
export const selectGroupByProperty = (
export const buildGroupSelectItems = (
group: GroupTrait,
ops?: {
onSelect?: (id?: string) => void;
onClose?: () => void;
onBack?: () => void;
}
): MenuOptions => {
onSelect: (id?: string) => void
): MenuConfig[] => {
const view = group.view;
return {
onClose: ops?.onClose,
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
items: [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property => {
return menu.action({
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(property.id);
ops?.onSelect?.(property.id);
},
});
}),
}),
menu.group({
items: [
return [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property =>
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(undefined);
ops?.onSelect?.();
group.changeGroup(property.id);
onSelect(property.id);
return false;
},
}),
],
}),
],
};
})
),
}),
menu.group({
items: [
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
select: () => {
group.changeGroup(undefined);
onSelect(undefined);
return false;
},
}),
],
}),
];
};
export const popSelectGroupByProperty = (
target: PopupTarget,
export const buildGroupSettingItems = (
group: GroupTrait,
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
middleware?: Array<Middleware | null | undefined | false>
) => {
const handler = popMenu(target, {
options: selectGroupByProperty(group, ops),
middleware,
});
handler.menu.menuElement.style.minHeight = '550px';
};
export const popGroupSetting = (
target: PopupTarget,
group: GroupTrait,
onBack: () => void,
onClose?: () => void,
middleware?: Array<Middleware | null | undefined | false>
) => {
onGroupByClick: () => void,
onGroupRemoved?: () => void
): MenuConfig[] => {
const view = group.view;
const gProp = group.property$.value;
if (!gProp) return;
if (!gProp) return [];
const type = gProp.type$.value;
if (!type) return;
if (!type) return [];
const icon = gProp.icon;
const menuHandler = popMenu(target, {
options: {
title: {
text: 'Group',
onBack,
onClose,
},
items: [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
const subHandler = popMenu(target, {
options: selectGroupByProperty(group, {
onSelect: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onBack: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onClose,
}),
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
return [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
onGroupByClick();
return false;
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name === key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
@@ -462,179 +452,118 @@ export const popGroupSetting = (
return false;
},
})
)
),
],
},
}),
]),
],
}),
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config
.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name ===
key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value
? 'Oldest first'
: 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menuObj => html`
<data-view-group-setting
@mouseenter=${() => menuObj.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menu => html`
<data-view-group-setting
@mouseenter=${() => menu.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
return false;
},
}),
],
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
onGroupRemoved?.();
return false;
},
}),
],
},
middleware,
});
menuHandler.menu.menuElement.style.minHeight = '550px';
}),
];
};
@@ -2,7 +2,10 @@ import { popupTargetFromElement } from '@blocksuite/affine-components/context-me
import { computed } from '@preact/signals-core';
import { html } from 'lit/static-html.js';
import { popTagSelect } from '../../core/component/tags/multi-tag-select.js';
import {
consumeTagDraftFromTableCellHost,
popTagSelect,
} from '../../core/component/tags/multi-tag-select.js';
import type { SelectTag } from '../../core/index.js';
import { BaseCellRenderer } from '../../core/property/index.js';
import { createFromBaseCellRenderer } from '../../core/property/renderer.js';
@@ -19,6 +22,7 @@ export class MultiSelectCell extends BaseCellRenderer<
> {
closePopup?: () => void;
private readonly popTagSelect = () => {
const initialDraftText = consumeTagDraftFromTableCellHost(this);
this.closePopup = popTagSelect(popupTargetFromElement(this), {
name: this.cell.property.name$.value,
options: this.options$,
@@ -29,6 +33,7 @@ export class MultiSelectCell extends BaseCellRenderer<
},
onComplete: this._editComplete,
minWidth: 400,
initialDraftText,
});
};
@@ -2,7 +2,10 @@ import { popupTargetFromElement } from '@blocksuite/affine-components/context-me
import { computed } from '@preact/signals-core';
import { html } from 'lit/static-html.js';
import { popTagSelect } from '../../core/component/tags/multi-tag-select.js';
import {
consumeTagDraftFromTableCellHost,
popTagSelect,
} from '../../core/component/tags/multi-tag-select.js';
import type { SelectTag } from '../../core/index.js';
import { BaseCellRenderer } from '../../core/property/index.js';
import { createFromBaseCellRenderer } from '../../core/property/renderer.js';
@@ -20,6 +23,7 @@ export class SelectCell extends BaseCellRenderer<
> {
closePopup?: () => void;
private readonly popTagSelect = () => {
const initialDraftText = consumeTagDraftFromTableCellHost(this);
this.closePopup = popTagSelect(popupTargetFromElement(this), {
name: this.cell.property.name$.value,
mode: 'single',
@@ -31,6 +35,7 @@ export class SelectCell extends BaseCellRenderer<
},
onComplete: this._editComplete,
minWidth: 400,
initialDraftText,
});
};
@@ -0,0 +1,605 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import { type DeltaInsert, Text } from '@blocksuite/store';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { Doc } from 'yjs';
import { evalFilter } from '../../core/filter/eval.js';
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
import type { FilterGroup } from '../../core/filter/types.js';
import { emptyFilterGroup } from '../../core/filter/utils.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import { type Row, RowBase } from '../../core/view-manager/row.js';
import {
type SingleView,
SingleViewBase,
} from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { getCalendarExternalSources } from './source.js';
import type {
CalendarEntry,
CalendarEntryRange,
CalendarExternalEntry,
CalendarExternalSource,
CalendarRowEntry,
CalendarStoredViewData,
CalendarTitleSegment,
} from './types.js';
export type CalendarDateMapping =
| {
status: 'ready';
propertyId: string;
}
| {
status: 'setup';
propertyId?: string;
};
const getStartColumnId = (data?: CalendarStoredViewData) =>
data?.date?.startColumnId;
const getEndColumnId = (data?: CalendarStoredViewData) => {
return data?.date?.endColumnId;
};
const getDateData = (data: CalendarStoredViewData) => ({
...data.date,
startColumnId: getStartColumnId(data),
});
const getCardData = (data?: CalendarStoredViewData) => {
if (data) {
return data.card;
}
return {
visiblePropertyIds: [],
};
};
const toTimestamp = (date: number | Date) =>
date instanceof Date ? date.getTime() : date;
const isValidTimestamp = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const createLinkedDocTitle = (docId: string) => {
const text = new Text<AffineTextAttributes>();
new Doc().getMap('root').set('text', text.yText);
text.applyDelta([
{
insert: ' ',
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
},
] satisfies DeltaInsert<AffineTextAttributes>[]);
return text;
};
const getTitleDeltas = (value: unknown) =>
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
const getTitleSegments = (
value: unknown,
title: string,
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
): CalendarTitleSegment[] | undefined => {
const deltas = getTitleDeltas(value);
if (!Array.isArray(deltas)) {
return;
}
const segments = deltas.flatMap(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
title?: unknown;
};
};
};
const linkedDoc =
item.attributes?.reference?.type === 'LinkedPage' &&
typeof item.attributes.reference.pageId === 'string';
const referenceTitle = item.attributes?.reference?.title;
const resolvedLinkedDocTitle =
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
? getLinkedDocTitle?.(
item.attributes.reference.pageId,
typeof referenceTitle === 'string' ? referenceTitle : undefined
)
: undefined;
const text =
resolvedLinkedDocTitle ||
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
? referenceTitle
: typeof item.insert === 'string'
? item.insert.trim()
: '');
if (linkedDoc) {
return {
text,
linkedDoc,
};
}
if (!text) {
return [];
}
return {
text,
};
});
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
(result, segment) => {
const previous = result.at(-1);
if (
previous?.linkedDoc &&
!previous.text &&
!segment.linkedDoc &&
segment.text
) {
previous.text = segment.text;
return result;
}
result.push(segment);
return result;
},
[]
);
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
return;
}
if (!normalizedSegments.some(segment => segment.text)) {
return title
? [...normalizedSegments, { text: title }]
: normalizedSegments;
}
return normalizedSegments;
};
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
private externalEntriesRequestId = 0;
propertiesRaw$ = computed(() => {
return this.dataSource.properties$.value.map(id =>
this.propertyGetOrCreate(id)
);
});
properties$ = this.propertiesRaw$;
detailProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value !== 'title'
);
});
private readonly filter$ = computed(() => {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
emptyMonthHintDismissed$ = computed(() => {
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => ({
sort: {
...data.sort,
...sortList,
},
}));
},
})
);
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
filterSet: (filter: FilterGroup) => {
this.dataUpdate(() => ({ filter }));
},
})
);
mainProperties$ = computed(() => {
const card = getCardData(this.data$.value);
return {
titleColumn:
card.titleColumnId ??
this.propertiesRaw$.value.find(
property => property.type$.value === 'title'
)?.id,
};
});
readonly$ = computed(() => {
return this.manager.readonly$.value;
});
dateProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value === 'date'
);
});
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
const propertyId = getStartColumnId(this.data$.value);
if (
propertyId &&
this.dataSource.properties$.value.includes(propertyId) &&
this.dataSource.propertyTypeGet(propertyId) === 'date'
) {
return {
status: 'ready',
propertyId,
};
}
return {
status: 'setup',
propertyId,
};
});
startDateMapping$ = this.dateMapping$;
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
const propertyId = getEndColumnId(this.data$.value);
if (
propertyId &&
this.dataSource.properties$.value.includes(propertyId) &&
this.dataSource.propertyTypeGet(propertyId) === 'date'
) {
return {
status: 'ready',
propertyId,
};
}
return {
status: 'setup',
propertyId,
};
});
private readonly visibleCardProperties$ = computed(() => {
const card = getCardData(this.data$.value);
const visiblePropertyIds = card.visiblePropertyIds ?? [];
const titleColumn = card.titleColumnId;
return visiblePropertyIds
.filter(propertyId => propertyId !== titleColumn)
.map(propertyId => this.propertyGetOrCreate(propertyId));
});
rowEntries$ = computed<CalendarRowEntry[]>(() => {
const mapping = this.dateMapping$.value;
if (mapping.status !== 'ready') {
return [];
}
const endMapping = this.endDateMapping$.value;
return this.rows$.value.flatMap(row => {
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
.jsonValue$.value;
if (!isValidTimestamp(startAt)) {
return [];
}
const endAt =
endMapping.status === 'ready'
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
.value
: undefined;
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
const jsonTitle = titleCell.jsonValue$.value;
const title =
(typeof jsonTitle === 'string'
? jsonTitle
: titleCell.stringValue$.value) ?? '';
const docDisplayMeta = this.manager.dataSource.serviceGet(
DocDisplayMetaProvider
);
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
docDisplayMeta?.title(pageId, { title }).value;
const titleSegments = getTitleSegments(
titleCell.value$.value,
title,
resolveLinkedDocTitle
);
const cardProperties = this.visibleCardProperties$.value.flatMap(
property => {
const cell = this.cellGetOrCreate(row.rowId, property.id);
const value = cell.stringValue$.value;
if (!value) {
return [];
}
return {
propertyId: property.id,
value,
};
}
);
return {
kind: 'row',
id: `database:${row.rowId}`,
sourceId: 'database',
rowId: row.rowId,
title,
startAt,
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
titleSegments,
cardProperties,
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
} satisfies CalendarRowEntry;
});
});
entries$ = computed<CalendarEntry[]>(() => {
return [...this.rowEntries$.value, ...this.externalEntries$.value];
});
externalSources$ = computed<CalendarExternalSource[]>(() => {
const viewData = this.data$.value;
if (!viewData) {
return [];
}
return getCalendarExternalSources(this.dataSource, viewData);
});
get type(): string {
return this.data$.value?.mode ?? 'calendar';
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
isShow(rowId: string): boolean {
if (this.filter$.value.conditions.length) {
const rowMap = Object.fromEntries(
this.propertiesRaw$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])
);
return evalFilter(this.filter$.value, rowMap);
}
return true;
}
override rowsMapping(rows: Row[]) {
return this.sortManager.sort(super.rowsMapping(rows));
}
propertyGetOrCreate(propertyId: string): CalendarProperty {
return new CalendarProperty(this, propertyId);
}
override rowGetOrCreate(rowId: string): CalendarRow {
return new CalendarRow(this, rowId);
}
setStartDateColumn(propertyId: string) {
this.dataUpdate(data => ({
date: {
...getDateData(data),
startColumnId: propertyId,
},
}));
}
setDateColumn(propertyId: string) {
this.setStartDateColumn(propertyId);
}
setEndDateColumn(propertyId: string | undefined) {
this.dataUpdate(data => ({
date: {
...getDateData(data),
endColumnId: propertyId,
},
}));
}
setWorkspaceCalendarEnabled(enabled: boolean) {
this.dataUpdate(data => ({
sources: {
...data.sources,
workspaceCalendar: {
...(data.sources?.workspaceCalendar ?? { enabled: true }),
enabled,
},
},
}));
}
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
this.dataUpdate(data => ({
sources: {
...data.sources,
workspaceCalendar: {
...(data.sources?.workspaceCalendar ?? { enabled: true }),
subscriptionIds,
},
},
}));
}
dismissEmptyMonthHint() {
this.dataUpdate(data => ({
ui: {
...data.ui,
emptyMonthHintDismissed: true,
},
}));
}
getDocDisplayTitle(docId: string) {
return (
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
.value ?? 'Untitled'
);
}
createStartDateColumn() {
const id = this.propertyAdd('end', {
type: 'date',
name: 'Date',
});
if (id) {
this.setStartDateColumn(id);
}
return id;
}
createDateColumn() {
return this.createStartDateColumn();
}
createEndDateColumn() {
const id = this.propertyAdd('end', {
type: 'date',
name: 'End Date',
});
if (id) {
this.setEndDateColumn(id);
}
return id;
}
createRowOnDate(date: number | Date) {
const mapping = this.startDateMapping$.value;
if (mapping.status !== 'ready') {
return;
}
const rowId = this.rowAdd('end');
const filter = this.filter$.value;
if (filter.conditions.length > 0) {
const defaultValues = generateDefaultValues(filter, this.vars$.value);
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGetOrCreate(propertyId);
const propertyMeta = property.meta$.value;
if (propertyMeta) {
const value = fromJson(propertyMeta.config, {
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
}
});
}
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
toTimestamp(date)
);
this.dismissEmptyMonthHint();
return rowId;
}
createLinkedDocRowOnDate(date: number | Date, docId: string) {
const rowId = this.createRowOnDate(date);
if (!rowId) return;
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
this.cellGetOrCreate(rowId, titleColumn).valueSet(
createLinkedDocTitle(docId)
);
return rowId;
}
moveRowToDate(rowId: string, date: number | Date) {
const mapping = this.startDateMapping$.value;
if (mapping.status !== 'ready') {
return;
}
const value = toTimestamp(date);
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
.jsonValue$.value;
const endMapping = this.endDateMapping$.value;
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
.jsonValue$.value;
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
value + (oldEndAt - oldStartAt)
);
}
}
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
}
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
const startMapping = this.startDateMapping$.value;
const endMapping = this.endDateMapping$.value;
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
return;
}
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
const startAt = startCell.jsonValue$.value;
const endAt = endCell.jsonValue$.value;
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
return;
}
const value = toTimestamp(date);
if (edge === 'start') {
startCell.jsonValueSet(Math.min(value, endAt));
} else {
endCell.jsonValueSet(Math.max(value, startAt));
}
}
async loadExternalEntries(range: CalendarEntryRange) {
const requestId = ++this.externalEntriesRequestId;
const viewData = this.data$.value;
if (!viewData) {
this.externalEntries$.value = [];
return [];
}
const results = await Promise.allSettled(
this.externalSources$.value.map(source =>
Promise.resolve(source.getEntries(range))
)
);
const entries = results.flatMap(result =>
result.status === 'fulfilled' ? result.value : []
);
if (requestId === this.externalEntriesRequestId) {
this.externalEntries$.value = entries;
}
return entries;
}
}
export class CalendarProperty extends PropertyBase {
hide$ = computed(() => false);
constructor(view: CalendarSingleView, propertyId: string) {
super(view as SingleView, propertyId);
}
hideSet(_hide: boolean): void {}
move(_position: InsertToPosition): void {}
}
export class CalendarRow extends RowBase {
constructor(
readonly calendarView: CalendarSingleView,
rowId: string
) {
super(calendarView, rowId);
}
}
@@ -0,0 +1,34 @@
import { viewType } from '../../core/view/data-view.js';
import { CalendarSingleView } from './calendar-view-manager.js';
import type { CalendarViewData } from './types.js';
export const calendarViewType = viewType('calendar');
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
{
defaultName: 'Calendar View',
dataViewManager: CalendarSingleView,
defaultData: viewManager => {
return {
filter: {
type: 'group',
op: 'and',
conditions: [],
},
date: {},
card: {
titleColumnId: viewManager.dataSource.properties$.value.find(
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
),
visiblePropertyIds: [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
ui: {},
};
},
}
);
@@ -0,0 +1,5 @@
import { pcEffects } from './pc/effect.js';
export function calendarEffects() {
pcEffects();
}
@@ -0,0 +1,6 @@
export * from './calendar-view-manager.js';
export * from './define.js';
export * from './layout.js';
export * from './renderer.js';
export * from './source.js';
export * from './types.js';
@@ -0,0 +1,250 @@
import type { CalendarEntry } from './types.js';
export type CalendarDayLayout = {
date: number;
inMonth: boolean;
entries: CalendarEntry[];
segments: CalendarRangeSegment[];
};
export type CalendarRangeSegment = {
entry: CalendarEntry;
weekIndex: number;
startIndex: number;
span: number;
slot: number;
startsBeforeWeek: boolean;
endsAfterWeek: boolean;
};
export type CalendarMonthLayout = {
from: number;
to: number;
weeks: CalendarDayLayout[][];
days: CalendarDayLayout[];
segments: CalendarRangeSegment[];
};
export type CalendarMonthLayoutOptions = {
month: number | Date;
entries: CalendarEntry[];
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const addDays = (date: number, days: number) => {
const current = new Date(date);
return startOfDay(
new Date(
current.getFullYear(),
current.getMonth(),
current.getDate() + days
)
);
};
const endOfDay = (date: number) => addDays(date, 1) - 1;
const toDate = (value: number | Date) =>
value instanceof Date ? value : new Date(value);
export const getCalendarVisibleMonthRange = (
month: number | Date,
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
) => {
const cursor = toDate(month);
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
const from = startOfDay(
new Date(
monthStart.getFullYear(),
monthStart.getMonth(),
monthStart.getDate() - startOffset
)
);
const to = endOfDay(
startOfDay(
new Date(
monthEnd.getFullYear(),
monthEnd.getMonth(),
monthEnd.getDate() + endOffset
)
)
);
return {
from,
to,
monthStart: startOfDay(monthStart),
monthEnd: endOfDay(startOfDay(monthEnd)),
};
};
const isRangeEntry = (entry: CalendarEntry) =>
entry.endAt != null &&
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
const getRangeEndDay = (entry: CalendarEntry) => {
const endAt = entry.endAt ?? entry.startAt;
const end = new Date(endAt);
if (
entry.kind === 'external' &&
entry.allDay &&
endAt > entry.startAt &&
end.getHours() === 0 &&
end.getMinutes() === 0 &&
end.getSeconds() === 0 &&
end.getMilliseconds() === 0
) {
return addDays(startOfDay(end), -1);
}
return startOfDay(end);
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
days.findIndex(day => day.date === date);
const assignSegmentSlots = (
weeks: CalendarDayLayout[][],
segments: CalendarRangeSegment[]
) => {
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
const weekSegments = segments.filter(
segment => segment.weekIndex === weekIndex
);
const slots: boolean[][] = [];
for (const segment of weekSegments) {
let slot = 0;
while (
slots[slot]?.some(
(occupied, index) =>
occupied &&
index >= segment.startIndex &&
index < segment.startIndex + segment.span
)
) {
slot++;
}
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
for (
let index = segment.startIndex;
index < segment.startIndex + segment.span;
index++
) {
slotDays[index] = true;
}
segment.slot = slot;
}
}
};
export const getCalendarDaySegmentSlots = (
day: CalendarDayLayout,
ignoredEntryId?: string
) => {
return Math.max(
0,
...day.segments
.filter(segment => segment.entry.id !== ignoredEntryId)
.map(segment => segment.slot + 1)
);
};
export const getCalendarDayContentSlots = (
day: CalendarDayLayout,
ignoredEntryId?: string
) => {
return (
getCalendarDaySegmentSlots(day, ignoredEntryId) +
day.entries.filter(entry => entry.id !== ignoredEntryId).length
);
};
export const createCalendarMonthLayout = ({
month,
entries,
weekStartsOn = 0,
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
const cursor = toDate(month);
const days: CalendarDayLayout[] = [];
const dayByTime = new Map<number, CalendarDayLayout>();
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
const day: CalendarDayLayout = {
date,
inMonth:
new Date(date).getMonth() === cursor.getMonth() &&
new Date(date).getFullYear() === cursor.getFullYear(),
entries: [],
segments: [],
};
days.push(day);
dayByTime.set(date, day);
}
for (const entry of entries) {
if (isRangeEntry(entry)) {
continue;
}
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
if (day) {
day.entries.push(entry);
}
}
const segments: CalendarRangeSegment[] = [];
const rangeEntries = entries.filter(isRangeEntry);
const visibleEndDay = startOfDay(new Date(range.to));
for (const entry of rangeEntries) {
const entryStart = startOfDay(new Date(entry.startAt));
const entryEnd = getRangeEndDay(entry);
if (entryEnd < range.from || entryStart > visibleEndDay) {
continue;
}
const start = clamp(entryStart, range.from, visibleEndDay);
const end = clamp(entryEnd, range.from, visibleEndDay);
const startOffset = getDayOffset(days, start);
const endOffset = getDayOffset(days, end);
if (startOffset < 0 || endOffset < 0) {
continue;
}
let offset = startOffset;
while (offset <= endOffset) {
const weekIndex = Math.floor(offset / 7);
const startIndex = offset % 7;
const weekEndOffset = weekIndex * 7 + 6;
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
const segment = {
entry,
weekIndex,
startIndex,
span,
slot: 0,
startsBeforeWeek: startOffset < weekIndex * 7,
endsAfterWeek: endOffset > weekEndOffset,
};
segments.push(segment);
for (let index = 0; index < span; index++) {
days[offset + index]?.segments.push(segment);
}
offset += span;
}
}
const weeks: CalendarDayLayout[][] = [];
for (let index = 0; index < days.length; index += 7) {
weeks.push(days.slice(index, index + 7));
}
assignSegmentSlots(weeks, segments);
return { from: range.from, to: range.to, weeks, days, segments };
};
@@ -0,0 +1,87 @@
import {
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import {
CalendarPanelIcon,
DateTimeIcon,
PinIcon,
TextIcon,
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import type { DataViewRootUILogic } from '../../../core/data-view.js';
import type { CalendarSingleView } from '../calendar-view-manager.js';
import type { CalendarEntry } from '../types.js';
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
});
export const formatEntryTime = (entry: CalendarEntry) => {
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
const start = formatter.format(new Date(entry.startAt));
if (!entry.endAt) {
return start;
}
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
};
export const openCalendarEntry = (
root: DataViewRootUILogic,
view: CalendarSingleView,
entry: CalendarEntry,
target: HTMLElement,
options?: { selectEntry?: (entryId: string | undefined) => void }
) => {
if (entry.kind === 'row') {
options?.selectEntry?.(entry.id);
root.openDetailPanel({
view,
rowId: entry.rowId,
onClose: () => options?.selectEntry?.(undefined),
});
return;
}
popMenu(popupTargetFromElement(target), {
options: {
items: [
() => html`
<div class="calendar-event-popover">
<div class="calendar-event-popover-title">${entry.title}</div>
<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon"
>${CalendarPanelIcon()}</span
>
<span>${entry.calendarName ?? 'Calendar event'}</span>
</div>
<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
<span>${formatEntryTime(entry)}</span>
</div>
${entry.location
? html`<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${PinIcon()}</span>
<span>${entry.location}</span>
</div>`
: ''}
${entry.description
? html`<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${TextIcon()}</span>
<span class="calendar-event-popover-description"
>${entry.description}</span
>
</div>`
: ''}
</div>
`,
],
},
});
};
@@ -0,0 +1,244 @@
import type { DndController } from '@blocksuite/std';
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
import { getCalendarDateFromPoint } from './hit-test.js';
export type CalendarDndEntity =
| {
type: 'calendar-entry';
entryId: string;
}
| {
type: 'doc';
docId: string;
};
type CalendarDndData = {
bsEntity?: unknown;
entity?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const getCalendarDndEntity = (
data: unknown
): CalendarDndEntity | undefined => {
if (!isRecord(data)) {
return;
}
const bsEntity = (data as CalendarDndData).bsEntity;
if (isRecord(bsEntity)) {
if (
bsEntity.type === 'calendar-entry' &&
typeof bsEntity.entryId === 'string'
) {
return {
type: 'calendar-entry',
entryId: bsEntity.entryId,
};
}
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
return {
type: 'doc',
docId: bsEntity.docId,
};
}
}
const entity = (data as CalendarDndData).entity;
if (
isRecord(entity) &&
entity.type === 'doc' &&
typeof entity.id === 'string'
) {
return {
type: 'doc',
docId: entity.id,
};
}
return;
};
export type CalendarDndCallbacks = {
getEntry: (entryId: string) => CalendarEntry | undefined;
canDragEntry: () => boolean;
canDrop: (entity: CalendarDndEntity) => boolean;
onEntryDragStart: (entry: CalendarRowEntry) => void;
onEntryDragEnd: () => void;
onDropTargetChange: (
date: number | undefined,
entity?: CalendarDndEntity
) => void;
onDrop: (entity: CalendarDndEntity, date: number) => void;
};
type ElementCleanup = {
element: HTMLElement;
cleanup: () => void;
};
export class CalendarDnd {
private readonly entryCleanups = new Map<string, ElementCleanup>();
private rootCleanup?: ElementCleanup;
constructor(
private readonly dnd: DndController | undefined,
private readonly callbacks: CalendarDndCallbacks
) {}
bindRoot(element?: Element) {
if (!this.dnd || !(element instanceof HTMLElement)) {
this.cleanupRoot();
return;
}
if (this.rootCleanup?.element === element) {
return;
}
this.cleanupRoot();
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
element,
getIsSticky: () => true,
setDropData: ({ input }) => ({
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
}),
canDrop: ({ source, input }) => {
const entity = getCalendarDndEntity(source.data);
const date = getCalendarDateFromPoint(
element,
input.clientX,
input.clientY
);
return entity && date !== undefined
? this.callbacks.canDrop(entity)
: false;
},
onDrag: ({ source, location }) => {
this.updateDropTarget(element, source.data, location.current.input);
},
onDragEnter: ({ source, location }) => {
this.updateDropTarget(element, source.data, location.current.input);
},
onDragLeave: () => {
this.callbacks.onDropTargetChange(undefined);
},
onDrop: ({ source, location }) => {
const entity = getCalendarDndEntity(source.data);
const date = getCalendarDateFromPoint(
element,
location.current.input.clientX,
location.current.input.clientY
);
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
this.callbacks.onDrop(entity, date);
}
this.callbacks.onDropTargetChange(undefined);
},
});
this.rootCleanup = { element, cleanup };
}
bindEntry(
key: string,
entry: CalendarEntry,
element?: Element,
disabled = false
) {
if (
!this.dnd ||
!(element instanceof HTMLElement) ||
entry.kind !== 'row' ||
disabled
) {
this.cleanupEntry(key);
if (element instanceof HTMLElement) {
element.setAttribute('draggable', 'false');
}
return;
}
const current = this.entryCleanups.get(key);
if (current?.element === element) {
return;
}
this.cleanupEntry(key);
const cleanup = this.dnd.draggable<CalendarDndEntity>({
element,
canDrag: () => {
const currentEntry = this.callbacks.getEntry(entry.id);
return currentEntry?.kind === 'row'
? this.callbacks.canDragEntry()
: false;
},
setDragData: () => ({
type: 'calendar-entry',
entryId: entry.id,
}),
setDragPreview: ({ container, setOffset }) => {
const currentEntry = this.callbacks.getEntry(entry.id);
const preview = document.createElement('div');
preview.textContent = currentEntry?.title || 'Untitled';
preview.style.cssText =
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
'font-size:12px;white-space:nowrap;overflow:hidden;' +
'background:var(--affine-hover-color,#f5f5f5);' +
'color:var(--affine-text-primary-color,#333);' +
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
container.append(preview);
setOffset({ x: 10, y: 11 });
},
onDragStart: () => {
const currentEntry = this.callbacks.getEntry(entry.id);
if (currentEntry?.kind === 'row') {
this.callbacks.onEntryDragStart(currentEntry);
}
},
onDrop: () => {
this.callbacks.onEntryDragEnd();
},
});
this.entryCleanups.set(key, { element, cleanup });
}
cleanup() {
this.cleanupRoot();
for (const key of this.entryCleanups.keys()) {
this.cleanupEntry(key);
}
}
private cleanupEntry(key: string) {
this.entryCleanups.get(key)?.cleanup();
this.entryCleanups.delete(key);
}
private cleanupRoot() {
this.rootCleanup?.cleanup();
this.rootCleanup = undefined;
}
private updateDropTarget(
root: HTMLElement,
data: unknown,
input: {
clientX: number;
clientY: number;
}
) {
const entity = getCalendarDndEntity(data);
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
this.callbacks.onDropTargetChange(date, entity);
} else {
this.callbacks.onDropTargetChange(undefined);
}
}
}
@@ -0,0 +1,8 @@
import { CalendarViewUI } from './view.js';
export function pcEffects() {
if (customElements.get('affine-data-view-calendar')) {
return;
}
customElements.define('affine-data-view-calendar', CalendarViewUI);
}
@@ -0,0 +1,38 @@
export const getCalendarDateFromPoint = (
root: HTMLElement,
clientX: number,
clientY: number
) => {
const doc = root.ownerDocument;
const hitStack = doc.elementsFromPoint(clientX, clientY);
for (const element of hitStack) {
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
if (day && root.contains(day)) {
return Number(day.dataset['date']);
}
}
for (const element of hitStack) {
const week =
element.closest<HTMLElement>('.calendar-week') ??
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
if (week && root.contains(week)) {
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
for (const day of days) {
const rect = day.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX < rect.right &&
clientY >= rect.top &&
clientY < rect.bottom &&
day.dataset['date']
) {
return Number(day.dataset['date']);
}
}
}
}
return;
};
@@ -0,0 +1,708 @@
import { css } from 'lit';
export const calendarViewStyles = css`
affine-data-view-calendar {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
--calendar-entry-height: 22px;
--calendar-entry-gap: 3px;
--calendar-entry-slot-height: calc(
var(--calendar-entry-height) + var(--calendar-entry-gap)
);
--calendar-grid-border-color: color-mix(
in srgb,
var(--affine-border-color) 58%,
transparent
);
--calendar-entry-bg: color-mix(
in srgb,
var(--affine-primary-color) 12%,
var(--affine-background-primary-color)
);
--calendar-entry-hover-bg: color-mix(
in srgb,
var(--affine-primary-color) 18%,
var(--affine-background-primary-color)
);
--calendar-entry-text-color: color-mix(
in srgb,
var(--affine-primary-color) 72%,
var(--affine-text-primary-color)
);
--calendar-external-fallback-color: #b45309;
}
.calendar-scroll {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.calendar-shell {
position: relative;
min-width: 720px;
padding: 0 0 12px;
}
.calendar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
margin-bottom: 8px;
}
.calendar-title {
color: var(--affine-text-primary-color);
font-size: 15px;
font-weight: 600;
}
.calendar-nav {
display: flex;
gap: 6px;
}
.calendar-nav button,
.calendar-setup button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--affine-border-color);
border-radius: 6px;
background: var(--affine-background-primary-color);
color: var(--affine-text-primary-color);
height: 28px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.calendar-nav button svg,
.calendar-setup button svg,
.calendar-new-row svg,
.calendar-empty-month-hint-action svg,
.calendar-empty-month-hint-close svg {
width: 16px;
height: 16px;
color: var(--affine-icon-secondary);
flex: 0 0 auto;
}
.calendar-nav .calendar-icon-button {
width: 28px;
padding: 5px;
}
.calendar-nav .calendar-today-button {
color: var(--affine-primary-color);
}
.calendar-weekdays,
.calendar-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-week {
position: relative;
}
.calendar-segments {
position: absolute;
left: 0;
right: 0;
top: 30px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: var(--calendar-entry-slot-height);
row-gap: 0;
column-gap: 0;
padding: 0;
pointer-events: none;
}
.calendar-segments .calendar-entry {
align-self: start;
height: var(--calendar-entry-height);
box-sizing: border-box;
pointer-events: auto;
margin: 0 6px;
}
.calendar-segments .calendar-entry-preview {
align-self: start;
pointer-events: none;
margin: 0 6px;
}
.calendar-weekday {
color: var(--affine-text-secondary-color);
font-size: 12px;
padding: 4px 6px;
user-select: none;
-webkit-user-select: none;
}
.calendar-grid {
border-top: 1px solid var(--calendar-grid-border-color);
border-left: 1px solid var(--calendar-grid-border-color);
}
.calendar-day {
position: relative;
min-height: 112px;
border-right: 1px solid var(--calendar-grid-border-color);
border-bottom: 1px solid var(--calendar-grid-border-color);
padding: 6px;
}
.calendar-day.is-outside {
background: color-mix(
in srgb,
var(--affine-background-secondary-color) 55%,
var(--affine-background-primary-color)
);
}
.calendar-day:not(.is-outside):hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 2%,
var(--affine-background-primary-color)
);
}
.calendar-day.is-drop-target {
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
}
.calendar-day.is-today {
background: color-mix(
in srgb,
var(--affine-primary-color) 6%,
var(--affine-background-primary-color)
);
}
.calendar-day-number {
display: flex;
align-items: center;
justify-content: center;
width: max-content;
min-width: 20px;
height: 20px;
padding: 0 2px;
border-radius: 4px;
color: var(--affine-text-secondary-color);
font-size: 12px;
line-height: 18px;
margin-bottom: 4px;
user-select: none;
-webkit-user-select: none;
}
.calendar-day:not(.is-outside) .calendar-day-number {
color: var(--affine-text-primary-color);
}
.calendar-day.is-outside .calendar-day-number {
color: color-mix(
in srgb,
var(--affine-text-secondary-color) 60%,
transparent
);
}
.calendar-day.is-today .calendar-day-number {
color: var(--affine-primary-color);
font-weight: 600;
}
.calendar-day.is-today:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 9%,
var(--affine-background-primary-color)
);
}
.calendar-entry {
position: relative;
display: flex;
align-items: center;
gap: 4px;
min-height: var(--calendar-entry-height);
margin-top: var(--calendar-entry-gap);
padding: 0 6px;
border-radius: 4px;
color: var(--calendar-entry-text-color);
background: var(--calendar-entry-bg);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.calendar-nav button:hover,
.calendar-setup button:hover {
background: var(--affine-hover-color);
}
.calendar-entry.row:hover {
background: var(--calendar-entry-hover-bg);
}
.calendar-entry:focus-visible {
outline: 1px solid var(--affine-primary-color);
outline-offset: 1px;
}
.calendar-entry.external:hover {
opacity: 0.9;
}
.calendar-entry.selected {
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
background: color-mix(
in srgb,
var(--affine-primary-color) 15%,
var(--calendar-entry-bg)
);
}
.calendar-entry.continues-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.calendar-entry.continues-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.calendar-entry-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry-title.is-empty {
color: var(--affine-text-secondary-color);
}
.calendar-entry-title.title-segments {
display: inline-flex;
align-items: center;
gap: 2px;
}
.calendar-entry-title-segment {
display: inline-flex;
align-items: center;
min-width: 0;
}
.calendar-entry-title-segment.linked-doc-segment {
gap: 3px;
min-width: 14px;
}
.calendar-entry-title-segment.linked-doc-segment svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.calendar-entry-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
flex-shrink: 1;
}
.calendar-entry-properties {
display: inline-flex;
gap: 3px;
min-width: 0;
}
.calendar-entry-property {
max-width: 72px;
padding: 1px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
color: var(--affine-text-primary-color);
font-size: 10px;
font-weight: 500;
line-height: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry.external {
color: var(--affine-pure-white);
background: var(
--calendar-external-color,
var(--calendar-external-fallback-color)
);
}
.calendar-entry[draggable='true'] {
cursor: grab;
}
.calendar-entry[draggable='true']:active {
opacity: 0.7;
}
.calendar-resize-handle {
display: none;
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 1;
}
.calendar-resize-handle.left {
left: 0;
border-radius: 4px 0 0 4px;
}
.calendar-resize-handle.right {
right: 0;
border-radius: 0 4px 4px 0;
}
.calendar-resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 10px;
transform: translate(-50%, -50%);
border-radius: 1px;
background: var(--affine-icon-secondary);
}
.calendar-resize-handle:hover::after {
background: var(--affine-primary-color);
}
.calendar-entry:hover .calendar-resize-handle {
display: block;
}
.calendar-entry-preview {
display: flex;
align-items: center;
gap: 4px;
min-height: var(--calendar-entry-height);
height: var(--calendar-entry-height);
margin-top: var(--calendar-entry-gap);
padding: 0 6px;
box-sizing: border-box;
border-radius: 4px;
border: 1.5px dashed var(--affine-primary-color);
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
color: var(--affine-primary-color);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.calendar-entry-preview svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.calendar-entry-preview.continues-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
padding-left: 6px;
}
.calendar-entry-preview.continues-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
padding-right: 6px;
}
.calendar-day-entries > .calendar-entry:first-child,
.calendar-day-entries > .calendar-entry-preview:first-child {
margin-top: 0;
}
.calendar-day-entries {
padding-top: calc(
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
);
}
.calendar-new-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 24px;
margin-top: 3px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--affine-primary-color);
font-size: 12px;
font-weight: 500;
line-height: 18px;
padding: 3px 8px;
opacity: 0;
cursor: pointer;
box-sizing: border-box;
transition:
opacity 0.1s ease,
background 0.1s ease;
}
.calendar-new-row svg,
.calendar-empty-month-hint-action svg {
width: 14px;
height: 14px;
color: var(--affine-primary-color);
}
.calendar-day:hover .calendar-new-row,
.calendar-new-row:focus-visible {
opacity: 1;
}
.calendar-day:hover .calendar-new-row {
background: color-mix(
in srgb,
var(--affine-primary-color) 10%,
var(--affine-background-primary-color)
);
}
.calendar-day:hover .calendar-new-row:disabled,
.calendar-day.is-today:hover .calendar-new-row:disabled,
.calendar-new-row:disabled {
background: transparent;
opacity: 0;
pointer-events: none;
}
.calendar-day.is-today:hover .calendar-new-row,
.calendar-day.is-today .calendar-new-row:focus-visible {
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.calendar-day.is-today .calendar-new-row:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 88%,
var(--affine-pure-white)
);
}
.calendar-day.is-today:hover .calendar-new-row svg,
.calendar-day.is-today .calendar-new-row:focus-visible svg {
color: var(--affine-pure-white);
}
.calendar-new-row:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 16%,
var(--affine-background-primary-color)
);
}
.calendar-empty-month-hint {
position: absolute;
top: 44px;
left: 8px;
right: 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 6px 8px 6px 12px;
border: 1px solid
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
border-radius: 6px;
background: color-mix(
in srgb,
var(--affine-background-primary-color) 92%,
var(--affine-primary-color)
);
box-shadow: var(--affine-menu-shadow);
box-sizing: border-box;
}
.calendar-empty-month-hint-copy {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.calendar-empty-month-hint-title {
flex: 0 0 auto;
color: var(--affine-text-primary-color);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.calendar-empty-month-hint-body {
min-width: 0;
color: var(--affine-text-secondary-color);
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-empty-month-hint-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.calendar-empty-month-hint-action,
.calendar-empty-month-hint-close {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 24px;
padding: 3px 8px;
border: 0;
border-radius: 5px;
background: color-mix(
in srgb,
var(--affine-primary-color) 10%,
var(--affine-background-primary-color)
);
color: var(--affine-primary-color);
font-size: 12px;
font-weight: 500;
line-height: 18px;
cursor: pointer;
}
.calendar-empty-month-hint-close {
width: 24px;
padding: 4px;
background: transparent;
color: var(--affine-icon-secondary);
}
.calendar-empty-month-hint-close svg {
width: 14px;
height: 14px;
}
.calendar-empty-month-hint-action:hover,
.calendar-empty-month-hint-close:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 16%,
var(--affine-background-primary-color)
);
}
.calendar-setup-wrap {
position: relative;
}
.calendar-setup-wrap .calendar-shell {
filter: grayscale(1) blur(1px);
opacity: 0.55;
pointer-events: none;
}
.calendar-setup {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.calendar-setup button {
height: 32px;
padding: 7px 12px;
}
.calendar-event-popover {
display: flex;
flex-direction: column;
gap: 4px;
width: 318px;
padding: 4px;
font-size: 13px;
line-height: 20px;
}
.calendar-event-popover-title {
padding: 2px 4px;
color: var(--affine-text-primary-color);
font-weight: 600;
font-size: 14px;
line-height: 22px;
margin-bottom: 2px;
}
.calendar-event-popover-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 2px 4px;
color: var(--affine-text-secondary-color);
}
.calendar-event-popover-icon {
display: flex;
align-items: center;
flex: 0 0 16px;
height: 20px;
color: var(--affine-icon-secondary);
}
.calendar-event-popover-icon svg {
width: 16px;
height: 16px;
}
.calendar-event-popover-description {
white-space: pre-wrap;
word-break: break-word;
}
`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
import './pc/effect.js';
import { createIcon } from '../../core/utils/uni-icon.js';
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
import { calendarViewModel } from './define.js';
import { CalendarViewUILogic } from './pc/view.js';
export const calendarViewMeta = calendarViewModel.createMeta({
icon: createIcon('TodayIcon'),
pcLogic: () =>
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
});
@@ -0,0 +1,23 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { DataSource } from '../../core/data-source/base.js';
import type {
CalendarExternalSource,
CalendarStoredViewData,
} from './types.js';
export type CalendarExternalSourceFactory = {
id: string;
create(viewData: CalendarStoredViewData): CalendarExternalSource;
};
export const CalendarExternalSourceProvider =
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
export const getCalendarExternalSources = (
dataSource: DataSource,
viewData: CalendarStoredViewData
) =>
Array.from(
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
).map(source => source.create(viewData));
@@ -0,0 +1,97 @@
import type { FilterGroup } from '../../core/filter/types.js';
import type { Sort } from '../../core/sort/types.js';
import type { BasicViewDataType } from '../../core/view/data-view.js';
export type CalendarWorkspaceSourceConfig = {
enabled: boolean;
subscriptionIds?: string[];
};
export type CalendarUiData = {
emptyMonthHintDismissed?: boolean;
};
export type CalendarCardProperty = {
propertyId: string;
value: string;
};
export type CalendarTitleSegment = {
text: string;
linkedDoc?: boolean;
};
type CalendarViewDataShape = {
filter: FilterGroup;
sort?: Sort;
date: {
startColumnId?: string;
endColumnId?: string;
};
card: {
titleColumnId?: string;
visiblePropertyIds: string[];
};
sources: {
workspaceCalendar?: CalendarWorkspaceSourceConfig;
};
ui?: CalendarUiData;
};
export type CalendarViewData = BasicViewDataType<
'calendar',
CalendarViewDataShape
>;
export type CalendarStoredViewData = CalendarViewData;
export type CalendarEntryBase = {
id: string;
sourceId: string;
title: string;
color?: string;
startAt: number;
endAt?: number;
allDay?: boolean;
};
export type CalendarRowEntry = CalendarEntryBase & {
kind: 'row';
sourceId: 'database';
rowId: string;
titleSegments?: CalendarTitleSegment[];
cardProperties: CalendarCardProperty[];
canResizeRange: boolean;
};
export type CalendarExternalEntry = CalendarEntryBase & {
kind: 'external';
sourceId: string;
externalId: string;
calendarName?: string;
location?: string;
description?: string;
canResizeRange: false;
};
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
export type CalendarEntryRange = {
from: number;
to: number;
};
export type CalendarExternalSource = {
id: string;
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
openConnectSettings?(): void;
getEntries(
range: CalendarEntryRange
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
};
export type CalendarExternalSourceSubscription = {
id: string;
name: string;
color?: string;
};
@@ -1,13 +1,45 @@
import { createViewConvert } from '../core/view/convert.js';
import { calendarViewModel } from './calendar/index.js';
import { kanbanViewModel } from './kanban/index.js';
import { tableViewModel } from './table/index.js';
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
titleColumnId: header?.titleColumn,
visiblePropertyIds: [],
});
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
titleColumn: card?.titleColumnId,
});
export const viewConverts = [
createViewConvert(tableViewModel, kanbanViewModel, data => ({
filter: data.filter,
header: data.header,
})),
createViewConvert(kanbanViewModel, tableViewModel, data => ({
filter: data.filter,
header: data.header,
groupBy: data.groupBy,
})),
createViewConvert(tableViewModel, calendarViewModel, data => ({
filter: data.filter,
sort: data.sort,
card: headerToCalendarCard(data.header),
})),
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
filter: data.filter,
sort: data.sort,
card: headerToCalendarCard(data.header),
})),
createViewConvert(calendarViewModel, tableViewModel, data => ({
filter: data.filter,
sort: data.sort,
header: calendarCardToHeader(data.card),
})),
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
filter: data.filter,
sort: data.sort,
header: calendarCardToHeader(data.card),
})),
];
@@ -1,7 +1,9 @@
import { calendarEffects } from './calendar/effect.js';
import { kanbanEffects } from './kanban/effect.js';
import { tableEffects } from './table/effect.js';
export function viewPresetsEffects() {
calendarEffects();
kanbanEffects();
tableEffects();
}
@@ -1,6 +1,8 @@
import { calendarViewMeta } from './calendar/index.js';
import { kanbanViewMeta } from './kanban/index.js';
import { tableViewMeta } from './table/index.js';
export * from './calendar/index.js';
export * from './convert.js';
export * from './kanban/index.js';
export * from './table/index.js';
@@ -8,4 +10,5 @@ export * from './table/index.js';
export const viewPresets = {
tableViewMeta: tableViewMeta,
kanbanViewMeta: kanbanViewMeta,
calendarViewMeta: calendarViewMeta,
};
@@ -181,6 +181,19 @@ export class DatabaseCellContainer extends SignalWatcher(
},
});
}
private _tagDraft: string | undefined;
setTagDraft(value: string) {
this._tagDraft = value;
}
consumeTagDraft(): string | undefined {
const value = this._tagDraft;
this._tagDraft = undefined;
return value;
}
isEditing$ = signal(false);
rowIndex$ = computed(() => {
@@ -46,6 +46,18 @@ export class TableViewCellContainer extends SignalWatcher(
@property({ attribute: false })
accessor rowId!: string;
private _tagDraft: string | undefined;
setTagDraft(value: string) {
this._tagDraft = value;
}
consumeTagDraft(): string | undefined {
const value = this._tagDraft;
this._tagDraft = undefined;
return value;
}
cell$ = computed(() => {
return this.column.cellGetOrCreate(this.rowId);
});
@@ -1,13 +1,26 @@
import type { ReadonlySignal } from '@preact/signals-core';
import { multiSelectPropertyType } from '../../property-presets/multi-select/define.js';
import { selectPropertyType } from '../../property-presets/select/define.js';
import type { TableViewSelectionWithType } from './selection';
import { TableViewRowSelection } from './selection';
export interface TableCell {
rowId: string;
setTagDraft?(value: string): void;
}
export type ColumnAccessor<T extends TableCell> = (
cell: T
) => { valueSetFromString(rowId: string, value: string): void } | undefined;
const TAG_COLUMN_TYPES = new Set<string>([
selectPropertyType.type,
multiSelectPropertyType.type,
]);
export type ColumnAccessor<T extends TableCell> = (cell: T) =>
| {
valueSetFromString(rowId: string, value: string): void;
type$: ReadonlySignal<string>;
}
| undefined;
export interface StartEditOptions<T extends TableCell> {
event: KeyboardEvent;
@@ -48,7 +61,13 @@ export function handleCharStartEdit<T extends TableCell>(
);
if (cell) {
const column = getColumn(cell);
column?.valueSetFromString(cell.rowId, event.key);
if (column) {
if (TAG_COLUMN_TYPES.has(column.type$.value) && cell.setTagDraft) {
cell.setTagDraft(event.key);
} else {
column.valueSetFromString(cell.rowId, event.key);
}
}
updateSelection({ ...selection, isEditing: true });
event.preventDefault();
return true;
@@ -1,4 +1,5 @@
import {
type Menu,
menu,
type MenuButtonData,
type MenuConfig,
@@ -16,22 +17,22 @@ import {
InfoIcon,
LayoutIcon,
MoreHorizontalIcon,
PlusIcon,
SortIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popPropertiesSetting } from '../../../../core/common/properties.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import {
popGroupSetting,
popSelectGroupByProperty,
buildGroupSelectItems,
buildGroupSettingItems,
} from '../../../../core/group-by/setting.js';
import { groupTraitKey } from '../../../../core/group-by/trait.js';
import {
type DataViewUILogicBase,
emptyFilterGroup,
popCreateFilter,
renderUniLit,
} from '../../../../core/index.js';
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
const styles = css`
.affine-database-toolbar-item.more-action {
@@ -95,379 +94,486 @@ declare global {
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
}
}
const createSettingMenus = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
reopen: () => void,
closeMenu: () => void
) => {
const view = dataViewLogic.view;
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}`,
select: () => {
popPropertiesSetting(
target,
{
view: view,
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 filter'
: `${filterCount} filters`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
if (!filterTrait.filter$.value.conditions.length) {
popCreateFilter(
target,
{
vars: view.vars$,
onBack: reopen,
onClose: closeMenu,
onSelect: filter => {
filterTrait.filterSet({
...(filterTrait.filter$.value ?? emptyFilterGroup),
conditions: [
...filterTrait.filter$.value.conditions,
filter,
],
});
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
},
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 sort'
: `${sortCount} sorts`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const sortList = sortTrait.sortList$.value;
const sortUtils = createSortUtils(
sortTrait,
dataViewLogic.eventTrace
);
if (!sortList.length) {
popCreateSort(
target,
{
sortUtils: sortUtils,
onBack: reopen,
onClose: closeMenu,
},
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popSortRoot(
target,
{
sortUtils: sortUtils,
title: {
text: 'Sort',
onBack: reopen,
onClose: closeMenu,
},
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const groupBy = groupTrait.property$.value;
if (!groupBy) {
popSelectGroupByProperty(
target,
groupTrait,
{
onSelect: () =>
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]),
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
} else {
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]);
}
},
})
);
}
return settingItems;
type Page =
| 'main'
| 'properties'
| 'filter'
| 'sort'
| 'group'
| 'group-select'
| 'custom';
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
main: 'View settings',
properties: 'Properties',
filter: 'Filter',
sort: 'Sort',
group: 'Group',
'group-select': 'Group by',
};
export const popViewOptions = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
onClose?: () => void
) => {
const view = dataViewLogic.view;
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">${meta.model.defaultName}</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menu}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
items.push(
menu.group({
items: createSettingMenus(target, dataViewLogic, reopen, () =>
handler.close()
),
})
);
items.push(
const currentPage = signal<Page>('main');
const pageStack: Page[] = ['main'];
let menuHandler!: ReturnType<typeof popMenu>;
let mainPageHeight: number | null = null;
let customPageTitle = '';
let customPageItems: () => MenuConfig[] = () => [];
const isDesktopMenu = () =>
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
const navigate = (page: Page) => {
if (!isDesktopMenu()) {
pageStack.push(page);
currentPage.value = page;
return;
}
if (mainPageHeight === null) {
mainPageHeight =
menuHandler.menu.menuElement.getBoundingClientRect().height;
}
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
pageStack.push(page);
currentPage.value = page;
};
const goBack = () => {
if (pageStack.length > 1) {
pageStack.pop();
const dest = pageStack[pageStack.length - 1] ?? 'main';
currentPage.value = dest;
if (dest === 'main') {
menuHandler.menu.menuElement.style.height = '';
}
}
};
const navigateToCustomPage = (
title: string,
getItems: () => MenuConfig[]
) => {
customPageTitle = title;
customPageItems = getItems;
navigate('custom');
};
const titleConfig = {
get text() {
if (currentPage.value === 'custom') return customPageTitle;
return (
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
'View settings'
);
},
get onBack(): ((menu: Menu) => false) | undefined {
return currentPage.value !== 'main'
? (_: Menu) => {
goBack();
return false;
}
: undefined;
},
get postfix() {
if (currentPage.value !== 'properties') return undefined;
const items = view.propertiesRaw$.value;
const isAllShowed = items.every(p => !p.hide$.value);
const clickChangeAll = () => {
items.forEach(p => {
if (p.hideCanSet) p.hideSet(isAllShowed);
});
};
return () =>
html`<div
class="properties-group-op"
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
@click="${clickChangeAll}"
>
${isAllShowed ? 'Hide All' : 'Show All'}
</div>`;
},
get onClose() {
return () => menuHandler?.menu.close();
},
};
const getPropertiesPageItems = (): MenuConfig[] => [
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
() =>
html`<data-view-properties-setting
.view="${view}"
></data-view-properties-setting>`,
],
})
);
handler = popMenu(target, {
}),
];
const getFilterPageItems = (): MenuConfig[] => {
const filterTrait = view.traitGet(filterTraitKey);
if (!filterTrait) return getMainPageItems();
return [
menu.group({
items: [
() =>
html`<filter-root-view
.onBack="${goBack}"
.vars="${view.vars$}"
.filterGroup="${filterTrait.filter$}"
.onChange="${filterTrait.filterSet}"
></filter-root-view>`,
],
}),
menu.group({
items: [
menu.action({
name: 'Add',
prefix: PlusIcon(),
select: ele => {
const value = filterTrait.filter$.value;
popCreateFilter(popupTargetFromElement(ele), {
vars: view.vars$,
onSelect: filter => {
filterTrait.filterSet({
...value,
conditions: [...value.conditions, filter],
});
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
});
return false;
},
}),
],
}),
];
};
const getSortPageItems = (): MenuConfig[] => {
const sortTrait = view.traitGet(sortTraitKey);
if (!sortTrait) return getMainPageItems();
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
return [
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
menu.action({
name: 'Add sort',
prefix: PlusIcon(),
select: ele => {
popCreateSort(popupTargetFromElement(ele), { sortUtils });
return false;
},
}),
menu.action({
name: 'Delete',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
sortUtils.removeAll();
},
}),
];
};
const getGroupPageItems = (): MenuConfig[] => {
const groupTrait = view.traitGet(groupTraitKey);
if (!groupTrait) return getMainPageItems();
const gProp = groupTrait.property$.value;
if (!gProp) return [];
return buildGroupSettingItems(
groupTrait,
() => navigate('group-select'),
() => navigate('main')
);
};
const getGroupSelectPageItems = (): MenuConfig[] => {
const groupTrait = view.traitGet(groupTraitKey);
if (!groupTrait) return getMainPageItems();
return buildGroupSelectItems(groupTrait, id => {
if (id) {
if (pageStack.at(-1) === 'group-select') {
pageStack[pageStack.length - 1] = 'group';
} else {
pageStack.push('group');
}
currentPage.value = 'group';
} else {
while (pageStack.length > 1) pageStack.pop();
currentPage.value = 'main';
}
});
};
const getMainPageItems = (): MenuConfig[] => {
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
disableAutoFocus: true,
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menuObj => {
const viewTypeItems = menuObj.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menuObj => {
if (!menuObj.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">
${meta.model.defaultName}
</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menuObj}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
>
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:4px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('properties');
return false;
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 active'
: `${filterCount} active`}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('filter');
return false;
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 active'
: `${sortCount} active`}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('sort');
return false;
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
const hasGroup = !!groupTrait.property$.value;
navigate(hasGroup ? 'group' : 'group-select');
return false;
},
})
);
}
items.push(menu.group({ items: settingItems }));
const viewSpecificItems =
(
dataViewLogic as DataViewUILogicBase & {
getViewOptionsSettingItems?: (
navigateToSubPage?: (
title: string,
getItems: () => MenuConfig[]
) => void,
goBack?: () => void
) => MenuConfig[];
}
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
if (viewSpecificItems.length) {
items.push(menu.group({ items: viewSpecificItems }));
}
items.push(
menu.group({
items: [
menu.action({
name: 'Duplicate view',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete view',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
],
})
);
return items;
};
const getPageItems = (): MenuConfig[] => {
switch (currentPage.value) {
case 'properties':
return getPropertiesPageItems();
case 'filter':
return getFilterPageItems();
case 'sort':
return getSortPageItems();
case 'group':
return getGroupPageItems();
case 'group-select':
return getGroupSelectPageItems();
case 'custom':
return customPageItems();
default:
return getMainPageItems();
}
};
menuHandler = popMenu(target, {
options: {
title: {
text: 'View settings',
onClose: () => handler.close(),
},
items,
onClose: onClose,
title: titleConfig,
items: [menu.dynamic(getPageItems)],
onClose,
},
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
@@ -475,6 +581,23 @@ export const popViewOptions = (
shift({ crossAxis: true }),
],
});
handler.menu.menuElement.style.minHeight = '550px';
return handler;
if (isDesktopMenu()) {
menuHandler.menu.menuElement.style.minWidth = '380px';
menuHandler.menu.menuElement.style.maxWidth = '380px';
menuHandler.menu.menuElement.style.borderRadius = '10px';
menuHandler.menu.menuElement.style.padding = '12px';
menuHandler.menu.menuElement.style.gap = '10px';
requestAnimationFrame(() => {
const bodyEl =
menuHandler.menu.menuElement.querySelector<HTMLElement>(
'.affine-menu-body'
);
if (bodyEl) {
bodyEl.style.overflowY = 'auto';
bodyEl.style.flex = '1';
bodyEl.style.minHeight = '0';
}
});
}
return menuHandler;
};
@@ -434,6 +434,8 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
const textResizing = this.element.textResizing;
const viewport = this.gfx.viewport;
const zoom = viewport.zoom;
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
const { viewportX, viewportY, viewScale } = viewport;
const rect = getSelectedRect([this.element]);
const rotate = this.element.rotate;
const [leftTopX, leftTopY] = Vec.rotWith(
@@ -441,7 +443,8 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
[rect.left + rect.width / 2, rect.top + rect.height / 2],
toRadian(rotate)
);
const [x, y] = this.gfx.viewport.toViewCoord(leftTopX, leftTopY);
const x = ((leftTopX - viewportX) * zoom) / viewScale;
const y = ((leftTopY - viewportY) * zoom) / viewScale;
const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
const constrainedAutoWidth = autoWidth && !!this.element.maxWidth;
const editorWidth = constrainedAutoWidth
@@ -476,7 +479,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
fontWeight: this.element.fontWeight,
lineHeight: 'normal',
outline: 'none',
transform: `scale(${zoom}, ${zoom}) rotate(${rotate}deg)`,
transform: `scale(${zoom / viewScale}, ${zoom / viewScale}) rotate(${rotate}deg)`,
transformOrigin: 'top left',
color,
padding: `${verticalPadding}px ${horiPadding}px`,
@@ -418,13 +418,14 @@ export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
const rect = getSelectedRect([this.element]);
const { translateX, translateY, zoom } = this.gfx.viewport;
const { translateX, translateY, zoom, viewScale } = this.gfx.viewport;
const [visualX, visualY] = this.getVisualPosition(this.element);
const containerOffset = this.getContainerOffset();
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
const transformOperation = [
`translate(${translateX}px, ${translateY}px)`,
`translate(${visualX * zoom}px, ${visualY * zoom}px)`,
`scale(${zoom})`,
`translate(${translateX / viewScale}px, ${translateY / viewScale}px)`,
`translate(${(visualX * zoom) / viewScale}px, ${(visualY * zoom) / viewScale}px)`,
`scale(${zoom / viewScale})`,
`rotate(${rotate}deg)`,
`translate(${containerOffset})`,
];
+50 -31
View File
@@ -2,14 +2,48 @@ import {
DocModeProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import type { Command, TextSelection } from '@blocksuite/std';
import type { InlineRange } from '@blocksuite/std/inline';
function openInlineLatexEditor(
inlineEditor: AffineInlineEditor,
index: number
) {
inlineEditor
.waitForUpdate()
.then(async () => {
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(index);
if (!textPoint) return;
const [text] = textPoint;
const latexNode = text.parentElement?.closest('affine-latex-node');
if (!latexNode) return;
latexNode.toggleEditor();
})
.catch(console.error);
}
function getSingleBlockInlineRange(
textSelection: TextSelection
): InlineRange | null {
if (textSelection.to) {
return null;
}
return {
index: textSelection.from.index,
length: textSelection.from.length,
};
}
export const insertInlineLatex: Command<{
currentTextSelection?: TextSelection;
textSelection?: TextSelection;
}> = (ctx, next) => {
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
if (!textSelection || !textSelection.isCollapsed()) return;
if (!textSelection) return;
const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId);
if (!blockComponent) return;
@@ -20,24 +54,19 @@ export const insertInlineLatex: Command<{
const inlineEditor = richText.inlineEditor;
if (!inlineEditor) return;
inlineEditor.insertText(
{
index: textSelection.from.index,
length: 0,
},
' '
);
inlineEditor.formatText(
{
index: textSelection.from.index,
length: 1,
},
{
latex: '',
}
);
const inlineRange = getSingleBlockInlineRange(textSelection);
if (!inlineRange) return;
const latex = textSelection.isCollapsed()
? ''
: inlineEditor.yTextString.slice(
inlineRange.index,
inlineRange.index + inlineRange.length
);
inlineEditor.insertText(inlineRange, ' ', { latex });
inlineEditor.setInlineRange({
index: textSelection.from.index,
index: inlineRange.index,
length: 1,
});
@@ -56,19 +85,9 @@ export const insertInlineLatex: Command<{
control: 'create inline equation',
});
inlineEditor
.waitForUpdate()
.then(async () => {
await inlineEditor.waitForUpdate();
const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1);
if (!textPoint) return;
const [text] = textPoint;
const latexNode = text.parentElement?.closest('affine-latex-node');
if (!latexNode) return;
latexNode.toggleEditor();
})
.catch(console.error);
if (textSelection.isCollapsed()) {
openInlineLatexEditor(inlineEditor, inlineRange.index + 1);
}
next();
};
@@ -15,7 +15,7 @@ import {
import type { DeltaInsert } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import katex from 'katex';
import { css, html, render } from 'lit';
import { css, html, type PropertyValues, render } from 'lit';
import { property } from 'lit/decorators.js';
export class AffineLatexNode extends SignalWatcher(
@@ -85,6 +85,8 @@ export class AffineLatexNode extends SignalWatcher(
private _editorAbortController: AbortController | null = null;
private _isEditorOpen = false;
readonly latex$ = signal('');
readonly latexEditorSignal = signal('');
@@ -174,6 +176,22 @@ export class AffineLatexNode extends SignalWatcher(
return result;
}
protected override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (!changedProperties.has('delta') || this._isEditorOpen) {
return;
}
const latex = this.deltaLatex;
if (this.latex$.peek() !== latex) {
this.latex$.value = latex;
}
if (this.latexEditorSignal.peek() !== latex) {
this.latexEditorSignal.value = latex;
}
}
override render() {
return html`<span class="affine-latex" data-selected=${this.selected}
><div class="latex-container"></div>
@@ -212,9 +230,11 @@ export class AffineLatexNode extends SignalWatcher(
},
});
this._isEditorOpen = true;
this._editorAbortController.signal.addEventListener(
'abort',
() => {
this._isEditorOpen = false;
portal.remove();
const latex = this.latexEditorSignal.peek();
this.latex$.value = latex;
@@ -320,9 +320,21 @@ export const htmlMarkElementToDeltaMatcher = HtmlASTToDeltaExtension({
if (!isElement(ast)) {
return [];
}
const dataColor =
typeof ast.properties?.dataColor === 'string'
? ast.properties.dataColor
: '';
const colorName =
dataColor &&
/^(red|orange|yellow|green|teal|blue|purple|grey)$/.test(dataColor)
? dataColor
: 'yellow';
return ast.children.flatMap(child =>
context.toDelta(child, { trim: false }).map(delta => {
delta.attributes = { ...delta.attributes };
delta.attributes = {
...delta.attributes,
background: `var(--affine-text-highlight-${colorName})`,
};
return delta;
})
);
@@ -14,6 +14,7 @@ type CodeBlockProps = {
caption: string;
preview?: boolean;
lineNumber?: boolean;
collapsed?: boolean;
comments?: Record<string, boolean>;
} & BlockMeta;
@@ -27,6 +28,7 @@ export const CodeBlockSchema = defineBlockSchema({
caption: '',
preview: undefined,
lineNumber: undefined,
collapsed: undefined,
comments: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,
@@ -13,6 +13,7 @@ import {
QuoteIcon,
TextIcon,
} from '@blocksuite/affine-components/icons';
import { TeXIcon } from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit';
/**
@@ -119,6 +120,15 @@ export const textConversionConfigs: TextConversionConfig[] = [
hotkey: [`Mod-Alt-c`],
icon: CodeBlockIcon,
},
{
flavour: 'affine:latex',
type: undefined,
name: 'Equation',
description: 'Formula block with LaTeX rendering.',
hotkey: null,
icon: TeXIcon(),
searchAlias: ['mathBlock', 'equationBlock', 'latexBlock'],
},
{
flavour: 'affine:paragraph',
type: 'quote',
@@ -66,6 +66,10 @@ export type EmbedIframeConfig = {
* The function to build the oEmbed URL for fetching embed data
*/
buildOEmbedUrl: (url: string) => string | undefined;
/**
* Validate the final iframe src before rendering.
*/
validateIframeUrl?: (iframeUrl: string, originalUrl?: string) => boolean;
/**
* Use oEmbed URL directly as iframe src without fetching oEmbed data
*/
@@ -264,17 +264,21 @@ export class EdgelessWatcher {
const { viewport } = this.gfx;
const rect = getSelectedRect([edgelessElement]);
let [left, top] = viewport.toViewCoord(rect.left, rect.top);
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
const { viewportX, viewportY, viewScale } = viewport;
const scale = this.widget.scale.peek();
const width = rect.width * scale;
const height = rect.height * scale;
let left = ((rect.left - viewportX) * scale) / viewScale;
const top = ((rect.top - viewportY) * scale) / viewScale;
const width = (rect.width * scale) / viewScale;
const height = (rect.height * scale) / viewScale;
let [right, bottom] = [left + width, top + height];
const padding = HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale;
const padding = (HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale) / viewScale;
const containerWidth = DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale;
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL;
const containerWidth =
(DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale) / viewScale;
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL / viewScale;
left -= containerWidth + offsetLeft;
right += padding;
@@ -473,12 +473,15 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
const { zoom, selection, gfx } = this;
const elements = selection.selectedElements;
// in surface
const rect = getSelectedRect(elements);
// in viewport
const [left, top] = gfx.viewport.toViewCoord(rect.left, rect.top);
const [width, height] = [rect.width * zoom, rect.height * zoom];
// Compensate for outer CSS scale (e.g. embed-edgeless-synced-doc),
// matching GfxBlockComponent.getCSSTransform.
const { viewportX, viewportY, viewScale } = gfx.viewport;
const left = ((rect.left - viewportX) * zoom) / viewScale;
const top = ((rect.top - viewportY) * zoom) / viewScale;
const width = (rect.width * zoom) / viewScale;
const height = (rect.height * zoom) / viewScale;
let rotate = 0;
if (elements.length === 1 && elements[0].rotate) {
@@ -714,15 +717,17 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
element => element.id,
element => {
const [modelX, modelY, w, h] = deserializeXYWH(element.xywh);
const [x, y] = gfx.viewport.toViewCoord(modelX, modelY);
const { viewportX, viewportY, zoom, viewScale } = gfx.viewport;
const x = ((modelX - viewportX) * zoom) / viewScale;
const y = ((modelY - viewportY) * zoom) / viewScale;
const { left, top, borderWidth } = this._selectedRect;
const style = {
position: 'absolute',
boxSizing: 'border-box',
left: `${x - left - borderWidth}px`,
top: `${y - top - borderWidth}px`,
width: `${w * this.zoom}px`,
height: `${h * this.zoom}px`,
width: `${(w * zoom) / viewScale}px`,
height: `${(h * zoom) / viewScale}px`,
transform: `rotate(${element.rotate}deg)`,
border: `1px solid var(--affine-primary-color)`,
};
@@ -222,6 +222,17 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
});
},
},
{
name: 'Equation',
showWhen: ({ std }) =>
std.store.schema.flavourSchemaMap.has('affine:latex'),
icon: TeXIcon(),
action: ({ std }) => {
std.command.exec(updateBlockType, {
flavour: 'affine:latex',
});
},
},
{
name: 'Quote',
showWhen: ({ std }) =>
@@ -25,6 +25,7 @@
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"js-yaml": "^4.1.1",
"jszip": "^3.10.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"mammoth": "^1.11.0",
@@ -0,0 +1,540 @@
import {
defaultImageProxyMiddleware,
docLinkBaseURLMiddleware,
fileNameMiddleware,
filePathMiddleware,
MarkdownAdapter,
} from '@blocksuite/affine-shared/adapters';
import { Container } from '@blocksuite/global/di';
import { sha } from '@blocksuite/global/utils';
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import JSZip from 'jszip';
import { createCollectionDocCRUD } from './markdown.js';
/** Recursive tree node representing a tag-based folder hierarchy. */
type FolderHierarchy = {
name: string;
path: string;
children: Map<string, FolderHierarchy>;
pageId?: string;
parentPath?: string;
};
type BearImportOptions = {
collection: Workspace;
schema: Schema;
imported: Blob;
extensions: ExtensionType[];
};
type BearImportResult = {
docIds: string[];
tags: Map<string, string[]>;
folderHierarchy: FolderHierarchy;
};
type BundleEntry = {
bundlePath: string;
markdownPath: string | null;
infoJsonPath: string | null;
assetPaths: string[];
};
/** Create a DI provider from the given extensions. */
function getProvider(extensions: ExtensionType[]) {
const container = new Container();
extensions.forEach(ext => {
ext.setup(container);
});
return container.provider();
}
/**
* Extract Bear tags from the trailing footer of a markdown document.
* Bear places tags (e.g. `#tag`, `#multi word tag#`, `#nested/tag`) at the end
* of notes. This scans from the bottom up, collecting tag-only lines (up to 5)
* and returns the deduplicated tags plus the content with those lines removed.
*/
function parseBearTags(markdown: string): {
tags: string[];
content: string;
} {
const lines = markdown.split('\n');
const codeFenceState: boolean[] = [];
let inCodeBlock = false;
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
}
codeFenceState.push(inCodeBlock);
}
const tags: string[] = [];
const tagLineIndices = new Set<number>();
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line) continue;
if (codeFenceState[i]) break;
const lineTags = extractTagsFromLine(line);
if (lineTags.length > 0) {
for (const tag of lineTags) {
tags.push(tag);
}
tagLineIndices.add(i);
} else {
break;
}
if (tagLineIndices.size >= 5) break;
}
const filteredLines = lines.filter((_, i) => !tagLineIndices.has(i));
while (
filteredLines.length > 0 &&
filteredLines[filteredLines.length - 1].trim() === ''
) {
filteredLines.pop();
}
return {
tags: deduplicateTags(tags),
content: filteredLines.join('\n'),
};
}
/**
* Parse Bear tags from a single line. Supports open tags (`#tag`),
* closed tags (`#multi word tag#`), and nested tags (`#parent/child`).
* Returns an empty array if the line contains non-tag content.
*/
function extractTagsFromLine(line: string): string[] {
const tags: string[] = [];
let remaining = line;
while (remaining.length > 0) {
remaining = remaining.trimStart();
if (!remaining) break;
if (remaining.startsWith('[')) return [];
if (remaining.startsWith('#')) {
if (remaining.length > 1 && remaining[1] === ' ') return [];
if (remaining.length > 2 && remaining[1] === '#') return [];
const closedMatch = remaining.match(/^#([^#\n]+)#/);
if (closedMatch) {
const tagValue = closedMatch[1].trim();
if (tagValue) {
tags.push(tagValue);
remaining = remaining.slice(closedMatch[0].length);
continue;
}
}
const openMatch = remaining.match(
/^#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)(.*)$/u
);
if (openMatch) {
const tagValue = openMatch[1];
const after = openMatch[2].trim();
if (tagValue) {
tags.push(tagValue);
remaining = after;
continue;
}
}
return [];
} else {
return [];
}
}
return tags;
}
/**
* Deduplicate tags case-insensitively while preserving the original
* capitalization of the first occurrence of each tag.
*/
function deduplicateTags(tags: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const normalized = tag.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(tag);
}
}
return result;
}
/**
* Build a nested folder hierarchy from Bear tags.
* Tags like `parent/child` create nested folders. Documents are attached
* as leaf nodes under their tag's folder using `__doc__` prefixed keys.
*/
function buildFolderHierarchyFromTags(
tagDocMap: Map<string, string[]>
): FolderHierarchy {
const root: FolderHierarchy = {
name: '',
path: '',
children: new Map(),
};
for (const [tag, docIds] of tagDocMap) {
const parts = tag.split('/');
let current = root;
let currentPath = '';
for (const part of parts) {
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!current.children.has(part)) {
current.children.set(part, {
name: part,
path: currentPath,
parentPath: parentPath || undefined,
children: new Map(),
});
}
current = current.children.get(part)!;
}
for (const docId of docIds) {
const docNodeKey = `__doc__${docId}`;
if (!current.children.has(docNodeKey)) {
current.children.set(docNodeKey, {
name: docNodeKey,
path: `${current.path}/${docNodeKey}`,
parentPath: current.path,
children: new Map(),
pageId: docId,
});
}
}
}
return root;
}
const GFM_CALLOUT_MAP: Record<string, string> = {
IMPORTANT: '\u26A0',
NOTE: '\uD83D\uDCDD',
WARNING: '\u26A0',
TIP: '\uD83D\uDCA1',
CAUTION: '\uD83D\uDD34',
};
/**
* Convert GFM-style callouts (`> [!NOTE]`, `> [!WARNING]`, etc.) to
* emoji-based callouts that AFFiNE's remark-callout plugin understands.
* Skips content inside fenced code blocks.
*/
function convertGfmCallouts(markdown: string): string {
const lines = markdown.split('\n');
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (!inCodeBlock) {
lines[i] = lines[i].replace(
/^(>\s*)\[!(\w+)\]/,
(_match, prefix: string, type: string) => {
const emoji = GFM_CALLOUT_MAP[type.toUpperCase()];
return emoji ? `${prefix}[!${emoji}]` : _match;
}
);
}
}
return lines.join('\n');
}
function stripBearMetadataComments(markdown: string): string {
let current = markdown;
while (true) {
const next = current.replace(/<!--\s*\{[^}]*\}\s*-->/g, '');
if (next === current) {
return current;
}
current = next;
}
}
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
'\uD83D\uDFE2': 'green',
'\uD83D\uDD35': 'blue',
'\uD83D\uDFE3': 'purple',
'\uD83D\uDD34': 'red',
'\uD83D\uDFE1': 'yellow',
'\uD83D\uDFE0': 'orange',
};
/** Escape HTML special characters to prevent markup injection. */
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Convert Bear `==highlight==` syntax to `<mark>` HTML elements.
* Supports colored highlights via leading color emoji (e.g. `==🟢green text==`).
* Skips content inside fenced code blocks.
*/
function convertHighlights(markdown: string): string {
const lines = markdown.split('\n');
let inCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (!inCodeBlock) {
lines[i] = lines[i].replace(
/==(\S(?:[^=]|=[^=])*?)==/g,
(_match, content: string) => {
const firstChar = String.fromCodePoint(content.codePointAt(0)!);
const color = HIGHLIGHT_COLOR_MAP[firstChar];
if (color) {
const text = content.slice(firstChar.length);
return `<mark data-color="${color}">${escapeHtml(text)}</mark>`;
}
return `<mark>${escapeHtml(content)}</mark>`;
}
);
}
}
return lines.join('\n');
}
/** Extract the document title from the first `# heading` or fall back to the bundle name. */
function extractTitle(markdown: string, bundleName: string): string {
const lines = markdown.split('\n');
let inCodeBlock = false;
for (const line of lines) {
if (line.trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
const match = line.match(/^#\s+(.+)/);
if (match) {
const title = match[1].trim();
if (title) return title;
}
}
return bundleName.replace(/\.textbundle$/i, '') || 'Untitled';
}
/**
* Import a Bear .bear2bk backup file.
* Uses JSZip for lazy/streaming decompression to handle large backups.
*/
async function importBearBackup({
collection,
schema,
imported,
extensions,
}: BearImportOptions): Promise<BearImportResult> {
const provider = getProvider(extensions);
// JSZip reads the zip directory without decompressing all entries
const zip = await JSZip.loadAsync(imported);
// Scan entries and group by textbundle
const bundleMap = new Map<string, BundleEntry>();
zip.forEach((path, _entry) => {
if (path.includes('__MACOSX') || path.includes('.DS_Store')) return;
const tbMatch = path.match(/^(.+?\.textbundle)\/(.*)/i);
if (!tbMatch) return;
const bundlePath = tbMatch[1];
const innerPath = tbMatch[2];
if (!bundleMap.has(bundlePath)) {
bundleMap.set(bundlePath, {
bundlePath,
markdownPath: null,
infoJsonPath: null,
assetPaths: [],
});
}
const bundle = bundleMap.get(bundlePath)!;
if (innerPath === 'text.md' || innerPath === 'text.txt') {
bundle.markdownPath = path;
} else if (innerPath === 'info.json') {
bundle.infoJsonPath = path;
} else if (innerPath.startsWith('assets/') && innerPath !== 'assets/') {
bundle.assetPaths.push(path);
}
});
// Read info.json for all bundles to filter out trashed notes
// (info.json is tiny, safe to read all at once)
const validBundles: Array<{
entry: BundleEntry;
bearMeta: Record<string, unknown> | undefined;
}> = [];
for (const entry of bundleMap.values()) {
if (!entry.markdownPath) continue;
let info: Record<string, unknown> = {};
if (entry.infoJsonPath) {
try {
const text = await zip.file(entry.infoJsonPath)!.async('string');
info = JSON.parse(text);
} catch {
// Invalid JSON
}
}
const bearMeta = info['net.shinyfrog.bear'] as
| Record<string, unknown>
| undefined;
if (bearMeta?.trashed === 1) continue;
validBundles.push({ entry, bearMeta });
}
if (validBundles.length === 0) {
throw new Error(
'No valid Bear textbundles found in the archive. Please select a .bear2bk backup file.'
);
}
const docIds: string[] = [];
const tagDocMap = new Map<string, string[]>();
// Process bundles sequentially to limit memory.
// Each bundle is wrapped in try/catch so one bad note does not abort the
// entire import after earlier notes have already been written.
for (const { entry, bearMeta } of validBundles) {
try {
// Read markdown (decompress on demand)
const rawMarkdown = await zip.file(entry.markdownPath!)!.async('string');
if (!rawMarkdown.trim()) continue;
const { tags, content: cleanedMarkdown } = parseBearTags(rawMarkdown);
const bundleDirName =
entry.bundlePath.split('/').findLast(Boolean) ?? 'Untitled';
const title = extractTitle(cleanedMarkdown, bundleDirName);
const markdown = convertHighlights(
convertGfmCallouts(stripBearMetadataComments(cleanedMarkdown))
);
// Read assets on demand (decompress only this bundle's assets)
const pendingAssets = new Map<string, File>();
const pendingPathBlobIdMap = new Map<string, string>();
for (const assetFullPath of entry.assetPaths) {
try {
const data = await zip.file(assetFullPath)!.async('arraybuffer');
const tbMatch = assetFullPath.match(/^.+?\.textbundle\/(.*)/i);
const assetRelPath = tbMatch ? tbMatch[1] : assetFullPath;
const ext = assetRelPath.split('.').at(-1) ?? '';
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
const key = await sha(data);
// Map both the full zip path and the relative path (assets/...)
pendingPathBlobIdMap.set(assetFullPath, key);
pendingPathBlobIdMap.set(assetRelPath, key);
try {
const decodedRel = decodeURIComponent(assetRelPath);
if (decodedRel !== assetRelPath) {
pendingPathBlobIdMap.set(decodedRel, key);
}
const decodedFull = decodeURIComponent(assetFullPath);
if (decodedFull !== assetFullPath) {
pendingPathBlobIdMap.set(decodedFull, key);
}
} catch {
// Invalid URI encoding
}
const fileName = assetRelPath.split('/').pop() ?? '';
pendingAssets.set(key, new File([data], fileName, { type: mime }));
} catch {
// Failed to read asset, skip
}
}
const fullPath = `${entry.bundlePath}/text.md`;
const job = new Transformer({
schema,
blobCRUD: collection.blobSync,
docCRUD: createCollectionDocCRUD(collection),
middlewares: [
defaultImageProxyMiddleware,
fileNameMiddleware(title),
filePathMiddleware(fullPath),
docLinkBaseURLMiddleware(collection.id),
],
});
const assets = job.assets;
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
for (const [p, key] of pendingPathBlobIdMap.entries()) {
pathBlobIdMap.set(p, key);
}
for (const [key, file] of pendingAssets.entries()) {
assets.set(key, file);
}
const mdAdapter = new MarkdownAdapter(job, provider);
const doc = await mdAdapter.toDoc({
file: markdown,
assets: job.assetsManager,
});
if (doc) {
docIds.push(doc.id);
const metaPatch: Record<string, unknown> = {};
if (bearMeta?.creationDate) {
const ts = Date.parse(String(bearMeta.creationDate));
if (!isNaN(ts)) metaPatch.createDate = ts;
}
if (bearMeta?.modificationDate) {
const ts = Date.parse(String(bearMeta.modificationDate));
if (!isNaN(ts)) metaPatch.updatedDate = ts;
}
if (Object.keys(metaPatch).length) {
collection.meta.setDocMeta(doc.id, metaPatch);
}
for (const tag of tags) {
if (!tagDocMap.has(tag)) {
tagDocMap.set(tag, []);
}
tagDocMap.get(tag)!.push(doc.id);
}
}
} catch (err) {
console.warn(`Failed to import bundle: ${entry.bundlePath}`, err);
}
}
const folderHierarchy = buildFolderHierarchyFromTags(tagDocMap);
return { docIds, tags: tagDocMap, folderHierarchy };
}
/** Public API for importing Bear .bear2bk backup archives. */
export const BearTransformer = {
importBearBackup,
};
@@ -1,3 +1,4 @@
export { BearTransformer } from './bear.js';
export { DocxTransformer } from './docx.js';
export { HtmlTransformer } from './html.js';
export { MarkdownTransformer } from './markdown.js';
@@ -462,12 +462,23 @@ async function importMarkdownToDoc({
* @param options.imported The zip file as a Blob
* @returns A Promise that resolves to an array of IDs of the newly created docs
*/
type FolderHierarchy = {
name: string;
path: string;
children: Map<string, FolderHierarchy>;
pageId?: string;
parentPath?: string;
};
async function importMarkdownZip({
collection,
schema,
imported,
extensions,
}: ImportMarkdownZipOptions) {
}: ImportMarkdownZipOptions): Promise<{
docIds: string[];
folderHierarchy?: FolderHierarchy;
}> {
const provider = getProvider(extensions);
const unzip = new Unzip();
await unzip.load(imported);
@@ -476,6 +487,7 @@ async function importMarkdownZip({
const pendingAssets: AssetMap = new Map();
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
const markdownBlobs: ImportedFileEntry[] = [];
const docPathMap: Array<{ fullPath: string; docId: string }> = [];
// Iterate over all files in the zip
for (const { path, content: blob } of unzip) {
@@ -527,10 +539,94 @@ async function importMarkdownZip({
if (doc) {
applyMetaPatch(collection, doc.id, meta);
docIds.push(doc.id);
docPathMap.push({ fullPath, docId: doc.id });
}
})
);
return docIds;
// Build folder hierarchy from zip paths
const folderHierarchy = buildMarkdownZipFolderHierarchy(docPathMap);
return { docIds, folderHierarchy };
}
/**
* Builds a tree of {@link FolderHierarchy} nodes from the zip paths of
* imported markdown files. Returns `undefined` when every entry sits at
* the same level (no real subfolder structure). A common root directory
* shared by all entries is stripped automatically so that the resulting
* hierarchy starts one level deeper.
*/
function buildMarkdownZipFolderHierarchy(
entries: Array<{ fullPath: string; docId: string }>
): FolderHierarchy | undefined {
if (entries.length === 0) return undefined;
// Check if any entries have folder structure
const hasSubfolders = entries.some(e => {
const parts = e.fullPath.split('/').filter(Boolean);
// More than just "root/file.md" -- need at least one real subfolder
return parts.length > 2;
});
if (!hasSubfolders) {
// All files are at the same level, no folder hierarchy needed
return undefined;
}
const root: FolderHierarchy = {
name: '',
path: '',
children: new Map(),
};
// Check once whether all entries share a common root directory
const candidateRoot = entries[0]?.fullPath.split('/').find(Boolean);
const skipRoot =
!!candidateRoot &&
entries.every(e => e.fullPath.startsWith(candidateRoot + '/'));
for (const { fullPath, docId } of entries) {
const parts = fullPath.split('/').filter(Boolean);
const fileName = parts.pop(); // Remove filename
if (!fileName) continue;
let folderParts = skipRoot ? parts.slice(1) : parts;
if (folderParts.length === 0) {
// Root-level file, no folder needed
continue;
}
let current = root;
let currentPath = '';
for (const folderName of folderParts) {
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
if (!current.children.has(folderName)) {
current.children.set(folderName, {
name: folderName,
path: currentPath,
parentPath: parentPath || undefined,
children: new Map(),
});
}
current = current.children.get(folderName)!;
}
// Add the doc as a leaf
const docNodeKey = `__doc__${docId}`;
current.children.set(docNodeKey, {
name: docNodeKey,
path: `${current.path}/${docNodeKey}`,
parentPath: current.path,
children: new Map(),
pageId: docId,
});
}
return root.children.size > 0 ? root : undefined;
}
export const MarkdownTransformer = {
@@ -148,13 +148,14 @@ export class EdgelessRemoteSelectionWidget extends WidgetComponent<RootBlockMode
};
private readonly _updateTransform = requestThrottledConnectedFrame(() => {
const { translateX, translateY, zoom } = this.gfx.viewport;
const { translateX, translateY, zoom, viewScale } = this.gfx.viewport;
this.style.setProperty('--v-zoom', `${zoom}`);
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
this.style.setProperty('--v-zoom', `${zoom / viewScale}`);
this.style.setProperty(
'transform',
`translate(${translateX}px, ${translateY}px) scale(var(--v-zoom))`
`translate(${translateX / viewScale}px, ${translateY / viewScale}px) scale(var(--v-zoom))`
);
}, this);
+4
View File
@@ -0,0 +1,4 @@
.vitepress/cache
.vitepress/dist
.vitepress/.temp
api/
+246
View File
@@ -0,0 +1,246 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import type MarkdownIt from 'markdown-it';
import container from 'markdown-it-container';
import wasm from 'vite-plugin-wasm';
import { defineConfig } from 'vitepress';
import { renderSandbox } from 'vitepress-plugin-sandpack';
import { components, guide, reference } from './sidebar';
// https://vitepress.dev/reference/site-config
export default defineConfig({
// FIXME: remove dead links
ignoreDeadLinks: true,
title: 'BlockSuite',
description: 'Content Editing Tech Stack for the Web',
vite: {
build: {
target: 'ES2022',
},
plugins: [
wasm(),
{
name: 'redirect-plugin',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === '/blocksuite-overview.html') {
res.writeHead(301, { Location: '/guide/overview.html' });
res.end();
} else {
next();
}
});
},
},
],
},
lang: 'en-US',
head: [
[
'link',
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: 'https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg',
},
],
['meta', { property: 'twitter:card', content: 'summary_large_image' }],
[
'meta',
{
property: 'twitter:image',
content:
'https://raw.githubusercontent.com/toeverything/blocksuite/master/packages/docs/images/blocksuite-cover.jpg',
},
],
[
'meta',
{
property: 'og:image',
content:
'https://raw.githubusercontent.com/toeverything/blocksuite/master/packages/docs/images/blocksuite-cover.jpg',
},
],
],
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
outline: [2, 3],
nav: [
{
text: 'Components',
link: '/components/overview',
activeMatch: '/components/*',
},
{
text: 'Framework',
link: '/guide/overview',
activeMatch: '/guide/*',
},
{
text: 'Playground',
link: 'https://try-blocksuite.vercel.app/?init',
},
{
text: 'More',
items: [
{ text: 'Blog', link: '/blog/', activeMatch: '/blog/*' },
{
text: 'API',
link: '/api/',
activeMatch: '/api/*',
},
{
text: 'Releases',
link: 'https://github.com/toeverything/blocksuite/releases',
},
],
},
],
sidebar: {
'/guide/': { base: '/', items: guide },
'/api/': { base: '/', items: reference },
'/components/': { base: '/', items: components },
},
socialLinks: [
{ icon: 'github', link: 'https://github.com/toeverything/blocksuite' },
{
icon: {
svg: '<svg role="img" xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path fill="#777777" d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z"/></svg>',
},
link: 'https://twitter.com/AffineDev',
},
],
footer: {
copyright: 'Copyright © 2022-present Toeverything',
},
search: {
provider: 'local',
options: {
_render(src, env, md) {
if (env.relativePath.startsWith('api/')) {
return '';
}
return md.render(src, env);
},
},
},
},
markdown: {
config(md) {
md.use(container, 'code-sandbox', {
render(tokens, idx) {
return renderSandbox(tokens, idx, 'code-sandbox');
},
});
rewriteApiMemberLinks(md);
},
},
});
const apiMemberLinkPattern =
/^\/api\/@blocksuite\/(.+)\/(?:classes|enumerations|functions|interfaces|type-aliases|variables)\/([^/?#]+)(?:\.html)?((?:\?[^#]*)?(?:#.*)?)?$/;
function rewriteApiMemberLinks(md: MarkdownIt) {
const apiMemberTargets = getApiMemberTargets();
const defaultRender =
md.renderer.rules.link_open ??
((tokens, idx, options, _env, self) =>
self.renderToken(tokens, idx, options));
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0 && token.attrs) {
token.attrs[hrefIndex][1] = rewriteApiMemberLink(
token.attrs[hrefIndex][1],
apiMemberTargets
);
}
return defaultRender(tokens, idx, options, env, self);
};
}
function rewriteApiMemberLink(
href: string,
apiMemberTargets: Map<string, string>
) {
const match = href.match(apiMemberLinkPattern);
if (!match) {
return href;
}
const [, packagePath, memberFileName, suffix = ''] = match;
const target = apiMemberTargets.get(decodeURIComponent(memberFileName));
if (target) {
return `${target}${suffix}`;
}
return `/api/@blocksuite/${packagePath}.html${suffix}`;
}
function getApiMemberTargets() {
const apiDir = join(process.cwd(), 'api');
const targets = new Map<string, string>();
if (!existsSync(apiDir)) {
return targets;
}
for (const file of findMarkdownFiles(apiDir)) {
const route = `/api/${relative(apiDir, file)
.replace(/\.md$/, '.html')
.split(sep)
.join('/')}`;
for (const line of readFileSync(file, 'utf8').split('\n')) {
const member = line.match(/^### (.+)$/);
if (!member) {
continue;
}
const name = getApiMemberName(member[1]);
if (name && !targets.has(name)) {
targets.set(name, route);
}
}
}
return targets;
}
function findMarkdownFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return findMarkdownFiles(path);
}
return entry.isFile() && entry.name.endsWith('.md') ? [path] : [];
});
}
function getApiMemberName(heading: string) {
return heading
.replaceAll('`', '')
.replace(/\\([<>])/g, '$1')
.replace(/^(abstract|readonly)\s+/, '')
.replace(/\(\)$/, '')
.replace(/<.*>$/, '')
.trim();
}
+228
View File
@@ -0,0 +1,228 @@
import { existsSync, readdirSync } from 'node:fs';
import { join, parse } from 'node:path';
import type { DefaultTheme } from 'vitepress';
export const guide: DefaultTheme.NavItem[] = [
{
text: 'Introduction',
items: [
{ text: 'Overview', link: 'guide/overview' },
{ text: 'Quick Start', link: 'guide/quick-start' },
],
},
{
text: 'Framework Guide',
items: [
{ text: 'Component Types', link: 'guide/component-types' },
{
text: 'Working with Block Tree',
// @ts-expect-error nested items are supported by VitePress at runtime
link: 'guide/working-with-block-tree',
items: [
{
text: 'Block Tree Basics',
link: 'guide/working-with-block-tree#block-tree-basics',
},
{
text: 'Block Tree in Editor',
link: 'guide/working-with-block-tree#block-tree-in-editor',
},
{
text: 'Selecting Blocks',
link: 'guide/working-with-block-tree#selecting-blocks',
},
{
text: 'Service and Commands',
link: 'guide/working-with-block-tree#service-and-commands',
},
{
text: 'Defining New Blocks',
link: 'guide/working-with-block-tree#defining-new-blocks',
},
],
},
{ text: 'Data Synchronization', link: 'guide/data-synchronization' },
],
},
{
text: 'Framework Handbook',
items: [
{
text: '<code>block-std</code>',
items: [
{
text: 'Block Spec',
link: 'guide/block-spec',
// @ts-expect-error nested items are supported by VitePress at runtime
items: [
{ text: 'Block Schema', link: 'guide/block-schema' },
{ text: 'Block Service', link: 'guide/block-service' },
{ text: 'Block View', link: 'guide/block-view' },
{ text: 'Block Widgets', link: 'guide/block-widgets' },
],
},
{
text: 'Selection',
link: 'guide/selection',
},
{ text: 'Event', link: 'guide/event' },
{ text: 'Command', link: 'guide/command' },
],
},
{
text: '<code>store</code>',
items: [
{ text: 'Doc', link: 'guide/store#doc' },
{ text: 'DocCollection', link: 'guide/store#doccollection' },
{ text: 'Slot', link: 'guide/slot' },
{ text: 'Adapter', link: 'guide/adapter' },
],
},
{
text: '<code>inline</code>',
link: 'guide/inline',
},
],
},
{
text: 'Developing BlockSuite',
items: [
{
text: 'Building Packages',
link: '//github.com/toeverything/blocksuite/blob/master/BUILDING.md',
},
{
text: 'Running Tests',
link: '//github.com/toeverything/blocksuite/blob/master/BUILDING.md#testing',
},
],
},
];
export const reference: DefaultTheme.NavItem[] = [
{
text: 'API Reference',
items: getApiReferenceItems(),
},
];
function getApiReferenceItems(): DefaultTheme.NavItem[] {
const apiDir = join(process.cwd(), 'api', '@blocksuite');
if (!existsSync(apiDir)) {
return [
{ text: '@blocksuite/store', link: 'api/@blocksuite/store' },
{ text: '@blocksuite/std', link: 'api/@blocksuite/std/index' },
{ text: '@blocksuite/affine', link: 'api/@blocksuite/affine' },
];
}
return readdirSync(apiDir, { withFileTypes: true })
.flatMap(entry => {
if (entry.isFile() && entry.name.endsWith('.md')) {
const name = parse(entry.name).name;
return [
{ text: `@blocksuite/${name}`, link: `api/@blocksuite/${name}` },
];
}
if (entry.isDirectory()) {
const indexPath = join(apiDir, entry.name, 'index.md');
if (existsSync(indexPath)) {
return [
{
text: `@blocksuite/${entry.name}`,
link: `api/@blocksuite/${entry.name}/index`,
},
];
}
}
return [];
})
.sort((a, b) => a.text.localeCompare(b.text));
}
export const components: DefaultTheme.NavItem[] = [
{
text: 'Introduction',
items: [{ text: 'Overview', link: 'components/overview' }],
},
{
text: 'Editors',
items: [
{ text: '📝 Page Editor', link: 'components/editors/page-editor' },
{
text: '🎨 Edgeless Editor',
// @ts-expect-error nested items are supported by VitePress at runtime
link: 'components/editors/edgeless-editor',
items: [
{
text: 'Data Structure',
link: 'components/editors/edgeless-data-structure',
},
],
},
],
},
{
text: 'Blocks',
items: [
{
text: 'Regular Blocks',
items: [
{ text: 'Root Block', link: 'components/blocks/root-block' },
{ text: 'Note Block', link: 'components/blocks/note-block' },
{
text: 'Paragraph Block',
link: 'components/blocks/paragraph-block',
},
{ text: 'List Block', link: 'components/blocks/list-block' },
{ text: 'Code Block', link: 'components/blocks/code-block' },
{ text: 'Image Block', link: 'components/blocks/image-block' },
{
text: 'Attachment Block',
link: 'components/blocks/attachment-block',
},
{ text: 'Divider Block', link: 'components/blocks/divider-block' },
],
},
{
text: 'Advanced Blocks',
items: [
{ text: 'Surface Block', link: 'components/blocks/surface-block' },
{
text: 'Database Block',
link: 'components/blocks/database-block',
},
{ text: 'Frame Block', link: 'components/blocks/frame-block' },
{ text: 'Link Blocks', link: 'components/blocks/link-blocks' },
{ text: 'Embed Blocks', link: 'components/blocks/embed-blocks' },
],
},
],
},
{
text: 'Widgets 🚧',
items: [
{ text: 'Slash Menu', link: 'components/widgets/slash-menu' },
{ text: 'Format Bar', link: 'components/widgets/format-bar' },
{ text: 'Drag Handle', link: 'components/widgets/drag-handle' },
],
},
{
text: 'Fragments 🚧',
items: [
{ text: 'Doc Title', link: 'components/fragments/doc-title' },
{ text: 'Outline Panel', link: 'components/fragments/outline-panel' },
{ text: 'Frame Panel', link: 'components/fragments/frame-panel' },
{ text: 'Copilot Panel', link: 'components/fragments/copilot-panel' },
{
text: 'Bi-Directional Link Panel',
link: 'components/fragments/bi-directional-link-panel',
},
],
},
];
@@ -0,0 +1,79 @@
<template>
<h1>BlockSuite Blog</h1>
<div class="blog-posts-container">
<div class="blog-post" v-for="post in posts">
<a :href="post.url">
<h2 class="blog-post-title">{{ post.title }}</h2>
</a>
<div class="blog-post-excerpt">
{{ post.excerpt }}
<a class="blog-post-read-more" :href="post.url">Read more </a>
</div>
<div class="blog-post-date">{{ post.date.formatted }}</div>
</div>
</div>
</template>
<script setup>
import { usePosts } from '../composables/use-posts';
const { posts } = usePosts();
</script>
<style scoped>
h1 {
margin: auto;
margin-top: 30px;
margin-bottom: 50px;
font-size: 50px;
font-weight: bolder;
line-height: 50px;
text-align: center;
}
@media (max-width: 768px) {
h1 {
font-size: 40px;
line-height: 40px;
}
}
.blog-posts-container {
max-width: 800px;
margin: auto;
padding-left: 30px;
padding-right: 30px;
}
.blog-post {
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 1px solid #eaecef;
}
.blog-post-title {
margin: 0;
font-size: 24px;
font-weight: bold;
color: var(--vp-c-text-1);
}
.blog-post-date {
font-size: 14px;
color: var(--vp-c-text-3);
}
.blog-post-excerpt {
margin-top: 10px;
margin-bottom: 5px;
line-height: 1.6;
}
.dark .blog-post {
border-bottom: 1px solid #343a40;
}
.blog-post-read-more:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,36 @@
<template>
<div class="blog-post-meta">
<span class="post-date">{{ post.date.formatted }}</span> by
<span v-html="formattedAuthors"></span>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { usePosts } from '../composables/use-posts';
const { post } = usePosts();
const formattedAuthors = computed(() => {
return post.value.authors
.map(
author =>
`<a class="author" href="${author.link}" target="_blank">${author.name}</a>`
)
.join(', ');
});
</script>
<style>
.blog-post-meta {
margin-top: 8px;
font-size: 14px;
}
.author {
color: var(--vp-c-text-1) !important;
}
.post-date {
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,36 @@
<template>
<!-- 'code-options' is a build-in prop, do not edit it -->
<Sandbox
:rtl="rtl"
:template="'vanilla-ts'"
:light-theme="lightTheme"
:dark-theme="darkTheme"
:options="{
...props, // do not forget it
coderHeight: Number(props.coderHeight),
previewHeight: Number(props.previewHeight),
showLineNumbers: true,
}"
:custom-setup="{
...props, // do not forget it
deps: {
yjs: 'latest',
'@toeverything/theme': 'latest',
'@blocksuite/presets': 'canary',
},
}"
:code-options="codeOptions"
>
<slot />
</Sandbox>
</template>
<script setup lang="ts">
import { Sandbox, sandboxProps } from 'vitepress-plugin-sandpack';
const props = defineProps({
...sandboxProps,
coderHeight: String,
previewHeight: String,
});
</script>
@@ -0,0 +1,6 @@
<template>
<img
style="width: 70%; height: 100%; margin: auto; opacity: 0.8"
src="https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg"
/>
</template>
@@ -0,0 +1,26 @@
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
name: string;
icon?: string;
}>();
const src = computed(() => {
if (props.icon) return props.icon;
return `https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/${props.name.toLowerCase()}.svg`;
});
</script>
<template>
<img :src="src" :alt="`${name} Logo`" />
</template>
<style scoped>
img {
display: inline;
transform: translateY(5px);
margin-right: 8px;
width: 20px;
}
</style>
@@ -0,0 +1,44 @@
import { format, formatDistance } from 'date-fns';
import { createContentLoader } from 'vitepress';
interface Post {
title: string;
authors: { name: string; link: string }[];
url: string;
date: {
raw: string;
time: number;
formatted: string;
since: string;
};
}
function formatDate(raw: string) {
const date = new Date(raw);
date.setUTCHours(8);
return {
raw: date.toISOString().split('T')[0],
time: +date,
formatted: format(date, 'yyyy/MM/dd'),
since: formatDistance(date, new Date(), { addSuffix: true }),
};
}
const data = [] as Post[];
export { data };
export default createContentLoader('blog/*.md', {
includeSrc: true,
transform(raw) {
return raw
.filter(item => item.url !== '/blog/')
.map(({ url, frontmatter }) => ({
title: frontmatter.title,
authors: frontmatter.authors ?? [],
excerpt: frontmatter.excerpt ?? '',
url,
date: formatDate(frontmatter.date),
}))
.sort((a, b) => b.date.time - a.date.time);
},
});
@@ -0,0 +1,19 @@
import { useRoute } from 'vitepress';
import { computed } from 'vue';
import { data as posts } from './posts.data';
export function usePosts() {
const route = useRoute();
const path = route.path;
function findCurrentIndex() {
const result = posts.findIndex(p => p.url === route.path);
if (result === -1) console.error(`blog post missing: ${route.path}`);
return result;
}
const post = computed(() => posts[findCurrentIndex()]);
return { posts, post, path };
}
@@ -0,0 +1,29 @@
// https://vitepress.dev/guide/custom-theme
import 'vitepress-plugin-sandpack/dist/style.css';
import './style.css';
import Theme from 'vitepress/theme';
import { h } from 'vue';
import BlogListLayout from './components/blog-list-layout.vue';
import BlogPostMeta from './components/blog-post-meta.vue';
import CodeSandbox from './components/code-sandbox.vue';
import HeroLogo from './components/hero-logo.vue';
import Icon from './components/icon.vue';
export default {
...Theme,
Layout: () => {
return h(Theme.Layout, null, {
// https://vitepress.dev/guide/extending-default-theme#layout-slots
'home-hero-image': () => h(HeroLogo),
// 'home-features-after': () => h(Playground),
});
},
enhanceApp({ app }) {
app.component('Icon', Icon);
app.component('BlogListLayout', BlogListLayout);
app.component('BlogPostMeta', BlogPostMeta);
app.component('CodeSandbox', CodeSandbox);
},
};
@@ -0,0 +1,226 @@
/**
* Customize default theme styling by overriding CSS variables:
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
*/
/**
* Colors
* -------------------------------------------------------------------------- */
:root {
--vp-c-brand: #646cff;
--vp-c-brand-light: #747bff;
--vp-c-brand-lighter: #9499ff;
--vp-c-brand-lightest: #bcc0ff;
--vp-c-brand-dark: #535bf2;
--vp-c-brand-darker: #454ce1;
--vp-c-brand-dimm: rgba(100, 108, 255, 0.08);
}
/**
* Component: Button
* -------------------------------------------------------------------------- */
:root {
--vp-button-brand-border: var(--vp-c-brand-light);
--vp-button-brand-text: var(--vp-c-white);
--vp-button-brand-bg: var(--vp-c-brand);
--vp-button-brand-hover-border: var(--vp-c-brand-light);
--vp-button-brand-hover-text: var(--vp-c-white);
--vp-button-brand-hover-bg: var(--vp-c-brand-light);
--vp-button-brand-active-border: var(--vp-c-brand-light);
--vp-button-brand-active-text: var(--vp-c-white);
--vp-button-brand-active-bg: var(--vp-button-brand-bg);
}
/**
* Component: Home
* -------------------------------------------------------------------------- */
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(
120deg,
#bd34fe 30%,
#41d1ff
);
--vp-home-hero-image-background-image: linear-gradient(
-45deg,
#bd34fe 50%,
#47caff 50%
);
--vp-home-hero-image-filter: blur(40px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(72px);
}
}
/**
* Component: Custom Block
* -------------------------------------------------------------------------- */
:root {
--vp-custom-block-tip-border: var(--vp-c-brand);
--vp-custom-block-tip-text: var(--vp-c-brand-darker);
--vp-custom-block-tip-bg: var(--vp-c-brand-dimm);
}
.dark {
--vp-custom-block-tip-border: var(--vp-c-brand);
--vp-custom-block-tip-text: var(--vp-c-brand-lightest);
--vp-custom-block-tip-bg: var(--vp-c-brand-dimm);
}
/**
* Component: Algolia
* -------------------------------------------------------------------------- */
.DocSearch {
--docsearch-primary-color: var(--vp-c-brand) !important;
}
/**
* VitePress: Custom fix
* -------------------------------------------------------------------------- */
/*
Use lighter colors for links in dark mode for a11y.
Also specify some classes twice to have higher specificity
over scoped class data attribute.
*/
.dark .vp-doc a,
.dark .vp-doc a > code,
.dark .VPNavBarMenuLink.VPNavBarMenuLink:hover,
.dark .VPNavBarMenuLink.VPNavBarMenuLink.active,
.dark .link.link:hover,
.dark .link.link.active,
.dark .edit-link-button.edit-link-button,
.dark .pager-link .title {
color: var(--vp-c-brand-lighter);
}
.dark .vp-doc a:hover,
.dark .vp-doc a > code:hover {
color: var(--vp-c-brand-lightest);
opacity: 1;
}
/* Transition by color instead of opacity */
.dark .vp-doc .custom-block a {
transition: color 0.25s;
}
html[class='dark'] {
--affine-theme-mode: dark;
--affine-popover-shadow:
0px 1px 10px -6px rgba(24, 39, 75, 0.08),
0px 3px 16px -6px rgba(24, 39, 75, 0.04);
--affine-font-h-1: 28px;
--affine-font-h-2: 26px;
--affine-font-h-3: 24px;
--affine-font-h-4: 22px;
--affine-font-h-5: 20px;
--affine-font-h-6: 18px;
--affine-font-base: 16px;
--affine-font-sm: 14px;
--affine-font-xs: 12px;
--affine-line-height: calc(1em + 8px);
--affine-z-index-modal: 1000;
--affine-z-index-popover: 1000;
--affine-font-family:
Avenir Next, Poppins, apple-system, BlinkMacSystemFont, Helvetica Neue,
Tahoma, PingFang SC, Microsoft Yahei, Arial, Hiragino Sans GB, sans-serif,
Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
--affine-font-number-family:
Roboto Mono, apple-system, BlinkMacSystemFont, Helvetica Neue, Tahoma,
PingFang SC, Microsoft Yahei, Arial, Hiragino Sans GB, sans-serif,
Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
--affine-font-code-family:
Space Mono, Consolas, Menlo, Monaco, Courier, monospace, apple-system,
BlinkMacSystemFont, Helvetica Neue, Tahoma, PingFang SC, Microsoft Yahei,
Arial, Hiragino Sans GB, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol, Noto Color Emoji;
--affine-paragraph-space: 8px;
--affine-popover-radius: 10px;
--affine-zoom: 1;
--affine-scale: calc(1 / var(--affine-zoom));
--affine-brand-color: rgb(84, 56, 255);
--affine-primary-color: rgb(118, 95, 254);
--affine-secondary-color: rgb(144, 150, 245);
--affine-tertiary-color: rgb(30, 30, 30);
--affine-hover-color: rgba(255, 255, 255, 0.1);
--affine-icon-color: rgb(168, 168, 160);
--affine-border-color: rgb(57, 57, 57);
--affine-divider-color: rgb(114, 114, 114);
--affine-placeholder-color: rgb(62, 62, 63);
--affine-quote-color: rgb(100, 95, 130);
--affine-link-color: rgb(185, 191, 227);
--affine-edgeless-grid-color: rgb(49, 49, 49);
--affine-success-color: rgb(77, 213, 181);
--affine-warning-color: rgb(255, 123, 77);
--affine-error-color: rgb(212, 140, 130);
--affine-processing-color: rgb(195, 215, 255);
--affine-text-emphasis-color: rgb(208, 205, 220);
--affine-text-primary-color: rgb(234, 234, 234);
--affine-text-secondary-color: rgb(156, 156, 160);
--affine-text-disable-color: rgb(119, 117, 125);
--affine-black-10: rgba(255, 255, 255, 0.1);
--affine-black-30: rgba(255, 255, 255, 0.3);
--affine-black-50: rgba(255, 255, 255, 0.5);
--affine-black-60: rgba(255, 255, 255, 0.6);
--affine-black-80: rgba(255, 255, 255, 0.8);
--affine-black-90: rgba(255, 255, 255, 0.9);
--affine-black: rgb(255, 255, 255);
--affine-white-10: rgba(0, 0, 0, 0.1);
--affine-white-30: rgba(0, 0, 0, 0.3);
--affine-white-50: rgba(0, 0, 0, 0.5);
--affine-white-60: rgba(0, 0, 0, 0.6);
--affine-white-80: rgba(0, 0, 0, 0.8);
--affine-white-90: rgba(0, 0, 0, 0.9);
--affine-white: rgb(0, 0, 0);
--affine-background-code-block: rgb(41, 44, 51);
--affine-background-tertiary-color: rgb(30, 30, 30);
--affine-background-processing-color: rgb(255, 255, 255);
--affine-background-error-color: rgb(255, 255, 255);
--affine-background-warning-color: rgb(255, 255, 255);
--affine-background-success-color: rgb(255, 255, 255);
--affine-background-primary-color: rgb(20, 20, 20);
--affine-background-hover-color: rgb(47, 47, 47);
--affine-background-secondary-color: rgb(32, 32, 32);
--affine-background-modal-color: rgba(0, 0, 0, 0.8);
--affine-background-overlay-panel-color: rgb(30, 30, 30);
--affine-tag-blue: rgb(10, 84, 170);
--affine-tag-green: rgb(55, 135, 79);
--affine-tag-teal: rgb(33, 145, 138);
--affine-tag-white: rgb(84, 84, 84);
--affine-tag-purple: rgb(59, 38, 141);
--affine-tag-red: rgb(139, 63, 63);
--affine-tag-pink: rgb(194, 132, 132);
--affine-tag-yellow: rgb(187, 165, 61);
--affine-tag-orange: rgb(231, 161, 58);
--affine-tag-gray: rgb(41, 41, 41);
--affine-palette-yellow: rgb(255, 232, 56);
--affine-palette-orange: rgb(255, 175, 56);
--affine-palette-tangerine: rgb(255, 99, 31);
--affine-palette-red: rgb(252, 63, 85);
--affine-palette-magenta: rgb(255, 56, 179);
--affine-palette-purple: rgb(182, 56, 255);
--affine-palette-navy: rgb(59, 37, 204);
--affine-palette-blue: rgb(79, 144, 255);
--affine-palette-green: rgb(16, 203, 134);
--affine-palette-grey: rgb(153, 153, 153);
--affine-palette-white: rgb(255, 255, 255);
--affine-palette-black: rgb(0, 0, 0);
}
@@ -0,0 +1,87 @@
---
title: CRDT-Native Data Flow in BlockSuite
date: 2023-04-15
authors:
- name: Yifeng Wang
link: 'https://twitter.com/ewind1994'
- name: Saul-Mirone
link: 'https://github.com/Saul-Mirone'
excerpt: To make editors intuitive and collaboration-ready, BlockSuite ensure that regardless of whether you are collaborating with others or not, the application code should be unaware of it. This article introduce how this is designed.
---
# CRDT-Native Data Flow in BlockSuite
<BlogPostMeta />
To make editors intuitive and collaboration-ready, BlockSuite ensure that regardless of whether you are collaborating with others or not, the application code should be unaware of it. This article introduce how this is designed.
## CRDT as Single Source of Truth
Traditionally, CRDTs have often been seen as a technology specialized in conflict resolution. Many editors initially designed to support single users have implemented support for real-time collaboration by integrating CRDT libraries. To this end, the data models in these editors will be synchronized to the CRDTs. This usually involves two opposite data flows:
- When the local model is updated, the state of the native model is synchronized to the CRDT model.
- When a remote peer is updated, the data resolved from the CRDT model is synchronized back to the native model.
![bidirectional-data-flow](../images/bidirectional-data-flow.png)
Although this is an intuitive and common practice, it requires synchronization between two heterogeneous models, resulting in a bidirectional data flow. The main issues here are:
- This bidirectional binding is not that easy to implement reliably and requires non-trivial modifications.
- Application-layer code often needs to distinguish whether an update comes from a remote source, which increases complexity.
As an alternative, BlockSuite chooses to directly use the CRDT model as the single source of truth (since BlockSuite uses [Yjs](https://github.com/yjs/yjs), we also call it _YModel_ here). This means that regardless of whether the update comes from local or remote sources, the same process will be performed:
1. Firstly modify YModel, triggering the corresponding [`Y.Event`](https://docs.yjs.dev/api/y.event) that contains all incremental state changes in this update.
2. Update the model nodes in the block tree based on the `Y.Event`.
3. Send corresponding slot events after updating the block model, so as to update UI components accordingly.
This design can be represented by the following diagram:
![crdt-native-data-flow](../images/crdt-native-data-flow.png)
The advantage of this approach is that the application-layer code can **completely ignore whether updates to the block model come from local editing, history stack, or collaboration with other users**. Just subscribing to model update events is adequate.
## Case Study
As an example, suppose the current block tree structure is as follows:
```
RootBlock
NoteBlock
ParagraphBlock 0
ParagraphBlock 1
ParagraphBlock 2
```
Now user A selects `ParagraphBlock 2` and presses the delete key to delete it. At this point, `doc.deleteBlock` should be called to delete this block model instance:
```ts
const blockModel = doc.root.children[0].children[2];
doc.deleteBlock(blockModel);
```
At this point, BlockSuite does not directly modify the block tree under `doc.root`, but will instead firstly modify the underlying YBlock. After the CRDT state is changed, Yjs will generate the corresponding [Y.Event](https://docs.yjs.dev/api/y.event) data structure, which contains all the incremental state changes in this update (similar to incremental patches in git and virtual DOM). BlockSuite will always use this as the basis to synchronize the block models, then trigger the corresponding slot events for UI updates.
In this example, as the parent of `ParagraphBlock 2`, the `model.childrenUpdated` slot event of `NoteBlock` will be triggered. This will enable the corresponding component in the UI framework component tree to refresh itself. Since each child block has an ID, this is very conducive to combining the common list key optimizations in UI frameworks, achieving on-demand block component updates.
But the real power lies in the fact that if this block tree is being concurrently edited by multiple people, when user B performs a similar operation, the corresponding update will be encoded by Yjs and distributed by the provider. **When User A receives and applies the update from User B, the same state update pipeline as local editing will be triggered**. This makes it unnecessary for the application to make any additional modifications or adaptations for collaboration scenarios, inherently gaining real-time collaboration capabilities.
## Unidirectional Update Flow
Besides the block tree that uses CRDT as its single source of truth, BlockSuite also manages shared states that do not require a history of changes, such as the awareness state of each user's cursor position. Additionally, some user metadata may not be shared among all users.
In BlockSuite, the management of these state types follows a consistent, unidirectional pattern, enabling an intuitive one-way update flow that efficiently translates state changes into visual updates.
The complete state update process in BlockSuite involves several distinct steps, particularly when handling editor-related UI interactions:
1. **UI Event Handling**: View components generate UI events like clicks and drags, initiating corresponding callbacks. In BlockSuite, it is recommended to model and reuse these interactions using commands.
2. **State Manipulation via Commands**: Commands can manipulate the editor state to accomplish UI updates.
3. **State-Driven View Updates**: Upon state changes, slot events are used to notify and update view components accordingly.
![block-std-data-flow](../images/block-std-data-flow.png)
This update mechanism is depicted in the diagram above. Concepts such as [command](../guide/command), [view](../guide/block-view) and [event](../guide/event) are further elaborated in other documentation sections for detailed understanding.
## Summary
In summary, by utilizing the CRDT model as the single source of truth, the application layer code can remain agnostic to whether updates originate from local or remote sources. This simplifies synchronization and reduces complexity. This approach enables applications to acquire real-time collaboration capabilities without necessitating intrusive modifications or adaptations, which is a key reason why the BlockSuite editor has been inherently _collaborative_ from day one.
@@ -0,0 +1,196 @@
---
title: Building Document-Centric, CRDT-Native Editors
date: 2024-01-10
authors:
- name: Yifeng Wang
link: 'https://twitter.com/ewind1994'
excerpt: 'This article presents the document-centric way for building editors, and why CRDT is required to make this happpen.'
---
# Building Document-Centric, CRDT-Native Editors
<BlogPostMeta />
## Motivation
For years, web frameworks such as React and Vue have popularized the mental model of component based development. This approach allows us to break down complex front-end applications into components for better composition and maintenance.
Hence, when discussing front-end collaborative editing (or rich text editing), the first thought is often to define an `<Editor/>` component, then design the corresponding data flow and APIs around this editor. This method seems intuitive and has been adopted by many open-source editors in the front-end community. Everything sounds natural, but are there limitations or room for improvement?
In the past years, our team has been dedicated to building a notable open-source knowledge base product ([26k stars on GitHub](https://github.com/toeverything/AFFiNE)). To visualize and organize complex knowledge structures better, we wanted our the editor in our product to be powerful enough, so as to provide an immersive editing and collaboration experience - imagine nesting Google Docs or Notion in an infinite canvas like Figma, as shown below:
![affine-demo](../images/affine-demo.jpg)
However, before finding the best practice, our journey in developing editors was full of challenges. At first glance, the front-end community offers many great rich text editors (like [Slate](https://github.com/ianstormtaylor/slate), [Tiptap](https://tiptap.dev/), [Lexical](https://lexical.dev/)) and whiteboard editors (like [tldraw](https://github.com/tldraw/tldraw)), which usually allows the _embedding_ of React components. Bundling various React-compatible editors together seemed convenient - but proved impractical. To some extent, this is like trying to cram several devices supporting the USB protocol into the same shell. Despite sharing the same interface, there's no guarantee the resulting product will work correctly.
The frustration encountered in directly integrating various open-source editors led us to question the current design philosophy of popular editing frameworks. As a result, we decided to rebuild all necessary infrastructure for our editors, based on recent breakthroughs in collaborative editing technology (specifically, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)). The outcome was a powerful design pattern that no longer revolves around the editor. We call this approach _**document-centric**_.
## The Document-Centric Approach
We believe that the current mainstream editing frameworks design their data flow around the `<Editor/>` component, with each editor managing its internal state cohesively. While this is a good design, some issues are hard to resolve:
- The data loading methods and internal state management mechanisms in different editors are not universal, making cross-editor state sharing difficult, often requiring redundant deep copies.
- Different editor containers have distinct internal life cycles, complicating the establishment of a consistent component model.
- The strong binding between document data and editor instances makes sharing a single document across multiple editor instances difficult, or managing multiple documents within a single editor instance.
- Although editors generally support embedding external components, nesting editors can easily lead to conflicts in focus, selection, shortcuts, etc.
Consider a simple example where a text editor A and an image editor B are used together:
![composing-editors-1](../images/composing-editors-1.png)
For a simple user operation sequence:
- Perform several image editing operations.
- Delete the image, which will usually dispose its UI component.
- Continue editing the text.
![composing-editors-2](../images/composing-editors-2.png)
If A and B are independently implemented editors, how should the user operation history be managed? Allowing A and B to maintain their history states seems easier, but neither can hold a complete user operation history. When an editor instance is destroyed, the history stack recording user operations generally disappears. Therefore, this often requires bookkeeping outside these editor instances, which is only the beginning of a series of complexities.
Alternatively, in the document-centric model, **we believe that the _document_ - the data layer of the editor, should be maintained completely independent of the editor, allowing the document to persist throughout the application lifecycle**. Thus, no matter if a UI component is part of an editor or not, it should work by simply _**attaching**_ to this document, like this:
![attach-editors](../images/attach-editors.png)
Once the document is separated from the editor, it becomes easy to overcome many difficulties under the editor-centric approach:
- The above example is no longer a problem. Since the history record is stored in this persistently existing document, there's no need for bookkeeping between editor instances.
- Cross-editor state sharing can become zero-cost. Because the document (here we are referring to the editable content, not the global DOM variable) is also just a plain JavaScript object, which could be easily shared between different editor instances.
- Since editor instances are no longer strictly bound to document instances, rendering multiple documents in a single editor or displaying a single document across multiple editors becomes intuitively feasible.
In other words, **the document-centric approach aims to establish a data layer that transcends editor boundaries, requiring various editors to drive their updates based on the (whole or partial) state of the external document, thus building a more flexible and diverse experience in a scalable way**.
But given the complexity of collaborative document editing, is such architecture technically feasible?
## Document-Centric and CRDT
Collaborative document editing is known for its complexity. Beyond handling user undo/redo history, traditional real-time collaboration requires complex algorithms like [Operational Transformation](https://en.wikipedia.org/wiki/Operational_transformation) to model editing actions into several restricted operations. Fortunately, CRDTs, which have made breakthrough progress in recent years, can encapsulate this complexity, making the document-centric model possible. **In other words, we believe document-centric needs to be built on the foundation of CRDT**.
Delving into the workings of CRDT is beyond the scope of this article. If you're unfamiliar with CRDT, all you need to know is that when used as a foundational library, CRDTs offer an experience and optimizations akin to standard JavaScript data types, much like the [ImmutableJS](https://immutable-js.com/).
Here's an example using ImmutableJS:
```ts
import Immutable from 'immutable';
let immutableMap = Immutable.Map({ key1: 'value1' });
immutableMap = immutableMap.set('key2', 'value2');
// { key1: 'value1', key2: 'value2' }
console.log(immutableMap.toJSON());
```
And here's an (intuitively symmetrical) example using [Yjs](https://github.com/yjs/yjs), a popular CRDT library:
```ts
import * as Y from 'yjs';
const yMap = new Y.Map();
yMap.set('key1', 'value1');
yMap.set('key2', 'value2');
// Supposed to be { key1: 'value1', key2: 'value2' }
console.log(yMap.toJSON());
```
But be aware, this example won't work as expected! Here `yMap.toJSON()` will return an empty object. Because in Yjs, **you actually need to create a `Y.Doc` first**, then can you use CRDT data types like `Y.Map` / `Y.Array` / `Y.Text`:
```ts
import * as Y from 'yjs';
const yDoc = new Y.Doc();
// You need to `getMap` for top-level fields
const yMap = yDoc.getMap('hello');
yMap.set('key1', 'value1');
yMap.set('key2', 'value2');
// Only then can you attach nested data to doc nodes
yMap.set('key3', new Y.Map());
// { key1: 'value1', key2: 'value2', key3: {} }
console.log(yMap.toJSON());
// { hello: { key1: 'value1', key2: 'value2', key3: {} } }
console.log(yDoc.toJSON());
```
To some extent, **this API design is precisely a representation of the document-centric approach**! Since all state changes are compulsively recorded on one persistently existing `Y.Doc`, it's highly apt for serving as the single source of truth for the state of UI components like editors. Documents based on Yjs have these capabilities:
- They can represent content structures equivalent to JSON, which includes maps, arrays, and various primitive data types in JavaScript.
- Rich text nodes (using `Y.Text` instead of just `string`) can be optionally utilized within the document tree.
- Highly granular event notifications are sent when document tree nodes are updated, potentially replacing the need for a virtual DOM!
- Documents can be serialized into a binary structure akin to [protobuf](https://protobuf.dev/) or RSC payload (see [y-protocols](https://github.com/yjs/y-protocols)), and incremental encoding of partial updates to the document is also possible.
- In collaborative scenarios, these updates can be broadcast directly. Clients don't need to take care about the order of update application to achieve a consistent merged result (as guaranteed by the CRDT algorithm), enabling reliable real-time collaboration among multiple users.
As shown in the following diagram, the entire `Y.Doc` can be encoded into binary updates like the ones depicted, and all subsequent updates such as `yMap.set()` can also be incrementally encoded into the same binary patch:
![encoded-crdt-binary](../images/encoded-crdt-binary.png)
This mechanism is similar to git. Each `Y.Doc` works like a git repository, and every operation on the CRDT document, such as `yMap.set()`, is akin to performing a `git commit`. This is because, like git, CRDT records all historical operations but without merge conflicts. Naturally, this also makes history management based on CRDT (akin to `git revert`) possible. These capabilities are sufficient for implementing a complete data layer based on CRDT.
Therefore, we chose to implement a common document data layer based on Yjs. This results in the following application data flow:
![crdt-native-data-flow](../images/crdt-native-data-flow.png)
The blue part owns the full capability to drive UI in complex collaborative applications, including the management of rich text, history, conflict resolution, model update events, etc. This part has a well-defined isolation boundary from UI components and can be used independently of editors. We believe this is the data layer needed for being document-centric.
## The BlockSuite Showcase
Embracing the document-centric philosophy, we created the [BlockSuite](https://github.com/toeverything/blocksuite) project.
In BlockSuite, documents are modeled as `doc` objects. Each doc holds a tree of blocks. Some editor presets can be used upon connecting to a doc as following:
```ts
import { createEmptyDoc, PageEditor } from '@blocksuite/presets';
// Initialize a `doc` document
const doc = createEmptyDoc().init();
// Create an editor, then attach it to the document
const editor = new PageEditor();
editor.doc = doc;
document.body.appendChild(editor);
```
BlockSuite advocates for assembling the top-level `PageEditor` component from smaller editable components, as all editable components can connect to different nodes in the block tree document. For example, instead of using existing complex rich text editors, BlockSuite implemented a `@blocksuite/inline` rich text component that only supports rendering linear text sequences. Complex rich text content can be assembled from atomic inline editor components, as illustrated:
![inline-example](../images/inline-example.png)
In the diagram, each inline editor instance connects to a `Y.Text` node in the document tree. It models the data format of rich text as a linear sequence, with expressive power equivalent to the [delta](https://quilljs.com/docs/delta/) format. Thus, all rich text content in the document tree can be split into separate inline editors for rendering, **eliminating the nesting between inline editors**. This significantly lowers the cost of implementing rich text features, as depicted:
![flat-inlines](../images/flat-inlines.png)
Since various editors can be loaded and unloaded independently of the document, this allows BlockSuite to support switching between different editors using the same block tree document. Thus, when switching content between document editors and whiteboard editors (which we call `EdgelessEditor`), all operation history recorded on the doc can be preserved, rather than reset:
![showcase-page-edgeless-editors](../images/showcase-page-edgeless-editors.jpg)
Moreover, the separation of document and editor also allows docs to be used independently of editors. This is why BlockSuite not only provides various editor UI components but also many peripheral UI components that rely on doc state yet are not part of the editor. We refer to these components as _fragments_. The lifecycle of a fragment can be completely independent of the editor, and it can be implemented with a different technology stack than that used for the editor. For example, the right sidebar in the following diagram belongs to `OutlineFragment`, which facilitates panel arrangement by the application layer (rather than an all-in-one editor):
![showcase-fragments-1](../images/showcase-fragments-1.jpg)
Furthermore, by supporting a document data layer independent of the editor, we are also able to split traditionally editor-embedded components into independent fragments, thus providing a more unopinionated and reusable `PageEditor`. Areas like the title and doc info panel, intuitively part of the editor's internals, can also become examples of fragments:
![showcase-fragments-2](../images/showcase-fragments-2.jpg)
Additionally, the document-centric approach aids in better separation between the data layer and rendering layer, enabling developers to break free from the typically DOM-based editors, to implement better performance optimization strategies. For example, the BlockSuite document supports a surface block specially designed for rendering graphic content, which could take the advantage of the HTML5 `<canvas>`. BlockSuite allows these graphic contents to interleave with other block tree contents rendered to the DOM, automatically merging graphic elements into as few canvases as possible to enhance rendering performance:
![context-interleaving](../images/context-interleaving.png)
In contrast, when there are 2000 canvas shapes in the document, tldraw, the DOM-based open-source whiteboard, would reaches its limit. At this point, it exhibits noticeable frame drops during viewport panning and zooming, degrading the content to placeholders with React suspense. However, the canvas renderer in BlockSuite could still maintain a frame rate of over 100fps at this time - and don't forget, you can still use the complete DOM-based rich text editing capability!
![showcase-edgeless-perf](../images/showcase-edgeless-perf.jpg)
A year after creating BlockSuite, we have not only implemented a collaborative editing framework under the document-centric approach but also delivered an editor product with powerful document editing and canvas whiteboard editing capabilities. Considering the time traditionally required to implement complex rich text editors from scratch, we believe this is a highly efficient pattern. Of course, as a young open-source project, BlockSuite still has many areas for continuous improvement, and we hope you could stay tuned!
## Summary
We explored the evolution of collaborative document editors, especially the transitioning from the traditional editor-centric approach to the document-centric approach. This transition implies several key points:
- **Separation of Data and Editor:** We emphasized the importance of separating the document data layer from the editor logic. Through this approach, document data becomes the core of the application, rather than being confined to a specific editor instance. This makes data sharing across editors and history management simple and efficient.
- **Adoption of CRDT:** Withe the help of CRDT, we demonstrated how to efficiently handle complex issues in collaborative editing, such as real-time synchronization and conflict resolution. CRDT provides a scalable way to build powerful multi-user editing experiences while maintaining eventual consistency.
- **Flexible UI Construction:** By separating the document data layer from the editor, we offered greater flexibility in building and optimizing user interfaces. Editors become pluggable components that can be flexibly assembled and configured according to specific application needs, creating richer and more dynamic user experiences.
We believe that the shift to document-centric not only solves some core issues faced by traditional editors but also opens up new possibilities for building future editing experiences. With this new design philosophy, developers can more flexibly build diverse collaborative tools while offering powerful, reliable, and seamless user experiences. As this pattern evolves, we look forward to seeing more innovative collaborative editing solutions emerge.
---
Support our project with a star 🌟 on GitHub: [**toeverything/blocksuite**](https://github.com/toeverything/blocksuite)
+4
View File
@@ -0,0 +1,4 @@
---
layout: BlogListLayout
title: Blog
---

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