Compare commits

...

33 Commits

Author SHA1 Message Date
Peng Xiao adaee0ef5f feat(component): sortable 2025-03-31 17:21:52 +08:00
EYHN baf1aad412 fix(core): fix flaky e2e test (#11308) 2025-03-31 09:10:54 +00:00
EYHN 231956fd39 feat(core): track for notifications (#11298) 2025-03-31 08:38:29 +00:00
EYHN 73c7815a6d feat(core): adjust notification style (#11296) 2025-03-31 08:38:28 +00:00
Fangdun Tsai 6850871bfb fix(editor): fix callout tests (#11301) 2025-03-31 08:37:20 +00:00
doouding 18cb4199fa fix: note should hide collapse button in presentation mode (#11292)
Fixes [BS-1003](https://linear.app/affine-design/issue/BS-1003/ppt-演示状态下-note-会显示折叠箭头)
2025-03-31 16:17:44 +08:00
EYHN 24c382d3aa feat(core): enable callout in canary (#11302) 2025-03-31 08:10:18 +00:00
pengx17 8bea31698e fix(electron): tray menu icon adapt to dark theme (#11288)
fix AF-2431
2025-03-31 07:23:01 +00:00
forehalo 94d5a42355 chore(core): allow quick export (#11295) 2025-03-31 06:58:17 +00:00
donteatfriedrice b2aa3084ec feat(editor): support to drag embed iframe from note to surface (#11267)
Close [BS-2807](https://linear.app/affine-design/issue/BS-2807/note-中与-surface-中-embed-iframe-block-互相拖动时的优化)
2025-03-31 06:23:11 +00:00
renovate 00c5f48a7d chore: bump up mime-types version to v3 (#11274)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [mime-types](https://redirect.github.com/jshttp/mime-types) | [`^2.1.35` -> `^3.0.0`](https://renovatebot.com/diffs/npm/mime-types/2.1.35/3.0.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/mime-types/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/mime-types/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/mime-types/2.1.35/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mime-types/2.1.35/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>jshttp/mime-types (mime-types)</summary>

### [`v3.0.1`](https://redirect.github.com/jshttp/mime-types/blob/HEAD/HISTORY.md#301--2025-03-26)

[Compare Source](https://redirect.github.com/jshttp/mime-types/compare/v3.0.0...v3.0.1)

\===================

-   deps: mime-db@1.54.0

### [`v3.0.0`](https://redirect.github.com/jshttp/mime-types/blob/HEAD/HISTORY.md#300--2024-08-31)

[Compare Source](https://redirect.github.com/jshttp/mime-types/compare/2.1.35...v3.0.0)

\===================

-   Drop support for node <18
-   deps: mime-db@1.53.0
-   resolve extension conflicts with mime-score ([#&#8203;119](https://redirect.github.com/jshttp/mime-types/issues/119))
    -   asc -> application/pgp-signature is now application/pgp-keys
    -   mpp -> application/vnd.ms-project is now application/dash-patch+xml
    -   ac -> application/vnd.nokia.n-gage.ac+xml is now application/pkix-attr-cert
    -   bdoc -> application/x-bdoc is now application/bdoc
    -   wmz -> application/x-msmetafile is now application/x-ms-wmz
    -   xsl -> application/xslt+xml is now application/xml
    -   wav -> audio/wave is now audio/wav
    -   rtf -> text/rtf is now application/rtf
    -   xml -> text/xml is now application/xml
    -   mp4 -> video/mp4 is now application/mp4
    -   mpg4 -> video/mp4 is now application/mp4

</details>

---

### Configuration

📅 **Schedule**: 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:eyJjcmVhdGVkSW5WZXIiOiIzOS4yMDcuMSIsInVwZGF0ZWRJblZlciI6IjM5LjIwNy4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->
2025-03-31 05:49:12 +00:00
pengx17 1306a9733b feat(core): some enhancements to recording (#11287)
- Added a check to verify if AI is enabled before attempting to transcribe meeting recordings
- Improved error handling for empty recordings
- Fixed the recording timeout logic to ensure it only stops the correct recording session
2025-03-31 05:37:17 +00:00
CatsJuice 7c41ddb789 chore(core): update right sidebar border color (#11222) 2025-03-31 05:11:03 +00:00
forehalo 57ec22ec2e fix(core): do not pass flavor in space id (#11285) 2025-03-31 04:47:00 +00:00
CatsJuice a91193c921 fix(core): hide readwise setting if not connected (#11107) 2025-03-31 04:08:02 +00:00
CatsJuice 7477ba6d37 feat(core): support sending success feedback via MessagePort for web clipper (#11256) 2025-03-31 03:54:44 +00:00
Mirone 9f939d823e fix(editor): slash menu e2e (#11289) 2025-03-31 11:13:34 +08:00
pengx17 61b3f82bfe fix(electron): should not record affine app itself (#11277)
fix AF-2428
2025-03-29 11:56:44 +00:00
pengx17 a94bef6738 fix(core): incorrect animated icon color & sizes (#11276) 2025-03-29 11:56:43 +00:00
doodlewind dffb89c388 feat(editor): add list block turbo renderer scaffold (#11266)
This PR allows placeholder in turbo renderer to cover list block as a basic scaffold.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/eda28656-e56e-4845-9fe6-885e70841697.png)
2025-03-29 04:49:25 +00:00
akumatus ac815142b3 refactor(core): add request time out error for ai (#11244)
### Why make this change?
Seperate front end timeout errors from server side errors.

### What changed?
- Add `RequestTimeoutError` which extends from `BaseAIError`.
- Track as `request timeout` instead of `server error`.
2025-03-29 04:27:40 +00:00
doouding ee66545ac9 fix: mind map created in page mode has incorrect style (#11265)
Fixes [BS-2878](https://linear.app/affine-design/issue/BS-2878/slashmenu插入mindmap,style没有应用上)
2025-03-29 04:13:29 +00:00
doouding fcc2ec9d66 feat: use block card to render edgeless dnd preview (#11261)
Related issue [BS-2610](https://linear.app/affine-design/issue/BS-2610/多选的拖拽:如果保护不支持预览的-block,则直接显示-icon-block-名称的方式做-fallback).

Use simpler way to render edgeless dnd preview.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/57vl0IUaypligEBYUOO0/845d43ac-27e0-45fe-8289-2e6467c59108.png)
2025-03-29 04:13:28 +00:00
yoyoyohamapi 317d3e7ea6 test(core): split and enhance copilot e2e tests (#11007)
### TL;DR

Split and enhance copilot e2e tests.

### What Changed

#### Tests Structure

The e2e tests are organized into the following categories:

1. **Basic Tests (`/basic`)**: Tests for verifying core AI capabilities including feature onboarding, authorization workflows, and basic chat interactions.
2. **Chat Interaction Tests (`/chat-with`)**: Tests for verifying the AI's interaction with various ​object types, such as attachments, images, text content, Edgeless elements, etc.
3. **AI Action Tests (`/ai-action`)**: Tests for verifying the AI's actions, such as text translation, gramma correction, etc.
4. **Insertion Tests (`/insertion`)**: Tests for verifying answer insertion functionality.

#### Tests Writing

Writing a copilot test cases is easier and clear

e.g.
```ts
test('support chat with specified doc', async ({ page, utils }) => {
  // Initialize the doc
  await focusDocTitle(page);
  await page.keyboard.insertText('Test Doc');
  await page.keyboard.press('Enter');
  await page.keyboard.insertText('EEee is a cute cat');

  await utils.chatPanel.chatWithDoc(page, 'Test Doc');

  await utils.chatPanel.makeChat(page, 'What is EEee?');
  await utils.chatPanel.waitForHistory(page, [
    {
      role: 'user',
      content: 'What is EEee?',
    },
    {
      role: 'assistant',
      status: 'success',
    },
  ]);

  const { content } = await utils.chatPanel.getLatestAssistantMessage(page);
  expect(content).toMatch(/EEee/);
});
```

#### Summary

||Cases|
|------|----|
|Before|19||
|After|151||

> Close BS-2769
2025-03-29 03:41:09 +00:00
renovate a709ed2ef1 chore: bump up linter (major) (#11272)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [eslint-import-resolver-typescript](https://redirect.github.com/import-js/eslint-import-resolver-typescript) | [`^3.7.0` -> `^4.0.0`](https://renovatebot.com/diffs/npm/eslint-import-resolver-typescript/3.8.3/4.2.5) | [![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-import-resolver-typescript/4.2.5?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/eslint-import-resolver-typescript/4.2.5?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/eslint-import-resolver-typescript/3.8.3/4.2.5?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-import-resolver-typescript/3.8.3/4.2.5?slim=true)](https://docs.renovatebot.com/merge-confidence/) |
| [eslint-plugin-unicorn](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn) | [`^57.0.0` -> `^58.0.0`](https://renovatebot.com/diffs/npm/eslint-plugin-unicorn/57.0.0/58.0.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/eslint-plugin-unicorn/58.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/eslint-plugin-unicorn/58.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/eslint-plugin-unicorn/57.0.0/58.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint-plugin-unicorn/57.0.0/58.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>import-js/eslint-import-resolver-typescript (eslint-import-resolver-typescript)</summary>

### [`v4.2.5`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#425)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.2.4...v4.2.5)

##### Patch Changes

-   [#&#8203;410](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/410) [`ec59d22`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/ec59d22fdd1ec8093dcb97da626c28ea346f41e3) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - fix: absolute path aliasing should not be skipped

### [`v4.2.4`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#424)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.2.3...v4.2.4)

##### Patch Changes

-   [#&#8203;407](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/407) [`6b183ff`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/6b183fff1b42dfb1514545b91021dfa73ab4a1c5) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - chore: migrate to rebranding `unrs-resolver` with new targets supported:

    -   `i686-pc-windows-msvc`
    -   `armv7-unknown-linux-musleabihf`
    -   `powerpc64le-unknown-linux-gnu`
    -   `s390x-unknown-linux-gnu`

### [`v4.2.3`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#423)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.2.2...v4.2.3)

##### Patch Changes

-   [#&#8203;402](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/402) [`f21bf15`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/f21bf152311cdaa85bdf390bba2824c56cb111da) Thanks [@&#8203;SunsetTechuila](https://redirect.github.com/SunsetTechuila)! - fix: don't resolve not implemented node modules in `bun`

    `is-bun-module` is marked as `dependency`, again, for correctness, see [`isBunImplementedNodeModule`](https://redirect.github.com/SunsetTechuila/is-bun-module#isbunimplementednodemodulemodulename-bunversion) for more details

    For `Bun` users: you don't need to install `is-bun-module` any more but `bun: true` option is still required if you're running without `bun --bun` nor [`run#bun`](https://bun.sh/docs/runtime/bunfig#run-bun-auto-alias-node-to-bun) enabled

### [`v4.2.2`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#422)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.2.1...v4.2.2)

##### Patch Changes

-   [#&#8203;397](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/397) [`14a7688`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/14a76885499cf99b0e5ea588aeb916a881c4efcb) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - chore: bump `rspack-resolver` for better P'n'P support

    Now `rspack-resolver` resolves `pnpapi` natively.

### [`v4.2.1`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#421)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.2.0...v4.2.1)

##### Patch Changes

-   [#&#8203;394](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/394) [`9f11f6b`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/9f11f6bb94f1f9eae6794eea3e4624b80ceac305) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - fix: don't set empty `configFile` when no `tsconfig` found

-   [#&#8203;394](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/394) [`9f11f6b`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/9f11f6bb94f1f9eae6794eea3e4624b80ceac305) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - chore: bump `rspack-resolver` to v1.2.0

### [`v4.2.0`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#420)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.1.1...v4.2.0)

##### Minor Changes

-   [#&#8203;391](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/391) [`c8121e5`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/c8121e5eb4ce25a79396ae75df16d35fc67acbc6) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - feat: make `is-bun-module` as optional peer dependency

    Technically this is a BREAKING CHANGE, but considering we just raise out v4 recently and this only affects `bun` users, `bun --bun eslint` even works without this dependency, so I'd consider this as a minor change.

    So for `bun` users, there are three options:

    1.  install `is-bun-module` dependency manually and use `bun: true` option
    2.  run `eslint` with `bun --bun eslint` w/o `bun: true` option
    3.  enable `run#bun` in [`bunfig.toml`](https://bun.sh/docs/runtime/bunfig#run-bun-auto-alias-node-to-bun) w/o `bun: true` option

### [`v4.1.1`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#411)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.1.0...v4.1.1)

##### Patch Changes

-   [#&#8203;389](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/389) [`1b97d8a`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/1b97d8a5913e15bdfcf5f64152e8a4173b18dab1) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - fix: should prefer `module.isBuiltin` when `process.versions.bun` available

### [`v4.1.0`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#410)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v4.0.0...v4.1.0)

##### Minor Changes

-   [#&#8203;387](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/387) [`ef5cd10`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/ef5cd1083207d560b35694b99ccfefa4a1234acb) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - feat: add a new `bun?: boolean` option for `bun` users - close [#&#8203;386](https://redirect.github.com/import-js/eslint-import-resolver-typescript/issues/386)

    `process.versions.bun` is unavailable even with `bun eslint` due to its own design,
    but checking `bun` modules for non-bun users is incorrect behavior and just wasting time,
    so a new option is added for such case, you can still run with `bun --bun eslint` without this option enabled

### [`v4.0.0`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#400)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.10.0...v4.0.0)

##### Major Changes

-   [#&#8203;368](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/368) [`2fd7c2e`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/2fd7c2ea63f30c9990e19a52dbd07fd8131558e9) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - feat!: rewrite, speed up by using [`rspack-resolver`](https://redirect.github.com/unrs/rspack-resolver) which supports `references` natively under the hood

    BREAKING CHANGES:

    -   drop Node 14 support, Node `^16.17.0 || >=18.6` is now required
    -   `alwaysTryTypes` is enabled by default, you can set it as `false` to opt-out
    -   array type of `project` is discouraged but still supported, single `project` with `references` are encouraged for better performance, you can enable `noWarnOnMultipleProjects` option to supress the warning message
    -   root `tsconfig.json` or `jsconfig.json` will be used automatically if no `project` provided

### [`v3.10.0`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/releases/tag/v3.10.0)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.9.1...v3.10.0)

##### Minor Changes

-   [#&#8203;413](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/413) [`89c2795`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/89c2795cde0ddf0c38c941ee4cf5d4ce1f3ac842) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - chore: housekeeping, bump all (dev) deps

    Migrate `rspack-resolver` to rebranding [`unrs-resolver`](https://redirect.github.com/unrs/unrs-resolver) for more targets support and other bug fixes

**Full Changelog**: https://github.com/import-js/eslint-import-resolver-typescript/compare/v3.9.1...v3.10.0

### [`v3.9.1`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#391)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.9.0...v3.9.1)

##### Patch Changes

-   [#&#8203;382](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/382) [`4a9176e`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/4a9176e6e2b6013dc24b5634aea42feebd324e41) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - fix: use [`rspack-resolver`](https://redirect.github.com/unrs/rspack-resolver) fork for pnp support

### [`v3.9.0`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#390)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.7...v3.9.0)

##### Minor Changes

-   [#&#8203;379](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/379) [`6814443`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/681444336fc66104b9b490838a67ea7bf8ac8b61) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - feat: migrate `enhanced-resolve` to `oxc-resolver`

### [`v3.8.7`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#387)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.6...v3.8.7)

##### Patch Changes

-   [#&#8203;377](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/377) [`a14fdd9`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/a14fdd95011c4c09b74f71854410f684c0f04bc5) Thanks [@&#8203;carlocorradini](https://redirect.github.com/carlocorradini)! - fix: include mapper with no files and force non-dynamic projects to use absolute paths

### [`v3.8.6`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#386)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.5...v3.8.6)

##### Patch Changes

-   [#&#8203;374](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/374) [`c9d5ab0`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/c9d5ab0fa963bd891b6f2ae312ae3ec10a397b7c) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - fix: add support for importing with .js extension as tsx importee

### [`v3.8.5`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#385)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.4...v3.8.5)

##### Patch Changes

-   [#&#8203;372](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/372) [`366eeaf`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/366eeaf8ba87adf7c2e165b0a73406292c002ad9) Thanks [@&#8203;carlocorradini](https://redirect.github.com/carlocorradini)! - fix: if file has no corresponding mapper function, apply all of them, starting with the nearest one.

### [`v3.8.4`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/blob/HEAD/CHANGELOG.md#384)

[Compare Source](https://redirect.github.com/import-js/eslint-import-resolver-typescript/compare/v3.8.3...v3.8.4)

##### Patch Changes

-   [#&#8203;370](https://redirect.github.com/import-js/eslint-import-resolver-typescript/pull/370) [`c940785`](https://redirect.github.com/import-js/eslint-import-resolver-typescript/commit/c94078504cfb6fd17b775c53d268962a56a2d118) Thanks [@&#8203;JounQin](https://redirect.github.com/JounQin)! - fix: support multiple matching ts paths

</details>

<details>
<summary>sindresorhus/eslint-plugin-unicorn (eslint-plugin-unicorn)</summary>

### [`v58.0.0`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/releases/tag/v58.0.0)

[Compare Source](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/compare/v57.0.0...v58.0.0)

##### Potentially breaking

-   Update `engines.node` in package.json to match real compatibility ([#&#8203;2581](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2581))  [`e48a620`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/e48a620)

##### Improvements

-   `escape-case`: Add [case option](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/escape-case.md#options) ([#&#8203;2559](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2559))  [`0f6048c`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/0f6048c)
-   `number-literal-case`: Add [`hexadecimalValue` option](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/number-literal-case.md#hexadecimalvalue) ([#&#8203;2559](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2559))  [`0f6048c`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/0f6048c)
-   `prevent-abbreviations`: Preserve `iOS` ([#&#8203;2560](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2560))  [`e8798da`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/e8798da)

##### Fixes

-   `no-unnecessary-polyfills`: Fix browserslist field name ([#&#8203;2603](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2603))  [`1a4c76f`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/1a4c76f)
-   `no-unnecessary-polyfills`: Fix crash on checking `es6-error` module ([#&#8203;2582](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2582))  [`66de41a`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/66de41a)
-   `no-accessor-recursion`: Fix exception when used in CommonJS ([#&#8203;2574](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/issues/2574))  [`ca1e432`](https://redirect.github.com/sindresorhus/eslint-plugin-unicorn/commit/ca1e432)

***

</details>

---

### Configuration

📅 **Schedule**: 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:eyJjcmVhdGVkSW5WZXIiOiIzOS4yMDcuMSIsInVwZGF0ZWRJblZlciI6IjM5LjIwNy4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->
2025-03-28 14:50:49 +00:00
forehalo 1b93d3d8d2 chore(server): bump nestjs and express (#11259) 2025-03-28 14:00:19 +00:00
renovate efab5d4270 chore: bump up all non-major dependencies (#11215)
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence | Type | Update |
|---|---|---|---|---|---|---|---|
| [@aws-sdk/client-s3](https://redirect.github.com/aws/aws-sdk-js-v3/tree/main/clients/client-s3) ([source](https://redirect.github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3)) | [`3.775.0` -> `3.777.0`](https://renovatebot.com/diffs/npm/@aws-sdk%2fclient-s3/3.775.0/3.777.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@aws-sdk%2fclient-s3/3.777.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@aws-sdk%2fclient-s3/3.777.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@aws-sdk%2fclient-s3/3.775.0/3.777.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@aws-sdk%2fclient-s3/3.775.0/3.777.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | minor |
| [@graphql-codegen/typescript](https://redirect.github.com/dotansimha/graphql-code-generator) ([source](https://redirect.github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/typescript)) | [`4.1.5` -> `4.1.6`](https://renovatebot.com/diffs/npm/@graphql-codegen%2ftypescript/4.1.5/4.1.6) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@graphql-codegen%2ftypescript/4.1.6?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@graphql-codegen%2ftypescript/4.1.6?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@graphql-codegen%2ftypescript/4.1.5/4.1.6?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@graphql-codegen%2ftypescript/4.1.5/4.1.6?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@graphql-codegen/typescript-operations](https://redirect.github.com/dotansimha/graphql-code-generator) ([source](https://redirect.github.com/dotansimha/graphql-code-generator/tree/HEAD/packages/plugins/typescript/operations)) | [`4.5.1` -> `4.6.0`](https://renovatebot.com/diffs/npm/@graphql-codegen%2ftypescript-operations/4.5.1/4.6.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@graphql-codegen%2ftypescript-operations/4.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@graphql-codegen%2ftypescript-operations/4.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@graphql-codegen%2ftypescript-operations/4.5.1/4.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@graphql-codegen%2ftypescript-operations/4.5.1/4.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | minor |
| [@sentry/esbuild-plugin](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/esbuild-plugin) ([source](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins)) | [`3.2.2` -> `3.2.4`](https://renovatebot.com/diffs/npm/@sentry%2fesbuild-plugin/3.2.2/3.2.4) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@sentry%2fesbuild-plugin/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@sentry%2fesbuild-plugin/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@sentry%2fesbuild-plugin/3.2.2/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@sentry%2fesbuild-plugin/3.2.2/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@sentry/react](https://redirect.github.com/getsentry/sentry-javascript/tree/master/packages/react) ([source](https://redirect.github.com/getsentry/sentry-javascript)) | [`9.9.0` -> `9.10.0`](https://renovatebot.com/diffs/npm/@sentry%2freact/9.9.0/9.10.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@sentry%2freact/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@sentry%2freact/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@sentry%2freact/9.9.0/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@sentry%2freact/9.9.0/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | minor |
| [@sentry/react](https://redirect.github.com/getsentry/sentry-javascript/tree/master/packages/react) ([source](https://redirect.github.com/getsentry/sentry-javascript)) | [`9.9.0` -> `9.10.0`](https://renovatebot.com/diffs/npm/@sentry%2freact/9.9.0/9.10.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@sentry%2freact/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@sentry%2freact/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@sentry%2freact/9.9.0/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@sentry%2freact/9.9.0/9.10.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | minor |
| [@sentry/webpack-plugin](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/tree/main/packages/webpack-plugin) ([source](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins)) | [`3.2.2` -> `3.2.4`](https://renovatebot.com/diffs/npm/@sentry%2fwebpack-plugin/3.2.2/3.2.4) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@sentry%2fwebpack-plugin/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@sentry%2fwebpack-plugin/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@sentry%2fwebpack-plugin/3.2.2/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@sentry%2fwebpack-plugin/3.2.2/3.2.4?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [@slack/web-api](https://tools.slack.dev/node-slack-sdk/web-api) ([source](https://redirect.github.com/slackapi/node-slack-sdk)) | [`7.9.0` -> `7.9.1`](https://renovatebot.com/diffs/npm/@slack%2fweb-api/7.9.0/7.9.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@slack%2fweb-api/7.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@slack%2fweb-api/7.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@slack%2fweb-api/7.9.0/7.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@slack%2fweb-api/7.9.0/7.9.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [@storybook/addon-essentials](https://redirect.github.com/storybookjs/storybook/tree/next/code/addons/essentials) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/addons/essentials)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/@storybook%2faddon-essentials/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2faddon-essentials/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@storybook%2faddon-essentials/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@storybook%2faddon-essentials/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2faddon-essentials/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@storybook/addon-interactions](https://redirect.github.com/storybookjs/storybook/tree/next/code/addons/interactions) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/addons/interactions)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/@storybook%2faddon-interactions/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2faddon-interactions/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@storybook%2faddon-interactions/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@storybook%2faddon-interactions/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2faddon-interactions/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@storybook/addon-links](https://redirect.github.com/storybookjs/storybook/tree/next/code/addons/links) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/addons/links)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/@storybook%2faddon-links/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2faddon-links/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@storybook%2faddon-links/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@storybook%2faddon-links/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2faddon-links/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@storybook/addon-mdx-gfm](https://redirect.github.com/storybookjs/storybook/tree/next/code/addons/gfm) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/addons/gfm)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/@storybook%2faddon-mdx-gfm/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2faddon-mdx-gfm/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@storybook%2faddon-mdx-gfm/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@storybook%2faddon-mdx-gfm/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2faddon-mdx-gfm/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@storybook/react](https://redirect.github.com/storybookjs/storybook/tree/next/code/renderers/react) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/renderers/react)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/@storybook%2freact/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2freact/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@storybook%2freact/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@storybook%2freact/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2freact/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@storybook/react-vite](https://redirect.github.com/storybookjs/storybook/tree/next/code/frameworks/react-vite) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/frameworks/react-vite)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/@storybook%2freact-vite/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@storybook%2freact-vite/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@storybook%2freact-vite/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@storybook%2freact-vite/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@storybook%2freact-vite/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@types/mixpanel-browser](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mixpanel-browser) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mixpanel-browser)) | [`2.51.0` -> `2.54.0`](https://renovatebot.com/diffs/npm/@types%2fmixpanel-browser/2.51.0/2.54.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fmixpanel-browser/2.54.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@types%2fmixpanel-browser/2.54.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@types%2fmixpanel-browser/2.51.0/2.54.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fmixpanel-browser/2.51.0/2.54.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | minor |
| [@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | [`22.13.13` -> `22.13.14`](https://renovatebot.com/diffs/npm/@types%2fnode/22.13.13/22.13.14) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@types%2fnode/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@types%2fnode/22.13.13/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/22.13.13/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [@types/node](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)) | [`22.13.13` -> `22.13.14`](https://renovatebot.com/diffs/npm/@types%2fnode/22.13.13/22.13.14) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnode/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@types%2fnode/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@types%2fnode/22.13.13/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnode/22.13.13/22.13.14?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [@types/semver](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/semver) ([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver)) | [`7.5.8` -> `7.7.0`](https://renovatebot.com/diffs/npm/@types%2fsemver/7.5.8/7.7.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fsemver/7.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@types%2fsemver/7.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@types%2fsemver/7.5.8/7.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fsemver/7.5.8/7.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | minor |
| [ai](https://sdk.vercel.ai/docs) ([source](https://redirect.github.com/vercel/ai)) | [`4.2.5` -> `4.2.8`](https://renovatebot.com/diffs/npm/ai/4.2.5/4.2.8) | [![age](https://developer.mend.io/api/mc/badges/age/npm/ai/4.2.8?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/ai/4.2.8?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/ai/4.2.5/4.2.8?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ai/4.2.5/4.2.8?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [apollographql/apollo-ios](https://redirect.github.com/apollographql/apollo-ios) | `from: "1.18.0"` -> `from: "1.19.0"` | [![age](https://developer.mend.io/api/mc/badges/age/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios.git/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios.git/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios.git/1.18.0/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios.git/1.18.0/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |  | minor |
| [apollographql/apollo-ios](https://redirect.github.com/apollographql/apollo-ios) | `1.18.0` -> `1.19.0` | [![age](https://developer.mend.io/api/mc/badges/age/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios/1.18.0/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/git-tags/https:%2f%2fgithub.com%2fapollographql%2fapollo-ios/1.18.0/1.19.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |  | minor |
| [bullmq](https://bullmq.io/) ([source](https://redirect.github.com/taskforcesh/bullmq)) | [`5.44.4` -> `5.45.0`](https://renovatebot.com/diffs/npm/bullmq/5.44.4/5.45.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/bullmq/5.45.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/bullmq/5.45.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/bullmq/5.44.4/5.45.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/bullmq/5.44.4/5.45.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | minor |
| [electron](https://redirect.github.com/electron/electron) | [`35.1.0` -> `35.1.2`](https://renovatebot.com/diffs/npm/electron/35.1.0/35.1.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/electron/35.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/electron/35.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/electron/35.1.0/35.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/electron/35.1.0/35.1.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [electron-log](https://redirect.github.com/megahertz/electron-log) | [`5.3.2` -> `5.3.3`](https://renovatebot.com/diffs/npm/electron-log/5.3.2/5.3.3) | [![age](https://developer.mend.io/api/mc/badges/age/npm/electron-log/5.3.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/electron-log/5.3.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/electron-log/5.3.2/5.3.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/electron-log/5.3.2/5.3.3?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [eventsource-parser](https://redirect.github.com/rexxars/eventsource-parser) | [`3.0.0` -> `3.0.1`](https://renovatebot.com/diffs/npm/eventsource-parser/3.0.0/3.0.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/eventsource-parser/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/eventsource-parser/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/eventsource-parser/3.0.0/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eventsource-parser/3.0.0/3.0.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [mixpanel-browser](https://redirect.github.com/mixpanel/mixpanel-js) | [`2.61.2` -> `2.62.0`](https://renovatebot.com/diffs/npm/mixpanel-browser/2.61.2/2.62.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/mixpanel-browser/2.62.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/mixpanel-browser/2.62.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/mixpanel-browser/2.61.2/2.62.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mixpanel-browser/2.61.2/2.62.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | minor |
| [nestjs-cls](https://papooch.github.io/nestjs-cls/) ([source](https://redirect.github.com/Papooch/nestjs-cls)) | [`5.4.1` -> `5.4.2`](https://renovatebot.com/diffs/npm/nestjs-cls/5.4.1/5.4.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/nestjs-cls/5.4.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/nestjs-cls/5.4.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/nestjs-cls/5.4.1/5.4.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nestjs-cls/5.4.1/5.4.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [once_cell](https://redirect.github.com/matklad/once_cell) | `1.21.1` -> `1.21.2` | [![age](https://developer.mend.io/api/mc/badges/age/crate/once_cell/1.21.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/crate/once_cell/1.21.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/crate/once_cell/1.21.1/1.21.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/crate/once_cell/1.21.1/1.21.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | workspace.dependencies | patch |
| [openai](https://redirect.github.com/openai/openai-node) | [`4.89.0` -> `4.90.0`](https://renovatebot.com/diffs/npm/openai/4.89.0/4.90.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/openai/4.90.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/openai/4.90.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/openai/4.89.0/4.90.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/openai/4.89.0/4.90.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | minor |
| [sonner](https://sonner.emilkowal.ski/) ([source](https://redirect.github.com/emilkowalski/sonner)) | [`2.0.1` -> `2.0.2`](https://renovatebot.com/diffs/npm/sonner/2.0.1/2.0.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/sonner/2.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/sonner/2.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/sonner/2.0.1/2.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/sonner/2.0.1/2.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [storybook](https://redirect.github.com/storybookjs/storybook/tree/next/code/lib/cli) ([source](https://redirect.github.com/storybookjs/storybook/tree/HEAD/code/lib/cli)) | [`8.6.9` -> `8.6.11`](https://renovatebot.com/diffs/npm/storybook/8.6.9/8.6.11) | [![age](https://developer.mend.io/api/mc/badges/age/npm/storybook/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/storybook/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/storybook/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/storybook/8.6.9/8.6.11?slim=true)](https://docs.renovatebot.com/merge-confidence/) | devDependencies | patch |
| [swiftlang/swift-cmark](https://redirect.github.com/swiftlang/swift-cmark) | `from: "0.4.0"` -> `from: "0.5.0"` | [![age](https://developer.mend.io/api/mc/badges/age/git-tags/https:%2f%2fgithub.com%2fswiftlang%2fswift-cmark/0.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/git-tags/https:%2f%2fgithub.com%2fswiftlang%2fswift-cmark/0.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/git-tags/https:%2f%2fgithub.com%2fswiftlang%2fswift-cmark/0.4.0/0.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/git-tags/https:%2f%2fgithub.com%2fswiftlang%2fswift-cmark/0.4.0/0.5.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |  | minor |
| [undici](https://undici.nodejs.org) ([source](https://redirect.github.com/nodejs/undici)) | [`7.5.0` -> `7.6.0`](https://renovatebot.com/diffs/npm/undici/7.5.0/7.6.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/undici/7.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/undici/7.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/undici/7.5.0/7.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/undici/7.5.0/7.6.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | minor |
| [webm-muxer](https://redirect.github.com/Vanilagy/webm-muxer) | [`5.1.0` -> `5.1.1`](https://renovatebot.com/diffs/npm/webm-muxer/5.1.0/5.1.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/webm-muxer/5.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/webm-muxer/5.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/webm-muxer/5.1.0/5.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/webm-muxer/5.1.0/5.1.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [webpack-dev-server](https://redirect.github.com/webpack/webpack-dev-server) | [`5.2.0` -> `5.2.1`](https://renovatebot.com/diffs/npm/webpack-dev-server/5.2.0/5.2.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/webpack-dev-server/5.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/webpack-dev-server/5.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/webpack-dev-server/5.2.0/5.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/webpack-dev-server/5.2.0/5.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | dependencies | patch |
| [yarn](https://redirect.github.com/yarnpkg/berry) ([source](https://redirect.github.com/yarnpkg/berry/tree/HEAD/packages/yarnpkg-cli)) | [`4.7.0` -> `4.8.0`](https://renovatebot.com/diffs/npm/yarn/4.7.0/4.8.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@yarnpkg%2fcli/4.8.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/@yarnpkg%2fcli/4.8.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/@yarnpkg%2fcli/4.7.0/4.8.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@yarnpkg%2fcli/4.7.0/4.8.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | packageManager | minor |

---

### Release Notes

<details>
<summary>aws/aws-sdk-js-v3 (@&#8203;aws-sdk/client-s3)</summary>

### [`v3.777.0`](https://redirect.github.com/aws/aws-sdk-js-v3/blob/HEAD/clients/client-s3/CHANGELOG.md#37770-2025-03-27)

[Compare Source](https://redirect.github.com/aws/aws-sdk-js-v3/compare/v3.775.0...v3.777.0)

**Note:** Version bump only for package [@&#8203;aws-sdk/client-s3](https://redirect.github.com/aws-sdk/client-s3)

</details>

<details>
<summary>dotansimha/graphql-code-generator (@&#8203;graphql-codegen/typescript)</summary>

### [`v4.1.6`](https://redirect.github.com/dotansimha/graphql-code-generator/blob/HEAD/packages/plugins/typescript/typescript/CHANGELOG.md#416)

[Compare Source](https://redirect.github.com/dotansimha/graphql-code-generator/compare/@graphql-codegen/typescript@4.1.5...@graphql-codegen/typescript@4.1.6)

##### Patch Changes

-   Updated dependencies \[[`f6909d1`](https://redirect.github.com/dotansimha/graphql-code-generator/commit/f6909d1797c15b79a0afb7ec089471763a485bfc)]:
    -   [@&#8203;graphql-codegen/visitor-plugin-common](https://redirect.github.com/graphql-codegen/visitor-plugin-common)[@&#8203;5](https://redirect.github.com/5).8.0

</details>

<details>
<summary>dotansimha/graphql-code-generator (@&#8203;graphql-codegen/typescript-operations)</summary>

### [`v4.6.0`](https://redirect.github.com/dotansimha/graphql-code-generator/blob/HEAD/packages/plugins/typescript/operations/CHANGELOG.md#460)

[Compare Source](https://redirect.github.com/dotansimha/graphql-code-generator/compare/@graphql-codegen/typescript-operations@4.5.1...@graphql-codegen/typescript-operations@4.6.0)

##### Minor Changes

-   [#&#8203;10323](https://redirect.github.com/dotansimha/graphql-code-generator/pull/10323) [`f3cf4df`](https://redirect.github.com/dotansimha/graphql-code-generator/commit/f3cf4df358a896c5df0a7d8909c2fbf192e10c01) Thanks [@&#8203;eddeee888](https://redirect.github.com/eddeee888)! - Add support for `nullability.errorHandlingClient`. This allows clients to get stronger types with [semantic nullability](https://redirect.github.com/graphql/graphql-wg/blob/main/rfcs/SemanticNullability.md)-enabled schemas.

##### Patch Changes

-   Updated dependencies \[[`f6909d1`](https://redirect.github.com/dotansimha/graphql-code-generator/commit/f6909d1797c15b79a0afb7ec089471763a485bfc)]:
    -   [@&#8203;graphql-codegen/visitor-plugin-common](https://redirect.github.com/graphql-codegen/visitor-plugin-common)[@&#8203;5](https://redirect.github.com/5).8.0
    -   [@&#8203;graphql-codegen/typescript](https://redirect.github.com/graphql-codegen/typescript)[@&#8203;4](https://redirect.github.com/4).1.6

</details>

<details>
<summary>getsentry/sentry-javascript-bundler-plugins (@&#8203;sentry/esbuild-plugin)</summary>

### [`v3.2.4`](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/blob/HEAD/CHANGELOG.md#324)

[Compare Source](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.2.3...3.2.4)

-   Revert "feat(core): Use path instead of debug IDs as artifact names for debug ID upload ([#&#8203;700](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/700))" ([#&#8203;709](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/709))
-   ref: Remove deprecated use of `useArtifacBundles` ([#&#8203;707](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/707))

### [`v3.2.3`](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/blob/HEAD/CHANGELOG.md#323)

[Compare Source](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.2.2...3.2.3)

-   feat(core): Use path instead of debug IDs as artifact names for debug ID upload ([#&#8203;700](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/700))
-   feat(webpack): Primarily use `contentHash` for debug ID hash ([#&#8203;702](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/702))
-   feat: Detect Vercel commits and env ([#&#8203;694](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/694))
-   feat: Default to automatically setting commits on release ([#&#8203;692](https://redirect.github.com/getsentry/sentry-javascript-bundler-plugins/issues/692))

</details>

<details>
<summary>getsentry/sentry-javascript (@&#8203;sentry/react)</summary>

### [`v9.10.0`](https://redirect.github.com/getsentry/sentry-javascript/releases/tag/9.10.0)

[Compare Source](https://redirect.github.com/getsentry/sentry-javascript/compare/9.9.0...9.10.0)

##### Important Changes

-   **feat: Add support for logs**

    -   feat(node): Add logging public APIs to Node SDKs ([#&#8203;15764](https://redirect.github.com/getsentry/sentry-javascript/pull/15764))
    -   feat(core): Add support for `beforeSendLog` ([#&#8203;15814](https://redirect.github.com/getsentry/sentry-javascript/pull/15814))
    -   feat(core): Add support for parameterizing logs ([#&#8203;15812](https://redirect.github.com/getsentry/sentry-javascript/pull/15812))
    -   fix: Remove critical log severity level ([#&#8203;15824](https://redirect.github.com/getsentry/sentry-javascript/pull/15824))

    All JavaScript SDKs other than `@sentry/cloudflare` and `@sentry/deno` now support sending logs via dedicated methods as part of Sentry's [upcoming logging product](https://redirect.github.com/getsentry/sentry/discussions/86804).

    Logging is gated by an experimental option, `_experiments.enableLogs`.

    ```js
    Sentry.init({
      dsn: 'PUBLIC_DSN',
      // `enableLogs` must be set to true to use the logging features
      _experiments: { enableLogs: true },
    });

    const { trace, debug, info, warn, error, fatal, fmt } = Sentry.logger;

    trace('Starting database connection', { database: 'users' });
    debug('Cache miss for user', { userId: 123 });
    error('Failed to process payment', { orderId: 'order_123', amount: 99.99 });
    fatal('Database connection pool exhausted', { database: 'users', activeConnections: 100 });

    // Structured logging via the `fmt` helper function. When you use `fmt`, the string template and parameters are sent separately so they can be queried independently in Sentry.

    info(fmt(`Updated profile for user ${userId}`));
    warn(fmt(`Rate limit approaching for endpoint ${endpoint}. Requests: ${requests}, Limit: ${limit}`));
    ```

    With server-side SDKs like `@sentry/node`, `@sentry/bun` or server-side of `@sentry/nextjs` or `@sentry/sveltekit`, you can do structured logging without needing the `fmt` helper function.

    ```js
    const { info, warn } = Sentry.logger;

    info('User %s logged in successfully', [123]);
    warn('Failed to load user %s data', [123], { errorCode: 404 });
    ```

    To filter logs, or update them before they are sent to Sentry, you can use the `_experiments.beforeSendLog` option.

-   **feat(browser): Add `diagnoseSdkConnectivity()` function to programmatically detect possible connectivity issues ([#&#8203;15821](https://redirect.github.com/getsentry/sentry-javascript/pull/15821))**

    The `diagnoseSdkConnectivity()` function can be used to programmatically detect possible connectivity issues with the Sentry SDK.

    ```js
    const result = await Sentry.diagnoseSdkConnectivity();
    ```

    The result will be an object with the following properties:

    -   `"no-client-active"`: There was no active client when the function was called. This possibly means that the SDK was not initialized yet.
    -   `"sentry-unreachable"`: The Sentry SaaS servers were not reachable. This likely means that there is an ad blocker active on the page or that there are other connection issues.
    -   `undefined`: The SDK is working as expected.

-   **SDK Tracing Performance Improvements for Node SDKs**

    -   feat: Stop using `dropUndefinedKeys` ([#&#8203;15796](https://redirect.github.com/getsentry/sentry-javascript/pull/15796))
    -   feat(node): Only add span listeners for instrumentation when used ([#&#8203;15802](https://redirect.github.com/getsentry/sentry-javascript/pull/15802))
    -   ref: Avoid `dropUndefinedKeys` for `spanToJSON` calls ([#&#8203;15792](https://redirect.github.com/getsentry/sentry-javascript/pull/15792))
    -   ref: Avoid using `SentryError` for PromiseBuffer control flow ([#&#8203;15822](https://redirect.github.com/getsentry/sentry-javascript/pull/15822))
    -   ref: Stop using `dropUndefinedKeys` in SpanExporter ([#&#8203;15794](https://redirect.github.com/getsentry/sentry-javascript/pull/15794))
    -   ref(core): Avoid using `SentryError` for event processing control flow ([#&#8203;15823](https://redirect.github.com/getsentry/sentry-javascript/pull/15823))
    -   ref(node): Avoid `dropUndefinedKeys` in Node SDK init ([#&#8203;15797](https://redirect.github.com/getsentry/sentry-javascript/pull/15797))
    -   ref(opentelemetry): Avoid sampling work for non-root spans ([#&#8203;15820](https://redirect.github.com/getsentry/sentry-javascript/pull/15820))

    We've been hard at work making performance improvements to the Sentry Node SDKs (`@sentry/node`, `@sentry/aws-serverless`, `@sentry/nestjs`, etc.). We've seen that upgrading from `9.7.0` to `9.10.0` leads to 30-40% improvement in request latency for HTTP web-server applications that use tracing with high sample rates. Non web-server applications and non-tracing applications will see smaller improvements.

##### Other Changes

-   chore(deps): Bump `rrweb` to `2.35.0` ([#&#8203;15825](https://redirect.github.com/getsentry/sentry-javascript/pull/15825))
-   deps: Bump bundler plugins to `3.2.3` ([#&#8203;15829](https://redirect.github.com/getsentry/sentry-javascript/pull/15829))
-   feat: Always truncate stored breadcrumb messages to 2kb ([#&#8203;15819](https://redirect.github.com/getsentry/sentry-javascript/pull/15819))
-   feat(nextjs): Disable server webpack-handling for static builds ([#&#8203;15751](https://redirect.github.com/getsentry/sentry-javascript/pull/15751))
-   fix(nuxt): Don't override Nuxt options if undefined ([#&#8203;15795](https://redirect.github.com/getsentry/sentry-javascript/pull/15795))

#### Bundle size 📦

| Path                                                             | Size              |
| ---------------------------------------------------------------- | ----------------- |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser)                                                  | 23.08 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) - with treeshaking flags                         | 22.88 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. Tracing)                                  | 36.49 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. Tracing, Replay)                          | 73.65 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. Tracing, Replay) - with treeshaking flags | 67 KB     |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. Tracing, Replay with Canvas)              | 78.3 KB   |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. Tracing, Replay, Feedback)                | 90.87 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. Feedback)                                 | 40.21 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. sendFeedback)                             | 27.71 KB  |
| [@&#8203;sentry/browser](https://redirect.github.com/sentry/browser) (incl. FeedbackAsync)                            | 32.5 KB   |
| [@&#8203;sentry/react](https://redirect.github.com/sentry/react)                                                    | 24.86 KB  |
| [@&#8203;sentry/react](https://redirect.github.com/sentry/react) (incl. Tracing)                                    | 38.39 KB  |
| [@&#8203;sentry/vue](https://redirect.github.com/sentry/vue)                                                      | 27.3 KB   |
| [@&#8203;sentry/vue](https://redirect.github.com/sentry/vue) (incl. Tracing)                                      | 38.18 KB  |
| [@&#8203;sentry/svelte](https://redirect.github.com/sentry/svelte)                                                   | 23.12 KB  |
| CDN Bundle                                                       | 24.33 KB  |
| CDN Bundle (incl. Tracing)                                       | 36.51 KB  |
| CDN Bundle (incl. Tracing, Replay)                               | 71.53 KB  |
| CDN Bundle (incl. Tracing, Replay, Feedback)                     | 76.71 KB  |
| CDN Bundle - uncompressed                                        | 70.93 KB  |
| CDN Bundle (incl. Tracing) - uncompressed                        | 108.11 KB |
| CDN Bundle (incl. Tracing, Replay) - uncompressed                | 219.4 KB  |
| CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed      | 231.97 KB |
| [@&#8203;sentry/nextjs](https://redirect.github.com/sentry/nextjs) (client)                                          | 39.68 KB  |
| [@&#8203;sentry/sveltekit](https://redirect.github.com/sentry/sveltekit) (client)                                       | 36.92 KB  |
| [@&#8203;sentry/node](https://redirect.github.com/sentry/node)                                                     | 142.91 KB |
| [@&#8203;sentry/node](https://redirect.github.com/sentry/node) - without tracing                                   | 96.12 KB  |
| [@&#8203;sentry/aws-serverless](https://redirect.github.com/sentry/aws-serverless)                                           | 120.46 KB |

</details>

<details>
<summary>slackapi/node-slack-sdk (@&#8203;slack/web-api)</summary>

### [`v7.9.1`](https://redirect.github.com/slackapi/node-slack-sdk/compare/@slack/web-api@7.9.0...@slack/web-api@7.9.1)

[Compare Source](https://redirect.github.com/slackapi/node-slack-sdk/compare/@slack/web-api@7.9.0...@slack/web-api@7.9.1)

</details>

<details>
<summary>storybookjs/storybook (@&#8203;storybook/addon-essentials)</summary>

### [`v8.6.11`](https://redirect.github.com/storybookjs/storybook/compare/v8.6.10...2afd30d75089f27a8029a1ac320d7698873b163f)

[Compare Source](https://redirect.github.com/storybookjs/storybook/compare/v8.6.10...v8.6.11)

### [`v8.6.10`](https://redirect.github.com/storybookjs/storybook/blob/HEAD/CHANGELOG.md#8610)

[Compare Source](https://redirect.github.com/storybookjs/storybook/compare/v8.6.9...v8.6.10)

-   Addon-docs: Fix non-string handling in Stories block - [#&#8203;30913](https://redirect.github.com/storybookjs/storybook/pull/30913), thanks [@&#8203;JamesIves](https://redirect.github.com/JamesIves)!
-   Nextjs: Fix styled-jsx optimize vite warnings - [#&#8203;30932](https://redirect.github.com/storybookjs/storybook/pull/30932), thanks [@&#8203;kasperpeulen](https://redirect.github.com/kasperpeulen)!
-   React: Fix actImplementation is not a function - [#&#8203;30929](https://redirect.github.com/storybookjs/storybook/pull/30929), thanks [@&#8203;kasperpeulen](https://redirect.github.com/kasperpeulen)!

</details>

<details>
<summary>storybookjs/storybook (@&#8203;storybook/addon-interactions)</summary>

### [`v8.6.11`](https://redirect.github.com/storybookjs/storybook/blob/HEAD/CHANGELOG.md#8611)

[Compare Source](https://redirect.github.com/storybookjs/storybook/compare/v8.6.10...v8.6.11)

-   Angular: Fix zone.js support for Angular libraries - [#&#8203;30941](https://redirect.github.com/storybookjs/storybook/pull/30941), thanks [@&#8203;valentinpalkovic](https://redirect.github.com/valentinpalkovic)!

### [`v8.6.10`](https://redirect.github.com/storybookjs/storybook/blob/HEAD/CHANGELOG.md#8610)

[Compare Source](https://redirect.github.com/storybookjs/storybook/compare/v8.6.9...v8.6.10)

-   Addon-docs: Fix non-string handling in Stories block - [#&#8203;30913](https://redirect.github.com/storybookjs/storybook/pull/30913), thanks [@&#8203;JamesIves](https://redirect.github.com/JamesIves)!
-   Nextjs: Fix styled-jsx optimize vite warnings - [#&#8203;30932](https://redirect.github.com/storybookjs/storybook/pull/30932), thanks [@&#8203;kasperpeulen](https://redirect.github.com/kasperpeulen)!
-   React: Fix actImplementation is not a function - [#&#8203;30929](https://redirect.github.com/storybookjs/storybook/pull/30929), thanks [@&#8203;kasperpeulen](https://redirect.github.com/kasperpeulen)!

</details>

<details>
<summary>vercel/ai (ai)</summary>

### [`v4.2.8`](https://redirect.github.com/vercel/ai/releases/tag/ai%404.2.8)

[Compare Source](https://redirect.github.com/vercel/ai/compare/ai@4.2.7...ai@4.2.8)

##### Patch Changes

-   [`65243ce`](https://redirect.github.com/vercel/ai/commit/65243ce): fix (ui): introduce step start parts
-   Updated dependencies \[[`65243ce`](https://redirect.github.com/vercel/ai/commit/65243ce)]
    -   [@&#8203;ai-sdk/ui-utils](https://redirect.github.com/ai-sdk/ui-utils)[@&#8203;1](https://redirect.github.com/1).2.2
    -   [@&#8203;ai-sdk/react](https://redirect.github.com/ai-sdk/react)[@&#8203;1](https://redirect.github.com/1).2.3

### [`v4.2.7`](https://redirect.github.com/vercel/ai/releases/tag/ai%404.2.7)

[Compare Source](https://redirect.github.com/vercel/ai/compare/ai@4.2.6...ai@4.2.7)

##### Patch Changes

-   [`e14c066`](https://redirect.github.com/vercel/ai/commit/e14c066): fix (ai/core): convert user ui messages with only parts (no content) to core messages

### [`v4.2.6`](https://redirect.github.com/vercel/ai/releases/tag/ai%404.2.6)

[Compare Source](https://redirect.github.com/vercel/ai/compare/ai@4.2.5...ai@4.2.6)

##### Patch Changes

-   [`625591b`](https://redirect.github.com/vercel/ai/commit/625591b): feat (ai/core): auto-complete for provider registry
-   [`6a1506f`](https://redirect.github.com/vercel/ai/commit/6a1506f): feat (ai/core): custom separator support for provider registry
-   [`ea3d998`](https://redirect.github.com/vercel/ai/commit/ea3d998): chore (ai/core): move provider registry to stable

</details>

<details>
<summary>apollographql/apollo-ios (apollographql/apollo-ios)</summary>

### [`v1.19.0`](https://redirect.github.com/apollographql/apollo-ios/blob/HEAD/CHANGELOG.md#v1190)

[Compare Source](https://redirect.github.com/apollographql/apollo-ios/compare/1.18.0...1.19.0)

##### New

-   **New function to mutate the properties of a local cache mutation fragment. ([#&#8203;3433](https://redirect.github.com/apollographql/apollo-ios/issues/3443)):** Removal of the setter for type conditions made it difficult to work with the properties on those types. A new `mutateIfFulfilled` function was added to facilitate that workflow while still preventing a fragment from being added or removed from an existing model. See PR [#&#8203;608](https://redirect.github.com/apollographql/apollo-ios-dev/pull/608).
-   **Configure `URLRequest` timeout interval ([#&#8203;3522](https://redirect.github.com/apollographql/apollo-ios/issues/3522)):** Added a request context specialization protocol (`RequestContextTimeoutConfigurable`) that specifies options for configuring the timeout interval of a `URLRequest`. See PR [#&#8203;618](https://redirect.github.com/apollographql/apollo-ios-dev/pull/618).

</details>

<details>
<summary>taskforcesh/bullmq (bullmq)</summary>

### [`v5.45.0`](https://redirect.github.com/taskforcesh/bullmq/releases/tag/v5.45.0)

[Compare Source](https://redirect.github.com/taskforcesh/bullmq/compare/v5.44.4...v5.45.0)

##### Features

-   add deduplicated job id to the deduplicated event ([0f21c10](https://redirect.github.com/taskforcesh/bullmq/commit/0f21c10bc9fd9a2290e8dde3c9b43bc366fcb15a))

</details>

<details>
<summary>electron/electron (electron)</summary>

### [`v35.1.2`](https://redirect.github.com/electron/electron/releases/tag/v35.1.2): electron v35.1.2

[Compare Source](https://redirect.github.com/electron/electron/compare/v35.1.1...v35.1.2)

### Release Notes for v35.1.2

#### Fixes

-   Fixed an issue where `navigationHistory.restore()` failed to restore the `userAgent` if it was overridden. [#&#8203;46300](https://redirect.github.com/electron/electron/pull/46300) <span style="font-size:small;">(Also in [34](https://redirect.github.com/electron/electron/pull/46298), [36](https://redirect.github.com/electron/electron/pull/46299))</span>

#### Other Changes

-   Security: backported fix for CVE-2025-2783. [#&#8203;46303](https://redirect.github.com/electron/electron/pull/46303)
-   Updated Chromium to 134.0.6998.178. [#&#8203;46287](https://redirect.github.com/electron/electron/pull/46287)

### [`v35.1.1`](https://redirect.github.com/electron/electron/releases/tag/v35.1.1): electron v35.1.1

[Compare Source](https://redirect.github.com/electron/electron/compare/v35.1.0...v35.1.1)

### Release Notes for v35.1.1

#### Fixes

-   Fixed build failure when building with printing disabled. [#&#8203;46285](https://redirect.github.com/electron/electron/pull/46285) <span style="font-size:small;">(Also in [34](https://redirect.github.com/electron/electron/pull/46286), [36](https://redirect.github.com/electron/electron/pull/46284))</span>

</details>

<details>
<summary>megahertz/electron-log (electron-log)</summary>

### [`v5.3.3`](https://redirect.github.com/megahertz/electron-log/compare/v5.3.2...v5.3.3)

[Compare Source](https://redirect.github.com/megahertz/electron-log/compare/v5.3.2...v5.3.3)

</details>

<details>
<summary>rexxars/eventsource-parser (eventsource-parser)</summary>

### [`v3.0.1`](https://redirect.github.com/rexxars/eventsource-parser/blob/HEAD/CHANGELOG.md#301-2025-03-27)

[Compare Source](https://redirect.github.com/rexxars/eventsource-parser/compare/v3.0.0...v3.0.1)

##### Bug Fixes

-   optimize `splitLines` function ([8952917](https://redirect.github.com/rexxars/eventsource-parser/commit/8952917a6f5b3d8c97175d00980538edc96b611d))
-   throw helpful error if passing function to `createParser()` ([4cd3a44](https://redirect.github.com/rexxars/eventsource-parser/commit/4cd3a443f21c441be29e524637a3a603d4425a12))

</details>

<details>
<summary>mixpanel/mixpanel-js (mixpanel-browser)</summary>

### [`v2.62.0`](https://redirect.github.com/mixpanel/mixpanel-js/compare/v2.61.2...3e3d5731642dd3e3ac543521155d3c51c8a37261)

[Compare Source](https://redirect.github.com/mixpanel/mixpanel-js/compare/v2.61.2...v2.62.0)

</details>

<details>
<summary>Papooch/nestjs-cls (nestjs-cls)</summary>

### [`v5.4.2`](https://redirect.github.com/Papooch/nestjs-cls/releases/tag/nestjs-cls%405.4.2)

[Compare Source](https://redirect.github.com/Papooch/nestjs-cls/compare/nestjs-cls@5.4.1...nestjs-cls@5.4.2)

##### Bug Fixes

-   **core**: un-deprecate wrongly deprecated parts of the plugin API ([#&#8203;228](https://redirect.github.com/Papooch/nestjs-cls/issues/228)) ([11ca429](https://redirect.github.com/Papooch/nestjs-cls/commits/11ca429))

</details>

<details>
<summary>matklad/once_cell (once_cell)</summary>

### [`v1.21.2`](https://redirect.github.com/matklad/once_cell/blob/HEAD/CHANGELOG.md#1212)

[Compare Source](https://redirect.github.com/matklad/once_cell/compare/v1.21.1...v1.21.2)

-   Relax success ordering from AcqRel to Release in `race`: [#&#8203;278](https://redirect.github.com/matklad/once_cell/pull/278).

</details>

<details>
<summary>openai/openai-node (openai)</summary>

### [`v4.90.0`](https://redirect.github.com/openai/openai-node/blob/HEAD/CHANGELOG.md#4900-2025-03-27)

[Compare Source](https://redirect.github.com/openai/openai-node/compare/v4.89.1...v4.90.0)

Full Changelog: [v4.89.1...v4.90.0](https://redirect.github.com/openai/openai-node/compare/v4.89.1...v4.90.0)

##### Features

-   **api:** add `get /chat/completions` endpoint ([2d6710a](https://redirect.github.com/openai/openai-node/commit/2d6710a1f9dd4f768d9c73e9c9f5f93c737cdc66))

##### Bug Fixes

-   **audio:** correctly handle transcription streaming ([2a9b603](https://redirect.github.com/openai/openai-node/commit/2a9b60336cd40a4d4fb9b898ece49170ad648fd0))
-   **internal:** work around [https://github.com/vercel/next.js/issues/76881](https://redirect.github.com/vercel/next.js/issues/76881) ([#&#8203;1427](https://redirect.github.com/openai/openai-node/issues/1427)) ([b467e94](https://redirect.github.com/openai/openai-node/commit/b467e949476621e8e92587a83c9de6fab35b2b9d))

##### Chores

-   add hash of OpenAPI spec/config inputs to .stats.yml ([45db35e](https://redirect.github.com/openai/openai-node/commit/45db35e34be560c75bf36224cc153c6d0e6e2a88))
-   **api:** updates to supported Voice IDs ([#&#8203;1424](https://redirect.github.com/openai/openai-node/issues/1424)) ([404f4db](https://redirect.github.com/openai/openai-node/commit/404f4db41a2ee651f5bfdaa7b8881e1bf015f058))
-   **client:** expose headers on some streaming errors ([#&#8203;1423](https://redirect.github.com/openai/openai-node/issues/1423)) ([b0783cc](https://redirect.github.com/openai/openai-node/commit/b0783cc6221b68f1738e759b393756a7d0e540a3))

### [`v4.89.1`](https://redirect.github.com/openai/openai-node/blob/HEAD/CHANGELOG.md#4891-2025-03-26)

[Compare Source](https://redirect.github.com/openai/openai-node/compare/v4.89.0...v4.89.1)

Full Changelog: [v4.89.0...v4.89.1](https://redirect.github.com/openai/openai-node/compare/v4.89.0...v4.89.1)

##### Bug Fixes

-   avoid type error in certain environments ([#&#8203;1413](https://redirect.github.com/openai/openai-node/issues/1413)) ([d3f6f8f](https://redirect.github.com/openai/openai-node/commit/d3f6f8f9c7511a98cc5795756fee49a30e44d485))
-   **client:** remove duplicate types ([#&#8203;1410](https://redirect.github.com/openai/openai-node/issues/1410)) ([338878b](https://redirect.github.com/openai/openai-node/commit/338878bf484dac5a4fadf50592b1f8d1045cd4b6))
-   **exports:** add missing type exports ([#&#8203;1417](https://redirect.github.com/openai/openai-node/issues/1417)) ([2d15ada](https://redirect.github.com/openai/openai-node/commit/2d15ada0e0d81a4e0d097dddbe99be2222c4c0ef))

##### Chores

-   **internal:** version bump ([#&#8203;1408](https://redirect.github.com/openai/openai-node/issues/1408)) ([9c0949a](https://redirect.github.com/openai/openai-node/commit/9c0949a93c3e181d327f820dbc2a4b0ad77258e9))

</details>

<details>
<summary>emilkowalski/sonner (sonner)</summary>

### [`v2.0.2`](https://redirect.github.com/emilkowalski/sonner/releases/tag/v2.0.2)

[Compare Source](https://redirect.github.com/emilkowalski/sonner/compare/v2.0.1...v2.0.2)

#### What's Changed

-   fix: isExtendedResult. Check if promiseData is an object and not a valid React Element by [@&#8203;diegotraid](https://redirect.github.com/diegotraid) in [https://github.com/emilkowalski/sonner/pull/595](https://redirect.github.com/emilkowalski/sonner/pull/595)
-   fix: toast.dismiss without an id doesn't dismiss by [@&#8203;emilkowalski](https://redirect.github.com/emilkowalski) in [https://github.com/emilkowalski/sonner/pull/609](https://redirect.github.com/emilkowalski/sonner/pull/609)
-   f

</details>

---

### Configuration

📅 **Schedule**: 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:eyJjcmVhdGVkSW5WZXIiOiIzOS4yMDcuMSIsInVwZGF0ZWRJblZlciI6IjM5LjIwNy4xIiwidGFyZ2V0QnJhbmNoIjoiY2FuYXJ5IiwibGFiZWxzIjpbImRlcGVuZGVuY2llcyJdfQ==-->
2025-03-28 13:44:55 +00:00
forehalo 64c7fb1d66 chore(server): validate function not actually used (#11263) 2025-03-28 12:51:19 +00:00
pengx17 387f7211bf fix(electron): cannot enable meetings correctly (#11269) 2025-03-28 11:13:48 +00:00
doodlewind ebee11f573 refactor(editor): enable forceUpdate by default in viewport apis (#11264)
In this way, all downstream callers can be guaranteed by correct viewport fit result, instead of requiring them to set `forceUpdate: true` param explicitly to them. The resizing optimization is an internal exception.
2025-03-28 10:04:55 +00:00
LongYinan 85daea6fa8 chore: remove depracated packages 2025-03-28 17:41:37 +08:00
pengx17 6c125d9a38 feat(electron): audio capture permissions and settings (#11185)
fix AF-2420, AF-2391, AF-2265
2025-03-28 09:12:26 +00:00
forehalo 8c582122a8 chore: fix copilot cron test (#11262) 2025-03-28 08:42:22 +00:00
232 changed files with 10431 additions and 4395 deletions
+8 -4
View File
@@ -144,14 +144,18 @@ jobs:
name: server-native.node
path: ./packages/backend/server
- name: Prepare Server Test Environment
env:
COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }}
COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }}
COPILOT_GOOGLE_API_KEY: ${{ secrets.COPILOT_GOOGLE_API_KEY }}
COPILOT_PERPLEXITY_API_KEY: ${{ secrets.COPILOT_PERPLEXITY_API_KEY }}
uses: ./.github/actions/server-test-env
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
uses: ./.github/actions/copilot-test
with:
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
openai-key: ${{ secrets.COPILOT_OPENAI_API_KEY }}
google-key: ${{ secrets.COPILOT_GOOGLE_API_KEY }}
fal-key: ${{ secrets.COPILOT_FAL_API_KEY }}
perplexity-key: ${{ secrets.COPILOT_PERPLEXITY_API_KEY }}
test-done:
needs:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,4 +12,4 @@ npmPublishAccess: public
npmPublishRegistry: "https://registry.npmjs.org"
yarnPath: .yarn/releases/yarn-4.7.0.cjs
yarnPath: .yarn/releases/yarn-4.8.0.cjs
Generated
+2 -2
View File
@@ -2501,9 +2501,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.1"
version = "1.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
checksum = "c2806eaa3524762875e21c3dcd057bc4b7bfa01ce4da8d46be1cd43649e1cc6b"
[[package]]
name = "oorandom"
@@ -11,10 +11,10 @@ import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ERROR_CARD_DEFAULT_HEIGHT } from '../consts';
import type { EmbedIframeStatusCardOptions } from '../types';
const LINK_EDIT_POPUP_OFFSET = 12;
const ERROR_CARD_DEFAULT_HEIGHT = 114;
export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
static override styles = css`
@@ -24,7 +24,7 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
}
.affine-embed-iframe-error-card {
container: affine-embed-iframe-error-card / inline-size;
container: affine-embed-iframe-error-card / size;
display: flex;
box-sizing: border-box;
user-select: none;
@@ -41,7 +41,6 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 0 0;
.error-title {
display: flex;
@@ -64,6 +63,9 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
font-style: normal;
font-weight: 600;
line-height: 22px; /* 157.143% */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -119,12 +121,6 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
}
}
}
@container affine-embed-iframe-error-card (width < 480px) {
.error-banner {
display: none;
}
}
}
.affine-embed-iframe-error-card.horizontal {
@@ -133,12 +129,19 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
.error-content {
align-items: flex-start;
flex: 1 0 0;
.error-message {
height: 40px;
align-items: flex-start;
}
}
@container affine-embed-iframe-error-card (width < 480px) {
.error-banner {
display: none;
}
}
}
.affine-embed-iframe-error-card.vertical {
@@ -155,6 +158,18 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
align-items: center;
}
}
.icon-box {
svg {
transform: scale(1.6) translateY(-14px);
}
}
@container affine-embed-iframe-error-card (height < 300px) or (width < 300px) {
.error-banner {
display: none;
}
}
}
`;
@@ -216,10 +231,10 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
<div class=${cardClasses} style=${cardStyle}>
<div class="error-content">
<div class="error-title">
<div class="error-icon">
<span class="error-icon">
${InformationIcon({ width: '16px', height: '16px' })}
</div>
<div class="error-title-text">This link couldnt be loaded.</div>
</span>
<span class="error-title-text">This link couldnt be loaded.</span>
</div>
<div class="error-message">
${this.error?.message || 'Failed to load embedded content'}
@@ -244,8 +259,7 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
</div>
</div>
<div class="error-banner">
<!-- TODO: add error banner icon -->
<div class="icon-box"></div>
<div class="icon-box">${EmbedIframeErrorIcon}</div>
</div>
</div>
`;
@@ -280,3 +294,25 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
height: ERROR_CARD_DEFAULT_HEIGHT,
};
}
export const EmbedIframeErrorIcon = html`<svg
width="204"
height="102"
viewBox="0 0 204 102"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_2676_106795)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M94.6838 8.45092L106.173 31.9276L84.6593 57.0514L90.5888 64.9202C88.6083 64.6092 86.5089 65.0701 84.7813 66.3719L78.4802 71.1202C75.0967 73.6698 74.4207 78.4796 76.9704 81.8631C79.5201 85.2467 84.3299 85.9227 87.7134 83.373L89.4487 82.0654C90.3714 81.37 90.5558 80.0582 89.8604 79.1354C89.1651 78.2127 87.8533 78.0283 86.9305 78.7237L85.1952 80.0313C83.6573 81.1902 81.471 80.883 80.3121 79.345C79.1531 77.807 79.4604 75.6208 80.9984 74.4618L87.2995 69.7136C88.8375 68.5547 91.0237 68.8619 92.1827 70.3999C92.8645 71.3047 94.1389 71.4996 95.0582 70.8513L95.8982 71.966L94.6469 72.9089C93.109 74.0679 90.9227 73.7606 89.7638 72.2227C89.0684 71.2999 87.7566 71.1155 86.8339 71.8109C85.9111 72.5062 85.7267 73.818 86.4221 74.7408C88.9718 78.1243 93.7816 78.8003 97.1651 76.2506L98.4164 75.3077L99.8156 77.1646L86.8434 102.707L89.291 114.735L42.1397 108.108L56.3354 7.10072C56.6429 4.91308 58.6655 3.38889 60.8532 3.69634L94.6838 8.45092ZM122.987 12.4287L119.974 33.8672L95.4607 58.4925C98.7006 56.8928 102.722 57.7678 104.976 60.7594C107.526 64.1429 106.85 68.9527 103.466 71.5024L102.718 72.0665L105.949 78.0266L92.2105 103.461L92.9872 115.254L147.108 122.86L161.304 21.8531C161.611 19.6654 160.087 17.6428 157.899 17.3353L122.987 12.4287ZM100.701 68.3471L100.948 68.1607C102.486 67.0018 102.793 64.8155 101.634 63.2775C100.625 61.9381 98.8364 61.5321 97.3755 62.2152L100.701 68.3471ZM88.8231 36.502C84.6277 35.9124 80.7486 38.8354 80.159 43.0308L79.1885 49.9367C79.0277 51.0809 79.8249 52.1388 80.9691 52.2996C82.1133 52.4604 83.1712 51.6632 83.332 50.519L84.3025 43.6132C84.5705 41.7062 86.3337 40.3775 88.2407 40.6455L95.1466 41.6161C96.2908 41.7769 97.3487 40.9797 97.5095 39.8355C97.6703 38.6913 96.8731 37.6334 95.7289 37.4726L88.8231 36.502ZM115.065 40.1901C113.921 40.0293 112.863 40.8265 112.702 41.9707C112.542 43.1149 113.339 44.1728 114.483 44.3336L121.389 45.3042C123.296 45.5722 124.625 47.3354 124.357 49.2424L123.386 56.1483C123.225 57.2925 124.022 58.3504 125.167 58.5112C126.311 58.672 127.369 57.8748 127.529 56.7306L128.5 49.8247C129.09 45.6293 126.167 41.7503 121.971 41.1607L115.065 40.1901ZM123.031 73.7041C124.176 73.8649 124.973 74.9228 124.812 76.067L123.841 82.9728C123.252 87.1682 119.373 90.0913 115.177 89.5017L106.89 88.337C105.746 88.1762 104.949 87.1183 105.11 85.9741C105.27 84.8299 106.328 84.0327 107.473 84.1935L115.76 85.3582C117.667 85.6262 119.43 84.2975 119.698 82.3905L120.668 75.4847C120.829 74.3405 121.887 73.5433 123.031 73.7041Z"
fill="#E6E6E6"
/>
</g>
<defs>
<clipPath id="clip0_2676_106795">
<rect width="204" height="102" fill="white" />
</clipPath>
</defs>
</svg>`;
@@ -3,16 +3,22 @@ import { WithDisposable } from '@blocksuite/global/lit';
import { EmbedIcon } from '@blocksuite/icons/lit';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { IDLE_CARD_DEFAULT_HEIGHT } from '../consts';
import type { EmbedIframeStatusCardOptions } from '../types';
export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
height: 100%;
}
.affine-embed-iframe-idle-card {
width: 100%;
height: 48px;
container: affine-embed-iframe-idle-card / size;
box-sizing: border-box;
display: flex;
align-items: center;
@@ -23,8 +29,6 @@ export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
.icon {
display: flex;
width: 24px;
height: 24px;
justify-content: center;
align-items: center;
color: ${unsafeCSSVarV2('icon/secondary')};
@@ -48,18 +52,81 @@ export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
.affine-embed-iframe-idle-card:hover {
cursor: pointer;
}
.affine-embed-iframe-idle-card.horizontal {
flex-direction: row;
.icon {
width: 24px;
height: 24px;
svg {
width: 24px;
height: 24px;
}
}
}
.affine-embed-iframe-idle-card.vertical {
flex-direction: column;
justify-content: center;
overflow: hidden;
gap: 12px;
.icon {
width: 176px;
height: 112px;
overflow-y: hidden;
svg {
width: 112px;
height: 112px;
transform: rotate(12deg) translateY(18%);
}
}
.text {
text-align: center;
white-space: normal;
word-break: break-word;
}
@container affine-embed-iframe-idle-card (height < 180px) {
.icon {
display: none;
}
}
}
`;
override render() {
const { layout, width, height } = this.options;
const cardClasses = classMap({
'affine-embed-iframe-idle-card': true,
horizontal: layout === 'horizontal',
vertical: layout === 'vertical',
});
const cardWidth = width ? `${width}px` : '100%';
const cardHeight = height ? `${height}px` : '100%';
const cardStyle = styleMap({
width: cardWidth,
height: cardHeight,
});
return html`
<div class="affine-embed-iframe-idle-card">
<span class="icon">
${EmbedIcon({ width: '24px', height: '24px' })}
</span>
<div class=${cardClasses} style=${cardStyle}>
<span class="icon"> ${EmbedIcon()} </span>
<span class="text">
Embed anything (Google Drive, Google Docs, Spotify, Miro…)
</span>
</div>
`;
}
@property({ attribute: false })
accessor options: EmbedIframeStatusCardOptions = {
layout: 'horizontal',
height: IDLE_CARD_DEFAULT_HEIGHT,
};
}
@@ -104,6 +104,7 @@ export class EmbedIframeLinkInputBase extends WithDisposable(LitElement) {
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
}
get store() {
@@ -8,10 +8,9 @@ import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getEmbedCardIcons } from '../../common/utils';
import { LOADING_CARD_DEFAULT_HEIGHT } from '../consts';
import type { EmbedIframeStatusCardOptions } from '../types';
const LOADING_CARD_DEFAULT_HEIGHT = 114;
export class EmbedIframeLoadingCard extends LitElement {
static override styles = css`
:host {
@@ -20,7 +19,7 @@ export class EmbedIframeLoadingCard extends LitElement {
}
.affine-embed-iframe-loading-card {
container: affine-embed-iframe-loading-card / inline-size;
container: affine-embed-iframe-loading-card / size;
display: flex;
box-sizing: border-box;
border-radius: 8px;
@@ -147,6 +146,12 @@ export class EmbedIframeLoadingCard extends LitElement {
}
}
}
@container affine-embed-iframe-loading-card (height < 240px) {
.loading-banner {
display: none;
}
}
}
`;
@@ -6,3 +6,7 @@ export const DEFAULT_IFRAME_HEIGHT = 152;
export const DEFAULT_IFRAME_WIDTH = '100%';
export const LINK_CREATE_POPUP_OFFSET = 4;
export const IDLE_CARD_DEFAULT_HEIGHT = 48;
export const LOADING_CARD_DEFAULT_HEIGHT = 114;
export const ERROR_CARD_DEFAULT_HEIGHT = 114;
@@ -34,7 +34,10 @@ import {
DEFAULT_IFRAME_HEIGHT,
DEFAULT_IFRAME_WIDTH,
EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS,
ERROR_CARD_DEFAULT_HEIGHT,
IDLE_CARD_DEFAULT_HEIGHT,
LINK_CREATE_POPUP_OFFSET,
LOADING_CARD_DEFAULT_HEIGHT,
} from './consts.js';
import { embedIframeBlockStyles } from './style.js';
import type { EmbedIframeStatusCardOptions } from './types.js';
@@ -109,10 +112,23 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
return flag ?? false;
}
get _horizontalCardHeight(): number {
switch (this.status$.value) {
case 'idle':
return IDLE_CARD_DEFAULT_HEIGHT;
case 'loading':
return LOADING_CARD_DEFAULT_HEIGHT;
case 'error':
return ERROR_CARD_DEFAULT_HEIGHT;
default:
return LOADING_CARD_DEFAULT_HEIGHT;
}
}
get _statusCardOptions(): EmbedIframeStatusCardOptions {
return this.inSurface
? { layout: 'vertical' }
: { layout: 'horizontal', height: 114 };
: { layout: 'horizontal', height: this._horizontalCardHeight };
}
open = () => {
@@ -257,19 +273,21 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
};
protected _handleClick = () => {
// We don't need to select the block when the block is in the surface
if (this.inSurface) {
return;
}
// when the block is in idle status and the url is not set, clear the selection
// and show the link input popup
if (this.isIdle$.value && !this.model.props.url) {
this.selectionManager.clear(['block']);
// when the block is in the surface, clear the surface selection
// otherwise, clear the block selection
this.selectionManager.clear([this.inSurface ? 'surface' : 'block']);
this.toggleLinkInputPopup();
return;
}
// We don't need to select the block when the block is in the surface
if (this.inSurface) {
return;
}
// otherwise, select the block
this._selectBlock();
};
@@ -311,7 +329,9 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
private readonly _renderContent = () => {
if (this.isIdle$.value) {
return html`<embed-iframe-idle-card></embed-iframe-idle-card>`;
return html`<embed-iframe-idle-card
.options=${this._statusCardOptions}
></embed-iframe-idle-card>`;
}
if (this.isLoading$.value) {
@@ -356,6 +376,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
})
);
// if the iframe url is not set, refresh the data to get the iframe url
if (!this.model.props.iframeUrl) {
this.doc.withoutTransact(() => {
this.refreshData().catch(console.error);
@@ -11,6 +11,7 @@
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
@@ -34,7 +35,8 @@
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
"./effects": "./src/effects.ts",
"./turbo-painter": "./src/turbo/list-painter.worker.ts"
},
"files": [
"src",
@@ -3,3 +3,5 @@ export * from './commands';
export { correctNumberedListsOrderToPrev } from './commands/utils';
export * from './list-block.js';
export * from './list-spec.js';
export * from './turbo/list-layout-handler';
export * from './turbo/list-painter.worker';
@@ -0,0 +1,145 @@
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutHandlerExtension,
BlockLayoutHandlersIdentifier,
getSentenceRects,
segmentSentences,
} from '@blocksuite/affine-gfx-turbo-renderer';
import type { Container } from '@blocksuite/global/di';
import type { GfxBlockComponent } from '@blocksuite/std';
import { clientToModelCoord } from '@blocksuite/std/gfx';
import type { ListLayout } from './list-painter.worker';
export class ListLayoutHandlerExtension extends BlockLayoutHandlerExtension<ListLayout> {
readonly blockType = 'affine:list';
static override setup(di: Container) {
di.addImpl(
BlockLayoutHandlersIdentifier('list'),
ListLayoutHandlerExtension
);
}
queryLayout(component: GfxBlockComponent): ListLayout | null {
// Select all list items within this list block
const listItemSelector =
'.affine-list-block-container .affine-list-rich-text-wrapper [data-v-text="true"]';
const listItemNodes = component.querySelectorAll(listItemSelector);
if (listItemNodes.length === 0) return null;
const viewportRecord = component.gfx.viewport.deserializeRecord(
component.dataset.viewportState
);
if (!viewportRecord) return null;
const { zoom, viewScale } = viewportRecord;
const list: ListLayout = {
type: 'affine:list',
items: [],
};
listItemNodes.forEach(listItemNode => {
const listItemWrapper = listItemNode.closest(
'.affine-list-rich-text-wrapper'
);
if (!listItemWrapper) return;
// Determine list item type based on class
let itemType: 'bulleted' | 'numbered' | 'todo' | 'toggle' = 'bulleted';
let checked = false;
let collapsed = false;
let prefix = '';
if (listItemWrapper.classList.contains('affine-list--checked')) {
checked = true;
}
const parentListBlock = listItemWrapper.closest(
'.affine-list-block-container'
)?.parentElement;
if (parentListBlock) {
if (parentListBlock.dataset.listType === 'numbered') {
itemType = 'numbered';
const orderVal = parentListBlock.dataset.listOrder;
if (orderVal) {
prefix = orderVal + '.';
}
} else if (parentListBlock.dataset.listType === 'todo') {
itemType = 'todo';
} else if (parentListBlock.dataset.listType === 'toggle') {
itemType = 'toggle';
collapsed = parentListBlock.dataset.collapsed === 'true';
} else {
itemType = 'bulleted';
}
}
const computedStyle = window.getComputedStyle(listItemNode);
const fontSizeStr = computedStyle.fontSize;
const fontSize = parseInt(fontSizeStr);
const sentences = segmentSentences(listItemNode.textContent || '');
const sentenceLayouts = sentences.map(sentence => {
const sentenceRects = getSentenceRects(listItemNode, sentence);
return {
text: sentence,
rects: sentenceRects.map(({ text, rect }) => {
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
rect.x,
rect.y,
]);
return {
text,
rect: {
x: modelX,
y: modelY,
w: rect.w / zoom / viewScale,
h: rect.h / zoom / viewScale,
},
};
}),
fontSize,
type: itemType,
prefix,
checked,
collapsed,
};
});
list.items.push(...sentenceLayouts);
});
return list;
}
calculateBound(layout: ListLayout) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
layout.items.forEach(item => {
item.rects.forEach(r => {
minX = Math.min(minX, r.rect.x);
minY = Math.min(minY, r.rect.y);
maxX = Math.max(maxX, r.rect.x + r.rect.w);
maxY = Math.max(maxY, r.rect.y + r.rect.h);
});
});
const rect: Rect = {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
return {
rect,
subRects: layout.items.flatMap(s => s.rects.map(r => r.rect)),
};
}
}
@@ -0,0 +1,114 @@
import type {
BlockLayout,
BlockLayoutPainter,
TextRect,
WorkerToHostMessage,
} from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutPainterExtension,
getBaseline,
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
interface ListItemLayout {
text: string;
rects: TextRect[];
fontSize: number;
type: 'bulleted' | 'numbered' | 'todo' | 'toggle';
prefix?: string;
checked?: boolean;
collapsed?: boolean;
}
export interface ListLayout extends BlockLayout {
type: 'affine:list';
items: ListItemLayout[];
}
const debugListBorder = false;
function isListLayout(layout: BlockLayout): layout is ListLayout {
return layout.type === 'affine:list';
}
class ListLayoutPainter implements BlockLayoutPainter {
private static readonly supportFontFace =
typeof FontFace !== 'undefined' &&
typeof self !== 'undefined' &&
'fonts' in self;
static readonly font = ListLayoutPainter.supportFontFace
? new FontFace(
'Inter',
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
)
: null;
static fontLoaded = !ListLayoutPainter.supportFontFace;
static {
if (ListLayoutPainter.supportFontFace && ListLayoutPainter.font) {
// @ts-expect-error worker fonts API
self.fonts.add(ListLayoutPainter.font);
ListLayoutPainter.font
.load()
.then(() => {
ListLayoutPainter.fontLoaded = true;
})
.catch(error => {
console.error('Failed to load Inter font:', error);
});
}
}
paint(
ctx: OffscreenCanvasRenderingContext2D,
layout: BlockLayout,
layoutBaseX: number,
layoutBaseY: number
): void {
if (!ListLayoutPainter.fontLoaded) {
const message: WorkerToHostMessage = {
type: 'paintError',
error: 'Font not loaded',
blockType: 'affine:list',
};
self.postMessage(message);
return;
}
if (!isListLayout(layout)) return;
const renderedPositions = new Set<string>();
layout.items.forEach(item => {
const fontSize = item.fontSize;
const baselineY = getBaseline(fontSize);
ctx.font = `${fontSize}px Inter`;
ctx.strokeStyle = 'yellow';
// Render the text content
item.rects.forEach(textRect => {
const x = textRect.rect.x - layoutBaseX;
const y = textRect.rect.y - layoutBaseY;
const posKey = `${x},${y}`;
// Only render if we haven't rendered at this position before
if (renderedPositions.has(posKey)) return;
if (debugListBorder) {
ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h);
}
ctx.fillStyle = 'black';
ctx.fillText(textRect.text, x, y + baselineY);
renderedPositions.add(posKey);
});
});
}
}
export const ListLayoutPainterExtension = BlockLayoutPainterExtension(
'affine:list',
ListLayoutPainter
);
@@ -8,6 +8,7 @@
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../gfx/turbo-renderer" },
{ "path": "../../inlines/preset" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
@@ -216,6 +216,8 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
const { borderRadius } = edgeless.style;
const { collapse = false, collapsedHeight, scale = 1 } = edgeless;
const { tool } = this.gfx;
const bound = Bound.deserialize(xywh);
const height = bound.h / scale;
@@ -280,7 +282,9 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
.editing=${this._editing}
></edgeless-note-mask>
${isCollapsable && (!this.model.isPageBlock() || !hasHeader)
${isCollapsable &&
tool.currentToolName$.value !== 'frameNavigator' &&
(!this.model.isPageBlock() || !hasHeader)
? html`<div
class="${classMap({
[styles.collapseButton]: true,
@@ -3,5 +3,5 @@ export * from './commands';
export * from './paragraph-block.js';
export * from './paragraph-block-config.js';
export * from './paragraph-spec.js';
export * from './turbo/paragraph-layout-provider.js';
export * from './turbo/paragraph-painter.worker.js';
export * from './turbo/paragraph-layout-handler';
export * from './turbo/paragraph-painter.worker';
@@ -15,8 +15,10 @@ export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension
readonly blockType = 'affine:paragraph';
static override setup(di: Container) {
const layoutHandler = new ParagraphLayoutHandlerExtension();
di.addImpl(BlockLayoutHandlersIdentifier, layoutHandler);
di.addImpl(
BlockLayoutHandlersIdentifier('paragraph'),
ParagraphLayoutHandlerExtension
);
}
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {
@@ -4,7 +4,10 @@ import type {
TextRect,
WorkerToHostMessage,
} from '@blocksuite/affine-gfx-turbo-renderer';
import { BlockLayoutPainterExtension } from '@blocksuite/affine-gfx-turbo-renderer/painter';
import {
BlockLayoutPainterExtension,
getBaseline,
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
interface SentenceLayout {
text: string;
@@ -17,25 +20,8 @@ export interface ParagraphLayout extends BlockLayout {
sentences: SentenceLayout[];
}
const meta = {
emSize: 2048,
hHeadAscent: 1984,
hHeadDescent: -494,
};
const debugSentenceBorder = false;
function getBaseline(fontSize: number) {
const lineHeight = 1.2 * fontSize;
const A = fontSize * (meta.hHeadAscent / meta.emSize); // ascent
const D = fontSize * (meta.hHeadDescent / meta.emSize); // descent
const AD = A + Math.abs(D); // ascent + descent
const L = lineHeight - AD; // leading
const y = A + L / 2;
return y;
}
function isParagraphLayout(layout: BlockLayout): layout is ParagraphLayout {
return layout.type === 'affine:paragraph';
}
@@ -1,4 +1,9 @@
import { FileDropExtension } from '@blocksuite/affine-components/drop-indicator';
import { ConnectorElementView } from '@blocksuite/affine-gfx-connector';
import { GroupElementView } from '@blocksuite/affine-gfx-group';
import { MindMapView } from '@blocksuite/affine-gfx-mindmap';
import { ShapeElementView } from '@blocksuite/affine-gfx-shape';
import { TextElementView } from '@blocksuite/affine-gfx-text';
import { NoteBlockSchema } from '@blocksuite/affine-model';
import {
DNDAPIExtension,
@@ -27,6 +32,19 @@ import {
viewportOverlayWidget,
} from './widgets';
/**
* Why do we add these extensions into CommonSpecs?
* Because in some cases we need to create edgeless elements in page mode.
* And these view may contain some logic when elements initialize.
*/
const EdgelessElementViews = [
ConnectorElementView,
MindMapView,
GroupElementView,
TextElementView,
ShapeElementView,
];
export const CommonSpecs: ExtensionType[] = [
FlavourExtension('affine:page'),
DocModeService,
@@ -38,6 +56,7 @@ export const CommonSpecs: ExtensionType[] = [
ToolbarRegistryExtension,
...RootBlockAdapterExtensions,
...clipboardConfigs,
...EdgelessElementViews,
modalWidget,
innerModalWidget,
@@ -1,8 +1,3 @@
import { ConnectorElementView } from '@blocksuite/affine-gfx-connector';
import { GroupElementView } from '@blocksuite/affine-gfx-group';
import { MindMapView } from '@blocksuite/affine-gfx-mindmap';
import { ShapeElementView } from '@blocksuite/affine-gfx-shape';
import { TextElementView } from '@blocksuite/affine-gfx-text';
import { ViewportElementExtension } from '@blocksuite/affine-shared/services';
import { autoConnectWidget } from '@blocksuite/affine-widget-edgeless-auto-connect';
import { edgelessToolbarWidget } from '@blocksuite/affine-widget-edgeless-toolbar';
@@ -90,20 +85,11 @@ const EdgelessClipboardConfigs: ExtensionType[] = [
EdgelessClipboardEmbedSyncedDocConfig,
];
export const gfxElementViews = [
ConnectorElementView,
MindMapView,
GroupElementView,
TextElementView,
ShapeElementView,
];
const EdgelessCommonExtension: ExtensionType[] = [
CommonSpecs,
ToolController,
EdgelessRootService,
ViewportElementExtension('.affine-edgeless-viewport'),
...gfxElementViews,
...quickTools,
...seniorTools,
...EdgelessClipboardConfigs,
@@ -105,3 +105,20 @@ export class ViewportLayoutPainter {
}
};
}
const meta = {
emSize: 2048,
hHeadAscent: 1984,
hHeadDescent: -494,
};
export function getBaseline(fontSize: number) {
const lineHeight = 1.2 * fontSize;
const A = fontSize * (meta.hHeadAscent / meta.emSize); // ascent
const D = fontSize * (meta.hHeadDescent / meta.emSize); // descent
const AD = A + Math.abs(D); // ascent + descent
const L = lineHeight - AD; // leading
const y = A + L / 2;
return y;
}
@@ -11,6 +11,7 @@
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-callout": "workspace:*",
"@blocksuite/affine-block-embed": "workspace:*",
"@blocksuite/affine-block-list": "workspace:*",
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-paragraph": "workspace:*",
@@ -0,0 +1,133 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
EmbedIcon,
FrameIcon,
ImageIcon,
PageIcon,
ShapeIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
const BLOCK_PREVIEW_ICON_MAP: Record<
string,
{
icon: typeof ShapeIcon;
name: string;
}
> = {
shape: {
icon: ShapeIcon,
name: 'Edgeless shape',
},
'affine:image': {
icon: ImageIcon,
name: 'Image block',
},
'affine:note': {
icon: PageIcon,
name: 'Note block',
},
'affine:frame': {
icon: FrameIcon,
name: 'Frame block',
},
'affine:embed-': {
icon: EmbedIcon,
name: 'Embed block',
},
};
declare global {
interface HTMLElementTagNameMap {
'edgeless-dnd-preview-element': EdgelessDndPreviewElement;
}
}
export const EDGELESS_DND_PREVIEW_ELEMENT = 'edgeless-dnd-preview-element';
export class EdgelessDndPreviewElement extends LitElement {
static override styles = css`
.edgeless-dnd-preview-container {
position: relative;
padding: 12px;
width: 264px;
height: 80px;
}
.edgeless-dnd-preview-block {
display: flex;
position: absolute;
width: 234px;
align-items: flex-start;
box-sizing: border-box;
border-radius: 8px;
background-color: ${unsafeCSSVarV2(
'layer/background/overlayPanel',
'#FBFBFC'
)};
padding: 8px 20px;
gap: 8px;
transform-origin: center;
font-family: var(--affine-font-family);
box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset;
}
.edgeless-dnd-preview-block > svg {
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
}
.edgeless-dnd-preview-block > .text {
color: ${unsafeCSSVarV2('text/primary', '#121212')};
font-size: 14px;
line-height: 24px;
}
`;
@property({ type: Array })
accessor elementTypes: {
type: string;
}[] = [];
private _getPreviewIcon(type: string) {
if (BLOCK_PREVIEW_ICON_MAP[type]) {
return BLOCK_PREVIEW_ICON_MAP[type];
}
if (type.startsWith('affine:embed-')) {
return BLOCK_PREVIEW_ICON_MAP['affine:embed-'];
}
return {
icon: ShapeIcon,
name: 'Edgeless content',
};
}
override render() {
const blocks = repeat(this.elementTypes.slice(0, 3), ({ type }, index) => {
const { icon, name } = this._getPreviewIcon(type);
return html`<div
class="edgeless-dnd-preview-block"
style=${styleMap({
transform: `rotate(${index * -2}deg)`,
zIndex: 3 - index,
})}
>
${icon({ width: '24px', height: '24px' })}
<span class="text">${name}</span>
</div>`;
});
return html`<div class="edgeless-dnd-preview-container">${blocks}</div>`;
}
}
@@ -1,6 +1,14 @@
import {
EDGELESS_DND_PREVIEW_ELEMENT,
EdgelessDndPreviewElement,
} from './components/edgeless-preview/preview';
import { AFFINE_DRAG_HANDLE_WIDGET } from './consts';
import { AffineDragHandleWidget } from './drag-handle';
export function effects() {
customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget);
customElements.define(
EDGELESS_DND_PREVIEW_ELEMENT,
EdgelessDndPreviewElement
);
}
@@ -1,19 +1,11 @@
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { RootBlockModel } from '@blocksuite/affine-model';
import {
DocModeExtension,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
} from '@blocksuite/affine-shared/services';
import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
import {
type BlockComponent,
BlockStdScope,
BlockViewIdentifier,
LifeCycleWatcher,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { SpecProvider } from '@blocksuite/affine-shared/utils';
import { BlockStdScope, BlockViewIdentifier } from '@blocksuite/std';
import type {
BlockModel,
BlockViewType,
@@ -24,14 +16,11 @@ import type {
import { signal } from '@preact/signals-core';
import { literal } from 'lit/static-html.js';
import { EdgelessDndPreviewElement } from '../components/edgeless-preview/preview.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
import { getSnapshotRect } from '../utils.js';
export class PreviewHelper {
private readonly _calculateQuery = (
selectedIds: string[],
mode: 'block' | 'gfx'
): Query => {
private readonly _calculateQuery = (selectedIds: string[]): Query => {
const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map(
id => ({
id,
@@ -58,22 +47,10 @@ export class PreviewHelper {
}
const children = model.children ?? [];
if (
mode === 'gfx' &&
matchModels(model, [RootBlockModel, SurfaceBlockModel])
) {
children.forEach(child => {
if (selectedIds.includes(child.id)) {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
}
});
} else {
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
}
children.forEach(child => {
ids.push({ viewType: 'display', id: child.id });
addChildren(child.id);
});
};
selectedIds.forEach(addChildren);
@@ -83,28 +60,16 @@ export class PreviewHelper {
};
};
getPreviewStd = (
blockIds: string[],
snapshot: SliceSnapshot,
mode: 'block' | 'gfx'
) => {
getPreviewStd = (blockIds: string[]) => {
const widget = this.widget;
const std = widget.std;
const sourceGfx = std.get(GfxControllerIdentifier);
const isEdgeless = mode === 'gfx';
blockIds = blockIds.slice();
if (isEdgeless) {
blockIds.push(sourceGfx.surface!.id, std.store.root!.id);
}
const docModeService = std.get(DocModeProvider);
const editorSetting = std.get(EditorSettingProvider).peek();
const query = this._calculateQuery(blockIds as string[], mode);
const query = this._calculateQuery(blockIds as string[]);
const store = widget.doc.doc.getStore({ query });
const previewSpec = SpecProvider._.getSpec(
isEdgeless ? 'preview:edgeless' : 'preview:page'
);
const previewSpec = SpecProvider._.getSpec('preview:page');
const settingSignal = signal({ ...editorSetting });
const extensions = [
DocModeExtension(docModeService),
@@ -134,35 +99,6 @@ export class PreviewHelper {
} as ExtensionType,
];
if (isEdgeless) {
class PreviewViewportInitializer extends LifeCycleWatcher {
static override key = 'preview-viewport-initializer';
override mounted(): void {
const rect = getSnapshotRect(snapshot);
if (!rect) {
return;
}
this.std.view.viewUpdated.subscribe(payload => {
if (payload.type !== 'block') return;
if (payload.view.model.flavour === 'affine:page') {
const gfx = this.std.get(GfxControllerIdentifier);
(
payload.view as BlockComponent & { overrideBackground: string }
).overrideBackground = 'transparent';
gfx.viewport.setViewportByBound(rect);
}
});
}
}
extensions.push(PreviewViewportInitializer);
}
previewSpec.extend(extensions);
settingSignal.value = {
@@ -177,50 +113,92 @@ export class PreviewHelper {
let width: number = 500;
let height;
let scale = 1;
if (isEdgeless) {
const rect = getSnapshotRect(snapshot);
if (rect) {
width = rect.w;
height = rect.h;
} else {
height = 500;
}
scale = sourceGfx.viewport.zoom;
} else {
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
}
const noteBlock = this.widget.host.querySelector('affine-note');
width = noteBlock?.offsetWidth ?? noteBlock?.clientWidth ?? 500;
return {
scale,
previewStd,
width,
height,
};
};
private _extractBlockTypes(snapshot: SliceSnapshot) {
const blockTypes: {
type: string;
}[] = [];
snapshot.content.forEach(block => {
if (block.flavour === 'affine:surface') {
Object.values(
block.props.elements as Record<string, { id: string; type: string }>
).forEach(elem => {
blockTypes.push({
type: elem.type,
});
});
} else {
blockTypes.push({
type: block.flavour,
});
}
});
return blockTypes;
}
getPreviewElement = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
mode: 'block' | 'gfx';
}) => {
const { blockIds, snapshot, mode } = options;
if (mode === 'block') {
const { previewStd, width, height } = this.getPreviewStd(blockIds);
const previewTemplate = previewStd.render();
return {
width,
height,
element: previewTemplate,
};
} else {
const blockTypes = this._extractBlockTypes(snapshot);
const edgelessPreview = new EdgelessDndPreviewElement();
edgelessPreview.elementTypes = blockTypes;
return {
left: 12,
top: 12,
element: edgelessPreview,
};
}
};
renderDragPreview = (options: {
blockIds: string[];
snapshot: SliceSnapshot;
container: HTMLElement;
mode: 'block' | 'gfx';
}): void => {
const { blockIds, snapshot, container, mode } = options;
const { previewStd, width, height, scale } = this.getPreviewStd(
blockIds,
snapshot,
mode
);
const previewTemplate = previewStd.render();
}): { x: number; y: number } => {
const { container } = options;
const { width, height, element, left, top } =
this.getPreviewElement(options);
container.style.transform = `scale(${scale})`;
container.style.width = `${width}px`;
if (height) {
container.style.height = `${height}px`;
}
container.append(previewTemplate);
container.style.position = 'absolute';
container.style.left = left ? `${left}px` : '';
container.style.top = top ? `${top}px` : '';
container.style.width = width ? `${width}px` : '';
container.style.height = height ? `${height}px` : '';
container.append(element);
return {
x: left ?? 0,
y: top ?? 0,
};
};
constructor(readonly widget: AffineDragHandleWidget) {}
@@ -1,3 +1,7 @@
import {
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
} from '@blocksuite/affine-block-embed';
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
import { DropIndicator } from '@blocksuite/affine-components/drop-indicator';
import {
@@ -511,6 +515,7 @@ export class DragEventWatcher {
this._mergeSnapshotToCurDoc(snapshot, point).catch(console.error);
} else {
this._dropAsGfxBlock(snapshot, point);
this.widget.selectionHelper.selection.clear(['block']);
}
} else {
this._onPageDrop(dropBlock, dragPayload, dropPayload, point);
@@ -1052,7 +1057,10 @@ export class DragEventWatcher {
Bound.deserialize(block.props.xywh as SerializedXYWH) ??
new Bound(0, 0, 0, 0);
if (
if (block.flavour === 'affine:embed-iframe') {
blockBound.w = EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
blockBound.h = EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
} else if (
block.flavour === 'affine:attachment' ||
block.flavour === 'affine:bookmark' ||
block.flavour.startsWith('affine:embed-')
@@ -1132,17 +1140,22 @@ export class DragEventWatcher {
this._dropToModel(surfaceSnapshot, this.gfx.surface!.id)
.then(slices => {
slices?.content.forEach((block, idx) => {
if (
block.id === content[idx].id &&
(block.flavour === 'affine:image' ||
if (block.id === content[idx].id) {
if (block.flavour === 'affine:embed-iframe') {
store.updateBlock(block.id, {
xywh: content[idx].props.xywh,
});
} else if (
block.flavour === 'affine:image' ||
block.flavour === 'affine:attachment' ||
block.flavour === 'affine:bookmark' ||
block.flavour.startsWith('affine:embed-'))
) {
store.updateBlock(block.id, {
xywh: content[idx].props.xywh,
style: content[idx].props.style,
});
block.flavour.startsWith('affine:embed-')
) {
store.updateBlock(block.id, {
xywh: content[idx].props.xywh,
style: content[idx].props.style,
});
}
}
});
})
@@ -1160,7 +1173,11 @@ export class DragEventWatcher {
this._dropToModel(pageSnapshot, this.widget.doc.root!.id)
.then(slices => {
slices?.content.forEach((block, idx) => {
if (
if (block.flavour === 'affine:embed-iframe') {
store.updateBlock(block.id, {
xywh: content[idx].props.xywh,
});
} else if (
block.flavour === 'affine:attachment' ||
block.flavour.startsWith('affine:embed-')
) {
@@ -1403,14 +1420,14 @@ export class DragEventWatcher {
const { snapshot, fromMode } = source.data.bsEntity;
this.previewHelper.renderDragPreview({
const offset = this.previewHelper.renderDragPreview({
blockIds: source.data?.bsEntity?.modelIds,
snapshot,
container,
mode: fromMode ?? 'block',
});
setOffset({ x: 0, y: 0 });
setOffset(offset);
},
setDragData: () => {
const { fromMode, snapshot } = this._getDraggedSnapshot();
@@ -8,6 +8,7 @@
"include": ["./src"],
"references": [
{ "path": "../../blocks/block-callout" },
{ "path": "../../blocks/block-embed" },
{ "path": "../../blocks/block-list" },
{ "path": "../../blocks/block-note" },
{ "path": "../../blocks/block-paragraph" },
+5 -5
View File
@@ -144,7 +144,7 @@ export class Viewport {
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
this.setCenter(newCenterX, newCenterY);
this.setCenter(newCenterX, newCenterY, false);
this._width = width;
this._height = height;
this._left = left;
@@ -362,7 +362,7 @@ export class Viewport {
* @param centerY The new y coordinate of the center of the viewport.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setCenter(centerX: number, centerY: number, forceUpdate = false) {
setCenter(centerX: number, centerY: number, forceUpdate = true) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
}
@@ -405,7 +405,7 @@ export class Viewport {
newZoom: number,
newCenter = Vec.toVec(this.center),
smooth = false,
forceUpdate = smooth
forceUpdate = true
) {
// Force complete any pending resize operations if forceUpdate is true
if (forceUpdate && this._isResizing) {
@@ -445,7 +445,7 @@ export class Viewport {
bound: Bound,
padding: [number, number, number, number] = [0, 0, 0, 0],
smooth = false,
forceUpdate = smooth
forceUpdate = true
) {
let [pt, pr, pb, pl] = padding;
@@ -511,7 +511,7 @@ export class Viewport {
zoom: number,
focusPoint?: IPoint,
wheel = false,
forceUpdate = false
forceUpdate = true
) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
+3 -3
View File
@@ -71,13 +71,13 @@
"electron": "^35.0.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import-x": "^4.5.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^57.0.0",
"eslint-plugin-unicorn": "^58.0.0",
"happy-dom": "^17.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
@@ -92,7 +92,7 @@
"vite": "^6.0.3",
"vitest": "3.0.9"
},
"packageManager": "yarn@4.7.0",
"packageManager": "yarn@4.8.0",
"resolutions": {
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@^1",
"array-includes": "npm:@nolyfill/array-includes@^1",
+16 -15
View File
@@ -26,24 +26,24 @@
},
"dependencies": {
"@ai-sdk/google": "^1.1.19",
"@apollo/server": "^4.11.2",
"@apollo/server": "^4.11.3",
"@aws-sdk/client-s3": "^3.709.0",
"@fal-ai/serverless-client": "^0.15.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.20.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.4.1",
"@google-cloud/opentelemetry-resource-util": "^2.4.0",
"@nestjs-cls/transactional": "^2.4.4",
"@nestjs-cls/transactional-adapter-prisma": "^1.2.7",
"@nestjs/apollo": "^12.2.2",
"@nestjs/bullmq": "^10.2.3",
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/graphql": "^12.2.2",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/throttler": "6.4.0",
"@nestjs/websockets": "^10.4.15",
"@nestjs-cls/transactional": "^2.6.1",
"@nestjs-cls/transactional-adapter-prisma": "^1.2.19",
"@nestjs/apollo": "^13.0.4",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.12",
"@nestjs/core": "^11.0.12",
"@nestjs/graphql": "^13.0.4",
"@nestjs/platform-express": "^11.0.12",
"@nestjs/platform-socket.io": "^11.0.12",
"@nestjs/schedule": "^5.0.1",
"@nestjs/throttler": "^6.4.0",
"@nestjs/websockets": "^11.0.12",
"@node-rs/argon2": "^2.0.2",
"@node-rs/crc32": "^1.10.6",
"@opentelemetry/api": "^1.9.0",
@@ -73,7 +73,7 @@
"dotenv": "^16.4.7",
"eventemitter2": "^6.4.9",
"eventsource-parser": "^3.0.0",
"express": "^4.21.2",
"express": "^5.0.1",
"fast-xml-parser": "^5.0.0",
"get-stream": "^9.0.1",
"graphql": "^16.9.0",
@@ -120,7 +120,8 @@
"@faker-js/faker": "^9.6.0",
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.21",
"@types/express": "^5.0.1",
"@types/express-serve-static-core": "^5.0.6",
"@types/graphql-upload": "^17.0.0",
"@types/http-errors": "^2.0.4",
"@types/lodash-es": "^4.17.12",
@@ -37,7 +37,7 @@ export type ConfigDescriptor<T> = {
type ConfigDefineDescriptor<T> = {
desc: string;
default: T;
validate?: (value: T) => boolean;
validate?: (value: T) => z.SafeParseReturnType<T, T>;
shape?: z.ZodType<T>;
env?: string | [string, EnvConfigType];
link?: string;
@@ -158,7 +158,7 @@ function standardizeDescriptor<T>(
default: desc.default,
type,
validate: (value: T) => {
return shape.safeParse(value);
return desc.validate ? desc.validate(value) : shape.safeParse(value);
},
env,
link: desc.link,
@@ -257,7 +257,15 @@ export function getDefaultConfig(): AppConfigSchema {
const { success, error } = desc.validate(defaultValue);
if (!success) {
throw error;
throw new Error(
error.issues
.map(issue => {
return `Invalid config for module [${module}] with key [${key}]
Value: ${JSON.stringify(defaultValue)}
Error: ${issue.message}`;
})
.join('\n')
);
}
set(modulizedConfig, key, defaultValue);
@@ -23,7 +23,7 @@ defineModuleConfig('redis', {
desc: 'The database index of redis server to be used(Must be less than 10).',
default: 0,
env: ['REDIS_SERVER_DATABASE', 'integer'],
validate: val => val >= 0 && val < 10,
shape: z.number().int().nonnegative().max(10),
},
host: {
desc: 'The host of the redis server.',
@@ -9,12 +9,12 @@ export interface ServerFlags {
declare global {
interface AppConfigSchema {
server: {
externalUrl: string;
externalUrl?: string;
https: boolean;
host: string;
port: number;
path: string;
name: string | undefined;
name?: string;
};
flags: ServerFlags;
}
@@ -29,9 +29,16 @@ defineModuleConfig('server', {
desc: `Base url of AFFiNE server, used for generating external urls.
Default to be \`[server.protocol]://[server.host][:server.port]\` if not specified.
`,
default: 'http://localhost:3010',
default: '',
env: 'AFFINE_SERVER_EXTERNAL_URL',
shape: z.string().url(),
validate: val => {
// allow to be nullable and empty string
if (!val) {
return { success: true, data: val };
}
return z.string().url().safeParse(val);
},
},
https: {
desc: 'Whether the server is hosted on a ssl enabled domain (https://).',
@@ -60,7 +60,7 @@ export class DocRendererController {
}
@Public()
@Get('/*')
@Get('/*path')
async render(@Req() req: Request, @Res() res: Response) {
const assets: HtmlAssets =
env.namespaces.canary &&
@@ -62,7 +62,7 @@ export class StaticFilesResolver implements OnModuleInit {
// fallback all unknown routes
app.get(
[basePath + '/admin', basePath + '/admin/*'],
[basePath + '/admin', basePath + '/admin/*path'],
this.check.use,
(_req, res) => {
res.sendFile(
@@ -101,11 +101,13 @@ export class StaticFilesResolver implements OnModuleInit {
redirect: false,
index: false,
fallthrough: true,
immutable: true,
dotfiles: 'ignore',
})
);
// fallback all unknown routes
app.get([basePath, basePath + '/*'], this.check.use, (req, res) => {
app.get([basePath, basePath + '/*path'], this.check.use, (req, res) => {
const mobile =
env.namespaces.canary &&
isMobile({
@@ -1,5 +1,6 @@
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
import { DocsService } from '@affine/core/modules/doc';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { JournalService } from '@affine/core/modules/journal';
@@ -24,14 +25,14 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
.catch(console.error);
});
events?.applicationMenu.openAboutPageInSettingModal(() => {
events?.applicationMenu.openInSettingModal(activeTab => {
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;
}
const { workspace } = currentWorkspace;
workspace.scope.get(WorkspaceDialogService).open('setting', {
activeTab: 'about',
activeTab: activeTab as unknown as SettingTab,
});
});
@@ -2,6 +2,7 @@ import type { DocProps } from '@affine/core/blocksuite/initialization';
import { DocsService } from '@affine/core/modules/doc';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
import { MeetingSettingsService } from '@affine/core/modules/media/services/meeting-settings';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { DebugLogger } from '@affine/debug';
import { apis, events } from '@affine/electron-api';
@@ -11,7 +12,7 @@ import { Text } from '@blocksuite/affine/store';
import type { BlobEngine } from '@blocksuite/affine/sync';
import type { FrameworkProvider } from '@toeverything/infra';
import { getCurrentWorkspace } from './utils';
import { getCurrentWorkspace, isAiEnabled } from './utils';
const logger = new DebugLogger('electron-renderer:recording');
@@ -34,6 +35,8 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') {
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
// maybe the workspace is not ready yet, eg. for shared workspace view
await apis?.recording.handleBlockCreationFailed(status.id);
return;
}
const { workspace } = currentWorkspace;
@@ -41,6 +44,7 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
frameworkProvider.get(EditorSettingService);
const docsService = workspace.scope.get(DocsService);
const editorSetting = editorSettingService.editorSetting;
const aiEnabled = isAiEnabled(frameworkProvider);
const timestamp = i18nTime(status.startTime, {
absolute: {
@@ -89,6 +93,19 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
model.props.sourceId = blobId;
model.props.embed = true;
const meetingSettingsService = frameworkProvider.get(
MeetingSettingsService
);
if (
!meetingSettingsService.settings.autoTranscription ||
!aiEnabled
) {
// auto transcription is disabled,
// so we don't need to transcribe the recording by default
return;
}
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;
@@ -100,8 +117,23 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
audioAttachment?.obj.transcribe().catch(err => {
logger.error('Failed to transcribe recording', err);
});
} else {
throw new Error('No attachment model found');
}
})().catch(console.error);
})()
.then(async () => {
await apis?.recording.handleBlockCreationSuccess(status.id);
})
.catch(error => {
logger.error('Failed to transcribe recording', error);
return apis?.recording.handleBlockCreationFailed(
status.id,
error
);
})
.catch(error => {
console.error('unknown error', error);
});
},
};
const page = docsService.createDoc({ docProps, primaryMode: 'page' });
@@ -1,3 +1,5 @@
import { ServersService } from '@affine/core/modules/cloud';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacesService } from '@affine/core/modules/workspace';
import type { FrameworkProvider } from '@toeverything/infra';
@@ -21,3 +23,23 @@ export function getCurrentWorkspace(frameworkProvider: FrameworkProvider) {
[Symbol.dispose]: dispose,
};
}
export function getCurrentServerService(frameworkProvider: FrameworkProvider) {
const currentServerId = frameworkProvider
.get(GlobalContextService)
.globalContext.serverId.get();
const serversService = frameworkProvider.get(ServersService);
const serverRef = currentServerId
? serversService.servers$.value.find(
server => server.id === currentServerId
)
: null;
return serverRef;
}
export function isAiEnabled(frameworkProvider: FrameworkProvider) {
const featureFlagService = frameworkProvider.get(FeatureFlagService);
const serverService = getCurrentServerService(frameworkProvider);
const aiConfig = serverService?.features$.value.copilot;
return featureFlagService.flags.enable_ai.$ && aiConfig;
}
@@ -1,100 +0,0 @@
import { ArrayBufferTarget, Muxer } from 'webm-muxer';
/**
* Encodes raw audio data to Opus in WebM container.
*/
export async function encodeRawBufferToOpus({
filepath,
sampleRate,
numberOfChannels,
}: {
filepath: string;
sampleRate: number;
numberOfChannels: number;
}): Promise<Uint8Array> {
// Use streams to process audio data incrementally
const response = await fetch(new URL(filepath, location.origin));
if (!response.body) {
throw new Error('Response body is null');
}
// Setup Opus encoder
const encodedChunks: EncodedAudioChunk[] = [];
const encoder = new AudioEncoder({
output: chunk => {
encodedChunks.push(chunk);
},
error: err => {
throw new Error(`Encoding error: ${err}`);
},
});
// Configure Opus encoder
encoder.configure({
codec: 'opus',
sampleRate: sampleRate,
numberOfChannels: numberOfChannels,
bitrate: 128000,
});
// Process the stream
const reader = response.body.getReader();
let offset = 0;
const CHUNK_SIZE = numberOfChannels * 1024; // Process 1024 samples per channel at a time
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Convert the chunk to Float32Array
const float32Data = new Float32Array(value.buffer);
// Process in smaller chunks to avoid large frames
for (let i = 0; i < float32Data.length; i += CHUNK_SIZE) {
const chunkSize = Math.min(CHUNK_SIZE, float32Data.length - i);
const chunk = float32Data.subarray(i, i + chunkSize);
// Create and encode frame
const frame = new AudioData({
format: 'f32',
sampleRate: sampleRate,
numberOfFrames: chunk.length / numberOfChannels,
numberOfChannels: numberOfChannels,
timestamp: (offset * 1000000) / sampleRate, // timestamp in microseconds
data: chunk,
});
encoder.encode(frame);
frame.close();
offset += chunk.length / numberOfChannels;
}
}
} finally {
await encoder.flush();
encoder.close();
}
// Initialize WebM muxer
const target = new ArrayBufferTarget();
const muxer = new Muxer({
target,
audio: {
codec: 'A_OPUS',
sampleRate: sampleRate,
numberOfChannels: numberOfChannels,
},
});
// Add all chunks to the muxer
for (const chunk of encodedChunks) {
muxer.addAudioChunk(chunk, {});
}
// Finalize and get WebM container
muxer.finalize();
const { buffer: webmBuffer } = target;
return new Uint8Array(webmBuffer);
}
@@ -1,19 +1,27 @@
import { Button } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { appIconMap } from '@affine/core/utils';
import { encodeRawBufferToOpus } from '@affine/core/utils/webm-encoding';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import { useEffect, useMemo, useState } from 'react';
import { encodeRawBufferToOpus } from './encode';
import * as styles from './styles.css';
type Status = {
id: number;
status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready';
status:
| 'new'
| 'recording'
| 'paused'
| 'stopped'
| 'ready'
| 'create-block-success'
| 'create-block-failed';
appName?: string;
appGroupId?: number;
icon?: Buffer;
filepath?: string;
};
export const useRecordingStatus = () => {
@@ -23,12 +31,12 @@ export const useRecordingStatus = () => {
// Get initial status
apis?.recording
.getCurrentRecording()
.then(status => setStatus(status as Status))
.then(status => setStatus(status satisfies Status | null))
.catch(console.error);
// Subscribe to status changes
const unsubscribe = events?.recording.onRecordingStatusChanged(status =>
setStatus(status as Status)
setStatus(status satisfies Status | null)
);
return () => {
@@ -51,15 +59,24 @@ export function Recording() {
}
if (status.status === 'new') {
return t['com.affine.recording.new']();
} else if (status.status === 'ready') {
return t['com.affine.recording.ready']();
} else if (status.appName) {
return t['com.affine.recording.recording']({
appName: status.appName,
});
} else {
return t['com.affine.recording.recording.unnamed']();
} else if (status.status === 'create-block-success') {
return t['com.affine.recording.success.prompt']();
} else if (status.status === 'create-block-failed') {
return t['com.affine.recording.failed.prompt']();
} else if (
status.status === 'recording' ||
status.status === 'ready' ||
status.status === 'stopped'
) {
if (status.appName) {
return t['com.affine.recording.recording']({
appName: status.appName,
});
} else {
return t['com.affine.recording.recording.unnamed']();
}
}
return null;
}, [status, t]);
const handleDismiss = useAsyncCallback(async () => {
@@ -77,6 +94,7 @@ export function Recording() {
let id: number | undefined;
try {
const result = await apis?.recording?.getCurrentRecording();
if (!result) {
return;
}
@@ -96,7 +114,7 @@ export function Recording() {
new Promise<void>(resolve => {
setTimeout(() => {
resolve();
}, 1000); // wait at least 1 second for better user experience
}, 500); // wait at least 500ms for better user experience
}),
]);
await apis?.recording.readyRecording(result.id, buffer);
@@ -125,6 +143,13 @@ export function Recording() {
await apis?.recording?.startRecording(status.appGroupId);
}, [status]);
const handleOpenFile = useAsyncCallback(async () => {
if (!status) {
return;
}
await apis?.recording?.showSavedRecordings(status.filepath);
}, [status]);
const controlsElement = useMemo(() => {
if (!status) {
return null;
@@ -150,7 +175,7 @@ export function Recording() {
{t['com.affine.recording.stop']()}
</Button>
);
} else if (status.status === 'stopped') {
} else if (status.status === 'stopped' || status.status === 'ready') {
return (
<Button
variant="error"
@@ -159,15 +184,33 @@ export function Recording() {
disabled
/>
);
} else if (status.status === 'ready') {
} else if (status.status === 'create-block-success') {
return (
<Button variant="primary" onClick={handleDismiss}>
{t['com.affine.recording.ready']()}
{t['com.affine.recording.success.button']()}
</Button>
);
} else if (status.status === 'create-block-failed') {
return (
<>
<Button variant="plain" onClick={handleDismiss}>
{t['com.affine.recording.dismiss']()}
</Button>
<Button variant="error" onClick={handleOpenFile}>
{t['com.affine.recording.failed.button']()}
</Button>
</>
);
}
return null;
}, [handleDismiss, handleStartRecording, handleStopRecording, status, t]);
}, [
handleDismiss,
handleOpenFile,
handleStartRecording,
handleStopRecording,
status,
t,
]);
if (!status) {
return null;
@@ -169,6 +169,10 @@ export default {
],
executableName: productName,
asar: true,
extendInfo: {
NSAudioCaptureUsageDescription:
'Please allow access in order to capture audio from other apps by AFFiNE.',
},
},
makers,
plugins: [{ name: '@electron-forge/plugin-auto-unpack-natives', config: {} }],
@@ -39,7 +39,7 @@ export function createApplicationMenu() {
label: `About ${app.getName()}`,
click: async () => {
await showMainWindow();
applicationMenuSubjects.openAboutPageInSettingModal$.next();
applicationMenuSubjects.openInSettingModal$.next('about');
},
},
{ type: 'separator' },
@@ -17,9 +17,9 @@ export const applicationMenuEvents = {
sub.unsubscribe();
};
},
openAboutPageInSettingModal: (fn: () => void) => {
const sub =
applicationMenuSubjects.openAboutPageInSettingModal$.subscribe(fn);
// todo: properly define the active tab type
openInSettingModal: (fn: (activeTab: string) => void) => {
const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
return () => {
sub.unsubscribe();
};
@@ -3,5 +3,5 @@ import { Subject } from 'rxjs';
export const applicationMenuSubjects = {
newPageAction$: new Subject<'page' | 'edgeless'>(),
openJournal$: new Subject<void>(),
openAboutPageInSettingModal$: new Subject<void>(),
openInSettingModal$: new Subject<string>(),
};
@@ -14,7 +14,7 @@ import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { logger } from './logger';
import { registerProtocol } from './protocol';
import { setupRecording } from './recording';
import { setupRecordingFeature } from './recording/feature';
import { setupTrayState } from './tray';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
@@ -89,18 +89,10 @@ app
.then(launch)
.then(createApplicationMenu)
.then(registerUpdater)
.then(setupRecordingFeature)
.then(setupTrayState)
.catch(e => console.error('Failed create window:', e));
if (isDev) {
app
.whenReady()
.then(setupRecording)
.then(setupTrayState)
.catch(e => {
logger.error('Failed setup recording or tray state:', e);
});
}
if (process.env.SENTRY_RELEASE) {
// https://docs.sentry.io/platforms/javascript/guides/electron/
Sentry.init({
@@ -0,0 +1,703 @@
/* oxlint-disable no-var-requires */
import { execSync } from 'node:child_process';
import path from 'node:path';
// Should not load @affine/native for unsupported platforms
import type { ShareableContent } from '@affine/native';
import { app, systemPreferences } from 'electron';
import fs from 'fs-extra';
import { debounce } from 'lodash-es';
import {
BehaviorSubject,
distinctUntilChanged,
groupBy,
interval,
mergeMap,
Subject,
throttleTime,
} from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { isMacOS, shallowEqual } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
import {
MeetingSettingsKey,
MeetingSettingsSchema,
} from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { recordingStateMachine } from './state-machine';
import type {
AppGroupInfo,
Recording,
RecordingStatus,
TappableAppInfo,
} from './types';
const MAX_DURATION_FOR_TRANSCRIPTION = 1.5 * 60 * 60 * 1000; // 1.5 hours
export const MeetingsSettingsState = {
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
map(v => MeetingSettingsSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MeetingSettingsSchema.parse(
globalStateStorage.get(MeetingSettingsKey) ?? {}
);
},
set value(value: MeetingSettingsSchema) {
globalStateStorage.set(MeetingSettingsKey, value);
},
};
const subscribers: Subscriber[] = [];
// recordings are saved in the app data directory
// may need a way to clean up old recordings
export const SAVED_RECORDINGS_DIR = path.join(
app.getPath('sessionData'),
'recordings'
);
let shareableContent: ShareableContent | null = null;
function cleanup() {
shareableContent = null;
subscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
}
beforeAppQuit(() => {
cleanup();
});
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
export const updateApplicationsPing$ = new Subject<number>();
// recording id -> recording
// recordings will be saved in memory before consumed and created as an audio block to user's doc
const recordings = new Map<number, Recording>();
// there should be only one active recording at a time
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
export const recordingStatus$ = recordingStateMachine.status$;
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
const groupProcess =
shareableContent?.applicationWithProcessId(processGroupId);
if (!groupProcess) {
return;
}
return {
processGroupId: processGroupId,
apps: [], // leave it empty for now.
name: groupProcess.name,
bundleIdentifier: groupProcess.bundleIdentifier,
// icon should be lazy loaded
get icon() {
try {
return groupProcess.icon;
} catch (error) {
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
return undefined;
}
},
isRunning: false,
};
}
// pipe applications$ to appGroups$
function setupAppGroups() {
subscribers.push(
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
const appGroups: AppGroupInfo[] = [];
apps.forEach(app => {
let appGroup = appGroups.find(
group => group.processGroupId === app.processGroupId
);
if (!appGroup) {
appGroup = createAppGroup(app.processGroupId);
if (appGroup) {
appGroups.push(appGroup);
}
}
if (appGroup) {
appGroup.apps.push(app);
}
});
appGroups.forEach(appGroup => {
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
});
appGroups$.next(appGroups);
})
);
}
function setupNewRunningAppGroup() {
const appGroupRunningChanged$ = appGroups$.pipe(
mergeMap(groups => groups),
groupBy(group => group.processGroupId),
mergeMap(groupStream$ =>
groupStream$.pipe(
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
)
)
);
appGroups$.value.forEach(group => {
const recordingStatus = recordingStatus$.value;
if (
group.isRunning &&
(!recordingStatus || recordingStatus.status === 'new')
) {
newRecording(group);
}
});
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
// check if the app is running again
if (appGroup.isRunning) {
startRecording(appGroup);
}
}, 1000);
subscribers.push(
appGroupRunningChanged$.subscribe(currentGroup => {
logger.info(
'appGroupRunningChanged',
currentGroup.bundleIdentifier,
currentGroup.isRunning
);
if (MeetingsSettingsState.value.recordingMode === 'none') {
return;
}
const recordingStatus = recordingStatus$.value;
if (currentGroup.isRunning) {
// when the app is running and there is no active recording popup
// we should show a new recording popup
if (
!recordingStatus ||
recordingStatus.status === 'new' ||
recordingStatus.status === 'create-block-success' ||
recordingStatus.status === 'create-block-failed'
) {
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
newRecording(currentGroup);
} else if (
MeetingsSettingsState.value.recordingMode === 'auto-start'
) {
// there is a case that the watched app's running state changed rapidly
// we will schedule the start recording to avoid that
debounceStartRecording(currentGroup);
} else {
// do nothing, skip
}
}
} else {
// when displaying in "new" state but the app is not running any more
// we should remove the recording
if (
recordingStatus?.status === 'new' &&
currentGroup.bundleIdentifier ===
recordingStatus.appGroup?.bundleIdentifier
) {
removeRecording(recordingStatus.id);
}
// if the recording is stopped and we are recording it,
// we should stop the recording
if (
recordingStatus?.status === 'recording' &&
recordingStatus.appGroup?.bundleIdentifier ===
currentGroup.bundleIdentifier
) {
stopRecording(recordingStatus.id).catch(err => {
logger.error('failed to stop recording', err);
});
}
}
})
);
}
function createRecording(status: RecordingStatus) {
const bufferedFilePath = path.join(
SAVED_RECORDINGS_DIR,
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
);
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const file = fs.createWriteStream(bufferedFilePath);
function tapAudioSamples(err: Error | null, samples: Float32Array) {
const recordingStatus = recordingStatus$.getValue();
if (
!recordingStatus ||
recordingStatus.id !== status.id ||
recordingStatus.status === 'paused'
) {
return;
}
if (err) {
logger.error('failed to get audio samples', err);
} else {
// Writing raw Float32Array samples directly to file
// For stereo audio, samples are interleaved [L,R,L,R,...]
file.write(Buffer.from(samples.buffer));
}
}
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const ShareableContent = require('@affine/native').ShareableContent;
const stream = status.app
? status.app.rawInstance.tapAudio(tapAudioSamples)
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
const recording: Recording = {
id: status.id,
startTime: status.startTime,
app: status.app,
appGroup: status.appGroup,
file,
stream,
};
return recording;
}
export async function getRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
logger.error(`Recording ${id} not found`);
return;
}
const rawFilePath = String(recording.file.path);
return {
id,
appGroup: recording.appGroup,
app: recording.app,
startTime: recording.startTime,
filepath: rawFilePath,
sampleRate: recording.stream.sampleRate,
numberOfChannels: recording.stream.channels,
};
}
// recording popup status
// new: recording is started, popup is shown
// recording: recording is started, popup is shown
// stopped: recording is stopped, popup showing processing status
// create-block-success: recording is ready, show "open app" button
// create-block-failed: recording is failed, show "failed to save" button
// null: hide popup
function setupRecordingListeners() {
subscribers.push(
recordingStatus$
.pipe(distinctUntilChanged(shallowEqual))
.subscribe(status => {
const popup = popupManager.get('recording');
if (status && !popup.showing) {
popup.show().catch(err => {
logger.error('failed to show recording popup', err);
});
}
if (status?.status === 'recording') {
let recording = recordings.get(status.id);
// create a recording if not exists
if (!recording) {
recording = createRecording(status);
recordings.set(status.id, recording);
}
} else if (status?.status === 'stopped') {
const recording = recordings.get(status.id);
if (recording) {
recording.stream.stop();
}
} else if (
status?.status === 'create-block-success' ||
status?.status === 'create-block-failed'
) {
// show the popup for 10s
setTimeout(() => {
// check again if current status is still ready
if (
(recordingStatus$.value?.status === 'create-block-success' ||
recordingStatus$.value?.status === 'create-block-failed') &&
recordingStatus$.value.id === status.id
) {
popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
}
}, 10_000);
} else if (!status) {
// status is removed, we should hide the popup
popupManager
.get('recording')
.hide()
.catch(err => {
logger.error('failed to hide recording popup', err);
});
}
})
);
}
function getAllApps(): TappableAppInfo[] {
if (!shareableContent) {
return [];
}
const apps = shareableContent.applications().map(app => {
try {
return {
rawInstance: app,
processId: app.processId,
processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier,
name: app.name,
isRunning: app.isRunning,
};
} catch (error) {
logger.error('failed to get app info', error);
return null;
}
});
const filteredApps = apps.filter(
(v): v is TappableAppInfo =>
v !== null &&
!v.bundleIdentifier.startsWith('com.apple') &&
!v.bundleIdentifier.startsWith('pro.affine') &&
v.processId !== process.pid
);
return filteredApps;
}
type Subscriber = {
unsubscribe: () => void;
};
function setupMediaListeners() {
const ShareableContent = require('@affine/native').ShareableContent;
applications$.next(getAllApps());
subscribers.push(
interval(3000).subscribe(() => {
updateApplicationsPing$.next(Date.now());
}),
ShareableContent.onApplicationListChanged(() => {
updateApplicationsPing$.next(Date.now());
}),
updateApplicationsPing$
.pipe(distinctUntilChanged(), throttleTime(3000))
.subscribe(() => {
applications$.next(getAllApps());
})
);
let appStateSubscribers: Subscriber[] = [];
subscribers.push(
applications$.subscribe(apps => {
appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
const _appStateSubscribers: Subscriber[] = [];
apps.forEach(app => {
try {
const tappableApp = app.rawInstance;
_appStateSubscribers.push(
ShareableContent.onAppStateChanged(tappableApp, () => {
updateApplicationsPing$.next(Date.now());
})
);
} catch (error) {
logger.error(
`Failed to convert app ${app.name} to TappableApplication`,
error
);
}
});
appStateSubscribers = _appStateSubscribers;
return () => {
_appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
};
})
);
}
// will be called when the app is ready or when the user has enabled the recording feature in settings
export function setupRecordingFeature() {
if (!MeetingsSettingsState.value.enabled || !checkRecordingAvailable()) {
return;
}
try {
const ShareableContent = require('@affine/native').ShareableContent;
if (!shareableContent) {
shareableContent = new ShareableContent();
setupMediaListeners();
}
// reset all states
recordingStatus$.next(null);
setupAppGroups();
setupNewRunningAppGroup();
setupRecordingListeners();
return true;
} catch (error) {
logger.error('failed to setup recording feature', error);
return false;
}
}
export function disableRecordingFeature() {
recordingStatus$.next(null);
cleanup();
}
function normalizeAppGroupInfo(
appGroup?: AppGroupInfo | number
): AppGroupInfo | undefined {
return typeof appGroup === 'number'
? appGroups$.value.find(group => group.processGroupId === appGroup)
: appGroup;
}
export function newRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
return recordingStateMachine.dispatch({
type: 'NEW_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
}
export function startRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
const state = recordingStateMachine.dispatch({
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
// set a timeout to stop the recording after MAX_DURATION_FOR_TRANSCRIPTION
setTimeout(() => {
if (
state?.status === 'recording' &&
state.id === recordingStatus$.value?.id
) {
stopRecording(state.id).catch(err => {
logger.error('failed to stop recording', err);
});
}
}, MAX_DURATION_FOR_TRANSCRIPTION);
return state;
}
export function pauseRecording(id: number) {
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
}
export function resumeRecording(id: number) {
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
}
export async function stopRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
logger.error(`Recording ${id} not found`);
return;
}
if (!recording.file.path) {
logger.error(`Recording ${id} has no file path`);
return;
}
const { file } = recording;
file.end();
// Wait for file to finish writing
try {
await new Promise<void>((resolve, reject) => {
file.on('finish', () => {
// check if the file is empty
const stats = fs.statSync(file.path);
if (stats.size === 0) {
logger.error(`Recording ${id} is empty`);
reject(new Error('Recording is empty'));
}
resolve();
});
});
const recordingStatus = recordingStateMachine.dispatch({
type: 'STOP_RECORDING',
id,
filepath: String(recording.file.path),
sampleRate: recording.stream.sampleRate,
numberOfChannels: recording.stream.channels,
});
if (!recordingStatus) {
logger.error('No recording status to stop');
return;
}
return serializeRecordingStatus(recordingStatus);
} catch (error: unknown) {
logger.error('Failed to stop recording', error);
const recordingStatus = recordingStateMachine.dispatch({
type: 'CREATE_BLOCK_FAILED',
id,
error: error instanceof Error ? error : undefined,
});
if (!recordingStatus) {
logger.error('No recording status to stop');
return;
}
return serializeRecordingStatus(recordingStatus);
}
}
export async function readyRecording(id: number, buffer: Buffer) {
const recordingStatus = recordingStatus$.value;
const recording = recordings.get(id);
if (!recordingStatus || recordingStatus.id !== id || !recording) {
logger.error(`Recording ${id} not found`);
return;
}
const filepath = path.join(
SAVED_RECORDINGS_DIR,
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm`
);
await fs.writeFile(filepath, buffer);
// Update the status through the state machine
recordingStateMachine.dispatch({
type: 'SAVE_RECORDING',
id,
filepath,
});
// bring up the window
getMainWindow()
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
}
})
.catch(err => {
logger.error('failed to bring up the window', err);
});
}
export async function handleBlockCreationSuccess(id: number) {
recordingStateMachine.dispatch({
type: 'CREATE_BLOCK_SUCCESS',
id,
});
}
export async function handleBlockCreationFailed(id: number, error?: Error) {
recordingStateMachine.dispatch({
type: 'CREATE_BLOCK_FAILED',
id,
error,
});
}
export function removeRecording(id: number) {
recordings.delete(id);
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
}
export interface SerializedRecordingStatus {
id: number;
status: RecordingStatus['status'];
appName?: string;
// if there is no app group, it means the recording is for system audio
appGroupId?: number;
icon?: Buffer;
startTime: number;
filepath?: string;
sampleRate?: number;
numberOfChannels?: number;
}
export function serializeRecordingStatus(
status: RecordingStatus
): SerializedRecordingStatus {
return {
id: status.id,
status: status.status,
appName: status.appGroup?.name,
appGroupId: status.appGroup?.processGroupId,
icon: status.appGroup?.icon,
startTime: status.startTime,
filepath: status.filepath,
sampleRate: status.sampleRate,
numberOfChannels: status.numberOfChannels,
};
}
export const getMacOSVersion = () => {
try {
const stdout = execSync('sw_vers -productVersion').toString();
const [major, minor, patch] = stdout.trim().split('.').map(Number);
return { major, minor, patch };
} catch (error) {
logger.error('Failed to get MacOS version', error);
return { major: 0, minor: 0, patch: 0 };
}
};
// check if the system is MacOS and the version is >= 14.2
export const checkRecordingAvailable = () => {
if (!isMacOS()) {
return false;
}
const version = getMacOSVersion();
return (version.major === 14 && version.minor >= 2) || version.major > 14;
};
export const checkScreenRecordingPermission = () => {
if (!isMacOS()) {
return false;
}
return systemPreferences.getMediaAccessStatus('screen') === 'granted';
};
@@ -1,546 +1,32 @@
// eslint-disable no-var-requires
// Should not load @affine/native for unsupported platforms
import path from 'node:path';
import { ShareableContent } from '@affine/native';
import { app } from 'electron';
import fs from 'fs-extra';
import {
BehaviorSubject,
distinctUntilChanged,
groupBy,
interval,
mergeMap,
Subject,
throttleTime,
} from 'rxjs';
import { shell } from 'electron';
import { isMacOS, shallowEqual } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
import { isMacOS } from '../../shared/utils';
import type { NamespaceHandlers } from '../type';
import { getMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { recordingStateMachine } from './state-machine';
import type {
AppGroupInfo,
Recording,
RecordingStatus,
TappableAppInfo,
} from './types';
const subscribers: Subscriber[] = [];
// adhoc recordings are saved in the temp directory
const SAVED_RECORDINGS_DIR = path.join(
app.getPath('temp'),
'affine-recordings'
);
beforeAppQuit(() => {
subscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
});
let shareableContent: ShareableContent | null = null;
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
export const updateApplicationsPing$ = new Subject<number>();
// recording id -> recording
// recordings will be saved in memory before consumed and created as an audio block to user's doc
const recordings = new Map<number, Recording>();
// there should be only one active recording at a time
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
export const recordingStatus$ = recordingStateMachine.status$;
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
const groupProcess =
shareableContent?.applicationWithProcessId(processGroupId);
if (!groupProcess) {
return;
}
return {
processGroupId: processGroupId,
apps: [], // leave it empty for now.
name: groupProcess.name,
bundleIdentifier: groupProcess.bundleIdentifier,
// icon should be lazy loaded
get icon() {
try {
return groupProcess.icon;
} catch (error) {
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
return undefined;
}
},
isRunning: false,
};
}
// pipe applications$ to appGroups$
function setupAppGroups() {
subscribers.push(
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
const appGroups: AppGroupInfo[] = [];
apps.forEach(app => {
let appGroup = appGroups.find(
group => group.processGroupId === app.processGroupId
);
if (!appGroup) {
appGroup = createAppGroup(app.processGroupId);
if (appGroup) {
appGroups.push(appGroup);
}
}
if (appGroup) {
appGroup.apps.push(app);
}
});
appGroups.forEach(appGroup => {
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
});
appGroups$.next(appGroups);
})
);
}
function setupNewRunningAppGroup() {
const appGroupRunningChanged$ = appGroups$.pipe(
mergeMap(groups => groups),
groupBy(group => group.processGroupId),
mergeMap(groupStream$ =>
groupStream$.pipe(
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
)
)
);
appGroups$.value.forEach(group => {
const recordingStatus = recordingStatus$.value;
if (
group.isRunning &&
(!recordingStatus || recordingStatus.status === 'new')
) {
newRecording(group);
}
});
subscribers.push(
appGroupRunningChanged$.subscribe(currentGroup => {
logger.info(
'appGroupRunningChanged',
currentGroup.bundleIdentifier,
currentGroup.isRunning
);
const recordingStatus = recordingStatus$.value;
if (currentGroup.isRunning) {
// when the app is running and there is no active recording popup
// we should show a new recording popup
if (
!recordingStatus ||
recordingStatus.status === 'new' ||
recordingStatus.status === 'ready'
) {
newRecording(currentGroup);
}
} else {
// when displaying in "new" state but the app is not running any more
// we should remove the recording
if (
recordingStatus?.status === 'new' &&
currentGroup.bundleIdentifier ===
recordingStatus.appGroup?.bundleIdentifier
) {
removeRecording(recordingStatus.id);
}
}
})
);
}
function createRecording(status: RecordingStatus) {
const bufferedFilePath = path.join(
SAVED_RECORDINGS_DIR,
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
);
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const file = fs.createWriteStream(bufferedFilePath);
function tapAudioSamples(err: Error | null, samples: Float32Array) {
const recordingStatus = recordingStatus$.getValue();
if (
!recordingStatus ||
recordingStatus.id !== status.id ||
recordingStatus.status === 'paused'
) {
return;
}
if (err) {
logger.error('failed to get audio samples', err);
} else {
// Writing raw Float32Array samples directly to file
// For stereo audio, samples are interleaved [L,R,L,R,...]
file.write(Buffer.from(samples.buffer));
}
}
const stream = status.app
? status.app.rawInstance.tapAudio(tapAudioSamples)
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
const recording: Recording = {
id: status.id,
startTime: status.startTime,
app: status.app,
appGroup: status.appGroup,
file,
stream,
};
return recording;
}
export async function getRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
logger.error(`Recording ${id} not found`);
return;
}
const rawFilePath = String(recording.file.path);
return {
id,
appGroup: recording.appGroup,
app: recording.app,
startTime: recording.startTime,
filepath: rawFilePath,
sampleRate: recording.stream.sampleRate,
numberOfChannels: recording.stream.channels,
};
}
// recording popup status
// new: recording is started, popup is shown
// recording: recording is started, popup is shown
// stopped: recording is stopped, popup showing processing status
// ready: recording is ready, show "open app" button
// null: hide popup
function setupRecordingListeners() {
subscribers.push(
recordingStatus$
.pipe(distinctUntilChanged(shallowEqual))
.subscribe(status => {
const popup = popupManager.get('recording');
if (status && !popup.showing) {
popup.show().catch(err => {
logger.error('failed to show recording popup', err);
});
}
if (status?.status === 'recording') {
let recording = recordings.get(status.id);
// create a recording if not exists
if (!recording) {
recording = createRecording(status);
recordings.set(status.id, recording);
}
} else if (status?.status === 'stopped') {
const recording = recordings.get(status.id);
if (recording) {
recording.stream.stop();
}
} else if (status?.status === 'ready') {
// show the popup for 10s
setTimeout(() => {
// check again if current status is still ready
if (
recordingStatus$.value?.status === 'ready' &&
recordingStatus$.value.id === status.id
) {
popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
}
}, 10_000);
} else if (!status) {
// status is removed, we should hide the popup
popupManager
.get('recording')
.hide()
.catch(err => {
logger.error('failed to hide recording popup', err);
});
}
})
);
}
function getAllApps(): TappableAppInfo[] {
if (!shareableContent) {
return [];
}
const apps = shareableContent.applications().map(app => {
try {
return {
rawInstance: app,
processId: app.processId,
processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier,
name: app.name,
isRunning: app.isRunning,
};
} catch (error) {
logger.error('failed to get app info', error);
return null;
}
});
const filteredApps = apps.filter(
(v): v is TappableAppInfo =>
v !== null &&
!v.bundleIdentifier.startsWith('com.apple') &&
v.processId !== process.pid
);
return filteredApps;
}
type Subscriber = {
unsubscribe: () => void;
};
function setupMediaListeners() {
applications$.next(getAllApps());
subscribers.push(
interval(3000).subscribe(() => {
updateApplicationsPing$.next(Date.now());
}),
ShareableContent.onApplicationListChanged(() => {
updateApplicationsPing$.next(Date.now());
}),
updateApplicationsPing$
.pipe(distinctUntilChanged(), throttleTime(3000))
.subscribe(() => {
applications$.next(getAllApps());
})
);
let appStateSubscribers: Subscriber[] = [];
subscribers.push(
applications$.subscribe(apps => {
appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
const _appStateSubscribers: Subscriber[] = [];
apps.forEach(app => {
try {
const tappableApp = app.rawInstance;
_appStateSubscribers.push(
ShareableContent.onAppStateChanged(tappableApp, () => {
updateApplicationsPing$.next(Date.now());
})
);
} catch (error) {
logger.error(
`Failed to convert app ${app.name} to TappableApplication`,
error
);
}
});
appStateSubscribers = _appStateSubscribers;
return () => {
_appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
};
})
);
}
export function setupRecording() {
if (!isMacOS()) {
return;
}
if (!shareableContent) {
try {
shareableContent = new ShareableContent();
setupMediaListeners();
} catch (error) {
logger.error('failed to get shareable content', error);
}
}
setupAppGroups();
setupNewRunningAppGroup();
setupRecordingListeners();
}
function normalizeAppGroupInfo(
appGroup?: AppGroupInfo | number
): AppGroupInfo | undefined {
return typeof appGroup === 'number'
? appGroups$.value.find(group => group.processGroupId === appGroup)
: appGroup;
}
export function newRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
if (!shareableContent) {
return null; // likely called on unsupported platform
}
return recordingStateMachine.dispatch({
type: 'NEW_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
}
export function startRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
return recordingStateMachine.dispatch({
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
}
export function pauseRecording(id: number) {
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
}
export function resumeRecording(id: number) {
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
}
export async function stopRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
logger.error(`Recording ${id} not found`);
return;
}
if (!recording.file.path) {
logger.error(`Recording ${id} has no file path`);
return;
}
const recordingStatus = recordingStateMachine.dispatch({
type: 'STOP_RECORDING',
id,
filepath: String(recording.file.path),
sampleRate: recording.stream.sampleRate,
numberOfChannels: recording.stream.channels,
});
if (!recordingStatus) {
logger.error('No recording status to stop');
return;
}
const { file } = recording;
file.end();
// Wait for file to finish writing
await new Promise<void>(resolve => {
file.on('finish', () => {
resolve();
});
});
return serializeRecordingStatus(recordingStatus);
}
export async function readyRecording(id: number, buffer: Buffer) {
const recordingStatus = recordingStatus$.value;
const recording = recordings.get(id);
if (!recordingStatus || recordingStatus.id !== id || !recording) {
logger.error(`Recording ${id} not found`);
return;
}
const filepath = path.join(
SAVED_RECORDINGS_DIR,
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm`
);
await fs.writeFile(filepath, buffer);
// Update the status through the state machine
recordingStateMachine.dispatch({
type: 'SAVE_RECORDING',
id,
filepath,
});
// bring up the window
getMainWindow()
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
}
})
.catch(err => {
logger.error('failed to bring up the window', err);
});
}
function removeRecording(id: number) {
recordings.delete(id);
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
}
export interface SerializedRecordingStatus {
id: number;
status: RecordingStatus['status'];
appName?: string;
// if there is no app group, it means the recording is for system audio
appGroupId?: number;
icon?: Buffer;
startTime: number;
filepath?: string;
sampleRate?: number;
numberOfChannels?: number;
}
function serializeRecordingStatus(
status: RecordingStatus
): SerializedRecordingStatus {
return {
id: status.id,
status: status.status,
appName: status.appGroup?.name,
appGroupId: status.appGroup?.processGroupId,
icon: status.appGroup?.icon,
startTime: status.startTime,
filepath: status.filepath,
sampleRate: status.sampleRate,
numberOfChannels: status.numberOfChannels,
};
}
import {
checkRecordingAvailable,
checkScreenRecordingPermission,
disableRecordingFeature,
getRecording,
handleBlockCreationFailed,
handleBlockCreationSuccess,
pauseRecording,
readyRecording,
recordingStatus$,
removeRecording,
SAVED_RECORDINGS_DIR,
type SerializedRecordingStatus,
serializeRecordingStatus,
setupRecordingFeature,
startRecording,
stopRecording,
} from './feature';
import type { AppGroupInfo } from './types';
export const recordingHandlers = {
getRecording: async (_, id: number) => {
@@ -565,9 +51,47 @@ export const recordingHandlers = {
readyRecording: async (_, id: number, buffer: Uint8Array) => {
return readyRecording(id, Buffer.from(buffer));
},
handleBlockCreationSuccess: async (_, id: number) => {
return handleBlockCreationSuccess(id);
},
handleBlockCreationFailed: async (_, id: number, error?: Error) => {
return handleBlockCreationFailed(id, error);
},
removeRecording: async (_, id: number) => {
return removeRecording(id);
},
checkRecordingAvailable: async () => {
return checkRecordingAvailable();
},
setupRecordingFeature: async () => {
return setupRecordingFeature();
},
disableRecordingFeature: async () => {
return disableRecordingFeature();
},
checkScreenRecordingPermission: async () => {
return checkScreenRecordingPermission();
},
showScreenRecordingPermissionSetting: async () => {
if (isMacOS()) {
return shell.openExternal(
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
);
}
// this only available on MacOS
return false;
},
showSavedRecordings: async (_, subpath?: string) => {
const normalizedDir = path.normalize(
path.join(SAVED_RECORDINGS_DIR, subpath ?? '')
);
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR);
if (!normalizedDir.startsWith(normalizedBase)) {
throw new Error('Invalid directory');
}
return shell.showItemInFolder(normalizedDir);
},
} satisfies NamespaceHandlers;
export const recordingEvents = {
@@ -4,17 +4,6 @@ import { shallowEqual } from '../../shared/utils';
import { logger } from '../logger';
import type { AppGroupInfo, RecordingStatus } from './types';
/**
* Possible states for a recording
*/
export type RecordingState =
| 'new'
| 'recording'
| 'paused'
| 'stopped'
| 'ready'
| 'inactive';
/**
* Recording state machine events
*/
@@ -35,6 +24,15 @@ export type RecordingEvent =
id: number;
filepath: string;
}
| {
type: 'CREATE_BLOCK_FAILED';
id: number;
error?: Error;
}
| {
type: 'CREATE_BLOCK_SUCCESS';
id: number;
}
| { type: 'REMOVE_RECORDING'; id: number };
/**
@@ -93,6 +91,12 @@ export class RecordingStateMachine {
case 'SAVE_RECORDING':
newStatus = this.handleSaveRecording(event.id, event.filepath);
break;
case 'CREATE_BLOCK_SUCCESS':
newStatus = this.handleCreateBlockSuccess(event.id);
break;
case 'CREATE_BLOCK_FAILED':
newStatus = this.handleCreateBlockFailed(event.id, event.error);
break;
case 'REMOVE_RECORDING':
this.handleRemoveRecording(event.id);
newStatus = currentStatus?.id === event.id ? null : currentStatus;
@@ -255,6 +259,47 @@ export class RecordingStateMachine {
};
}
/**
* Handle the CREATE_BLOCK_SUCCESS event
*/
private handleCreateBlockSuccess(id: number): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for create-block-success`);
return currentStatus;
}
return {
...currentStatus,
status: 'create-block-success',
};
}
/**
* Handle the CREATE_BLOCK_FAILED event
*/
private handleCreateBlockFailed(
id: number,
error?: Error
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for create-block-failed`);
return currentStatus;
}
if (error) {
logger.error(`Recording ${id} create block failed:`, error);
}
return {
...currentStatus,
status: 'create-block-failed',
};
}
/**
* Handle the REMOVE_RECORDING event
*/
@@ -39,7 +39,16 @@ export interface RecordingStatus {
// paused: the recording is paused
// stopped: the recording is stopped (processing audio file for use in the editor)
// ready: the recording is ready to be used
status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready';
// create-block-success: the recording is successfully created as a block
// create-block-failed: creating block failed
status:
| 'new'
| 'recording'
| 'paused'
| 'stopped'
| 'ready'
| 'create-block-success'
| 'create-block-failed';
app?: TappableAppInfo;
appGroup?: AppGroupInfo;
startTime: number; // 0 means not started yet
@@ -53,3 +53,21 @@ export const SpellCheckStateSchema = z.object({
export const SpellCheckStateKey = 'spellCheckState' as const;
// eslint-disable-next-line no-redeclare
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
export const MeetingSettingsKey = 'meetingSettings' as const;
export const MeetingSettingsSchema = z.object({
// global meeting feature control
enabled: z.boolean().default(false),
// when recording is saved, where to create the recording block
recordingSavingMode: z.enum(['new-doc', 'journal-today']).default('new-doc'),
// whether to enable auto transcription for new meeting recordings
autoTranscription: z.boolean().default(true),
// recording reactions to new meeting events
recordingMode: z.enum(['none', 'prompt', 'auto-start']).default('prompt'),
});
// eslint-disable-next-line no-redeclare
export type MeetingSettingsSchema = z.infer<typeof MeetingSettingsSchema>;
@@ -14,11 +14,14 @@ import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
import {
appGroups$,
checkRecordingAvailable,
checkScreenRecordingPermission,
MeetingsSettingsState,
recordingStatus$,
startRecording,
stopRecording,
updateApplicationsPing$,
} from '../recording';
} from '../recording/feature';
import { getMainWindow } from '../windows-manager';
import { icons } from './icons';
@@ -61,6 +64,10 @@ function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
}
if (nativeIcon) {
nativeIcon = nativeIcon.resize({ width: 20, height: 20 });
// string icon should be template image
if (typeof icon === 'string') {
nativeIcon.setTemplateImage(true);
}
}
const submenuConfig = submenu ? buildMenuConfig(submenu) : undefined;
menuConfig.push({
@@ -125,30 +132,37 @@ class TrayState {
};
}
getRecordingMenuProvider(): TrayMenuProvider {
const appGroups = appGroups$.value;
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
const recordingStatus = recordingStatus$.value;
getRecordingMenuProvider(): TrayMenuProvider | null {
if (
!recordingStatus ||
(recordingStatus?.status !== 'paused' &&
recordingStatus?.status !== 'recording')
!checkRecordingAvailable() ||
!checkScreenRecordingPermission() ||
!MeetingsSettingsState.value.enabled
) {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
);
startRecording(appGroup);
},
}));
return {
key: 'recording',
getConfig: () => [
return null;
}
const getConfig = () => {
const appGroups = appGroups$.value;
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
const recordingStatus = recordingStatus$.value;
if (
!recordingStatus ||
(recordingStatus?.status !== 'paused' &&
recordingStatus?.status !== 'recording')
) {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
);
startRecording(appGroup);
},
}));
return [
{
label: 'Start Recording Meeting',
icon: icons.record,
@@ -167,18 +181,22 @@ class TrayState {
],
},
...appMenuItems,
],
};
}
{
label: `Meetings Settings...`,
click: async () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('meetings');
},
},
];
}
const recordingLabel = recordingStatus.appGroup?.name
? `Recording (${recordingStatus.appGroup?.name})`
: 'Recording';
const recordingLabel = recordingStatus.appGroup?.name
? `Recording (${recordingStatus.appGroup?.name})`
: 'Recording';
// recording is either started or paused
return {
key: 'recording',
getConfig: () => [
// recording is either started or paused
return [
{
label: recordingLabel,
icon: icons.recording,
@@ -193,7 +211,12 @@ class TrayState {
});
},
},
],
];
};
return {
key: 'recording',
getConfig,
};
}
@@ -214,6 +237,13 @@ class TrayState {
});
},
},
{
label: `About ${app.getName()}`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('about');
},
},
'separator',
{
label: 'Quit AFFiNE Completely...',
@@ -267,7 +297,7 @@ class TrayState {
const providers = [
this.getPrimaryMenuProvider(),
isMacOS() ? this.getRecordingMenuProvider() : null,
this.getRecordingMenuProvider(),
this.getSecondaryMenuProvider(),
].filter(p => p !== null);
@@ -14,7 +14,7 @@ let package = Package(
.library(name: "AffineGraphQL", targets: ["AffineGraphQL"]),
],
dependencies: [
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.18.0"),
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.19.0"),
],
targets: [
.target(
@@ -16,7 +16,7 @@ let package = Package(
dependencies: [
.package(path: "../AffineGraphQL"),
.package(path: "../MarkdownView"),
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.18.0"),
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.19.0"),
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
.package(url: "https://github.com/apple/swift-collections", from: "1.1.4"),
.package(url: "https://github.com/Lakr233/ChidoriMenu", from: "2.4.3"),
@@ -14,7 +14,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/JohnSundell/Splash", from: "0.16.0"),
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.4.0"),
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.5.0"),
],
targets: [
.target(name: "MarkdownView", dependencies: [
+3
View File
@@ -46,9 +46,11 @@
"@radix-ui/react-visually-hidden": "^1.1.1",
"@toeverything/theme": "^1.1.12",
"@vanilla-extract/dynamic": "^2.1.2",
"bytes": "^3.1.2",
"check-password-strength": "^3.0.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"foxact": "^0.2.45",
"jotai": "^2.10.3",
"lit": "^3.2.1",
"lodash-es": "^4.17.21",
@@ -77,6 +79,7 @@
"@storybook/react-vite": "^8.4.7",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/bytes": "^3.1.5",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@vanilla-extract/css": "^1.17.0",
@@ -29,6 +29,10 @@ export const wrapper = style({
},
},
});
export const wrapperDisabled = style({
opacity: 0.5,
pointerEvents: 'none',
});
globalStyle(`${wrapper} .title`, {
fontSize: cssVar('fontSm'),
fontWeight: 600,
@@ -1,17 +1,20 @@
import clsx from 'clsx';
import type { PropsWithChildren, ReactNode } from 'react';
import { wrapper } from './share.css';
import { wrapper, wrapperDisabled } from './share.css';
interface SettingWrapperProps {
title?: ReactNode;
disabled?: boolean;
}
export const SettingWrapper = ({
title,
children,
disabled,
}: PropsWithChildren<SettingWrapperProps>) => {
return (
<div className={wrapper}>
<div className={clsx(wrapper, disabled && wrapperDisabled)}>
{title ? <div className="title">{title}</div> : null}
{children}
</div>
+2 -3
View File
@@ -1,6 +1,7 @@
export * from './hooks';
export * from './lit-react';
export * from './styles';
export * from './ui/audio-player';
export * from './ui/avatar';
export * from './ui/button';
export * from './ui/checkbox';
@@ -14,9 +15,7 @@ export * from './ui/error-message';
export * from './ui/input';
export * from './ui/layout';
export * from './ui/loading';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/lottie/folder-icon';
export * from './ui/lottie';
export * from './ui/masonry';
export * from './ui/menu';
export * from './ui/modal';
@@ -4,8 +4,9 @@ import React, { createElement, type ReactNode } from 'react';
import { createComponent } from './create-component';
export
@customElement('affine-lit-template-wrapper')
export class LitTemplateWrapper extends LitElement {
class LitTemplateWrapper extends LitElement {
static override get properties() {
return {
template: { type: Object },
@@ -83,7 +83,7 @@ export const controlButton = style({
justifyContent: 'center',
borderRadius: '50%',
backgroundColor: cssVarV2('layer/background/secondary'),
color: cssVarV2('text/primary'),
color: cssVarV2('icon/primary'),
});
export const controls = style({
@@ -0,0 +1,331 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AudioPlayer, MiniAudioPlayer } from './audio-player';
const AudioWrapper = () => {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [waveform, setWaveform] = useState<number[] | null>(null);
const [playbackState, setPlaybackState] = useState<
'idle' | 'playing' | 'paused' | 'stopped'
>('idle');
const [seekTime, setSeekTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loading, setLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const audioUrlRef = useRef<string | null>(null);
// Generate waveform data from audio file
const generateWaveform = async (audioBuffer: AudioBuffer) => {
const channelData = audioBuffer.getChannelData(0);
const samples = 1000;
const blockSize = Math.floor(channelData.length / samples);
const waveformData = [];
for (let i = 0; i < samples; i++) {
const start = i * blockSize;
const end = start + blockSize;
let sum = 0;
for (let j = start; j < end; j++) {
sum += Math.abs(channelData[j]);
}
waveformData.push(sum / blockSize);
}
// Normalize waveform data
const max = Math.max(...waveformData);
return waveformData.map(val => val / max);
};
const handleFileChange = useCallback(async (file: File) => {
setLoading(true);
setAudioFile(file);
setPlaybackState('idle');
setSeekTime(0);
setDuration(0);
setWaveform(null);
// Revoke previous URL if exists
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
}
// Create new URL for the audio file
const fileUrl = URL.createObjectURL(file);
audioUrlRef.current = fileUrl;
try {
const arrayBuffer = await file.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const waveformData = await generateWaveform(audioBuffer);
setWaveform(waveformData);
} catch (error) {
console.error('Error processing audio file:', error);
} finally {
setLoading(false);
}
}, []);
// Cleanup object URL when component unmounts
useEffect(() => {
return () => {
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
}
};
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/')) {
handleFileChange(file);
}
},
[handleFileChange]
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileChange(file);
}
},
[handleFileChange]
);
const handlePlay = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (audioRef.current) {
const playPromise = audioRef.current.play();
// Handle play promise to catch any errors
if (playPromise !== undefined) {
playPromise
.then(() => {
setPlaybackState('playing');
})
.catch(error => {
console.error('Error playing audio:', error);
setPlaybackState('paused');
});
}
}
}, []);
const handlePause = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (audioRef.current) {
audioRef.current.pause();
setPlaybackState('paused');
}
}, []);
const handleStop = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setPlaybackState('stopped');
setSeekTime(0);
}
}, []);
const handleSeek = useCallback(
(time: number) => {
if (audioRef.current) {
// Ensure time is within valid range
const clampedTime = Math.max(
0,
Math.min(time, audioRef.current.duration)
);
audioRef.current.currentTime = clampedTime;
if (playbackState === 'stopped') {
setPlaybackState('paused');
}
}
},
[playbackState]
);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !audioFile) return;
const updateTime = () => {
setSeekTime(audio.currentTime);
};
const updateDuration = () => {
if (!isNaN(audio.duration) && isFinite(audio.duration)) {
setDuration(audio.duration);
setPlaybackState('paused');
setLoading(false);
}
};
// Handle direct interaction with audio element controls
const handleNativeTimeUpdate = () => {
setSeekTime(audio.currentTime);
};
const handleNativePlay = () => {
setPlaybackState('playing');
};
const handleNativePause = () => {
if (audio.currentTime >= audio.duration - 0.1) {
setPlaybackState('stopped');
setSeekTime(0);
} else {
setPlaybackState('paused');
}
};
const handleEnded = () => {
setPlaybackState('stopped');
setSeekTime(0);
};
const handlePlaying = () => {
setPlaybackState('playing');
};
const handlePaused = () => {
if (audio.currentTime === 0) {
setPlaybackState('stopped');
} else {
setPlaybackState('paused');
}
};
const handleError = () => {
console.error('Audio playback error');
setPlaybackState('stopped');
setLoading(false);
};
const handleWaiting = () => {
setLoading(true);
};
const handleCanPlay = () => {
setLoading(false);
};
// Add all event listeners
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('seeking', handleNativeTimeUpdate);
audio.addEventListener('seeked', handleNativeTimeUpdate);
audio.addEventListener('play', handleNativePlay);
audio.addEventListener('pause', handleNativePause);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('durationchange', updateDuration);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('playing', handlePlaying);
audio.addEventListener('pause', handlePaused);
audio.addEventListener('error', handleError);
audio.addEventListener('waiting', handleWaiting);
audio.addEventListener('canplay', handleCanPlay);
return () => {
// Remove all event listeners
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('seeking', handleNativeTimeUpdate);
audio.removeEventListener('seeked', handleNativeTimeUpdate);
audio.removeEventListener('play', handleNativePlay);
audio.removeEventListener('pause', handleNativePause);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('durationchange', updateDuration);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('playing', handlePlaying);
audio.removeEventListener('pause', handlePaused);
audio.removeEventListener('error', handleError);
audio.removeEventListener('waiting', handleWaiting);
audio.removeEventListener('canplay', handleCanPlay);
};
}, [audioFile]);
return (
<div
style={{
width: '100%',
minHeight: '200px',
border: '2px dashed #ccc',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
gap: '20px',
}}
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
>
{!audioFile ? (
<>
<div>Drag & drop an audio file here, or</div>
<input
type="file"
accept="audio/*"
onChange={handleFileSelect}
style={{ maxWidth: '200px' }}
/>
</>
) : (
<>
<audio
ref={audioRef}
src={audioUrlRef.current || ''}
preload="metadata"
controls
style={{ width: '100%', maxWidth: '600px' }}
/>
<MiniAudioPlayer
name={audioFile.name}
size={audioFile.size}
waveform={waveform}
playbackState={playbackState}
seekTime={seekTime}
duration={duration}
loading={loading}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
/>
<AudioPlayer
name={audioFile.name}
size={audioFile.size}
waveform={waveform}
playbackState={playbackState}
seekTime={seekTime}
duration={duration}
loading={loading}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
/>
</>
)}
</div>
);
};
const meta: Meta<typeof AudioWrapper> = {
title: 'UI/AudioPlayer',
component: AudioWrapper,
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof AudioWrapper>;
export const Default: Story = {};
@@ -1,4 +1,3 @@
import { IconButton } from '@affine/component';
import {
AddThirtySecondIcon,
CloseIcon,
@@ -9,9 +8,10 @@ import bytes from 'bytes';
import { clamp } from 'lodash-es';
import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
import { IconButton } from '../button';
import { AnimatedPlayIcon } from '../lottie';
import * as styles from './audio-player.css';
import { AudioWaveform } from './audio-waveform';
import { AnimatedPlayIcon } from './lottie/animated-play-icon';
// Format seconds to mm:ss
const formatTime = (seconds: number): string => {
@@ -0,0 +1,2 @@
export * from './audio-player';
export * from './audio-waveform';
@@ -0,0 +1,3 @@
# Sortable
Migrated from https://github.com/clauderic/dnd-kit
@@ -0,0 +1,123 @@
import type { ElementDragType } from '@atlaskit/pragmatic-drag-and-drop/types';
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { rectSortingStrategy } from './strategies';
import type {
ClientRect,
Disabled,
SortingStrategy,
UniqueIdentifier,
} from './types';
import {
getSortedRects,
itemsEqual,
normalizeDisabled,
useUniqueId,
} from './utilities';
export interface Props {
children: React.ReactNode;
items: (UniqueIdentifier | { id: UniqueIdentifier })[];
strategy?: SortingStrategy;
disabled?: boolean | Disabled;
}
const ID_PREFIX = 'Sortable';
interface ContextDescriptor {
activeIndex: number;
containerId: string;
disableTransforms: boolean;
items: {
id: UniqueIdentifier;
}[];
overIndex: number;
sortedRects: ClientRect[];
strategy: SortingStrategy;
disabled: Disabled;
}
export const Context = React.createContext<ContextDescriptor>({
activeIndex: -1,
containerId: ID_PREFIX,
disableTransforms: false,
items: [],
overIndex: -1,
sortedRects: [],
strategy: rectSortingStrategy,
disabled: {
draggable: false,
droppable: false,
},
});
export function SortableContext({
children,
items: userDefinedItems,
strategy = rectSortingStrategy,
disabled: disabledProp = false,
}: Props) {
const [active, setActive] = useState<ElementDragType | null>(null);
const { active, droppableRects, over, measureDroppableContainers } =
useDndContext();
const containerId = useUniqueId(ID_PREFIX, id);
const items = useMemo<UniqueIdentifier[]>(
() =>
userDefinedItems.map(item =>
typeof item === 'object' && 'id' in item ? item.id : item
),
[userDefinedItems]
);
const isDragging = active != null;
const activeIndex = active ? items.indexOf(active.id) : -1;
const overIndex = over ? items.indexOf(over.id) : -1;
const previousItemsRef = useRef(items);
const itemsHaveChanged = !itemsEqual(items, previousItemsRef.current);
const disableTransforms =
(overIndex !== -1 && activeIndex === -1) || itemsHaveChanged;
const disabled = normalizeDisabled(disabledProp);
useLayoutEffect(() => {
if (itemsHaveChanged && isDragging) {
measureDroppableContainers(items);
}
}, [itemsHaveChanged, items, isDragging, measureDroppableContainers]);
useEffect(() => {
previousItemsRef.current = items;
}, [items]);
const contextValue = useMemo(
(): ContextDescriptor => ({
activeIndex,
containerId,
disabled,
disableTransforms,
items,
overIndex,
sortedRects: getSortedRects(items, droppableRects),
strategy,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeIndex,
containerId,
disabled.draggable,
disabled.droppable,
disableTransforms,
items,
overIndex,
droppableRects,
strategy,
]
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
@@ -0,0 +1,25 @@
import type { SortingStrategy } from './types';
import { arrayMove } from './utilities';
export const rectSortingStrategy: SortingStrategy = ({
rects,
activeIndex,
overIndex,
index,
}) => {
const newRects = arrayMove(rects, overIndex, activeIndex);
const oldRect = rects[index];
const newRect = newRects[index];
if (!newRect || !oldRect) {
return null;
}
return {
x: newRect.left - oldRect.left,
y: newRect.top - oldRect.top,
scaleX: newRect.width / oldRect.width,
scaleY: newRect.height / oldRect.height,
};
};
@@ -0,0 +1,32 @@
export type Transform = {
x: number;
y: number;
scaleX: number;
scaleY: number;
};
export interface ClientRect {
width: number;
height: number;
top: number;
left: number;
right: number;
bottom: number;
}
export type SortingStrategy = (args: {
activeNodeRect: ClientRect | null;
activeIndex: number;
index: number;
rects: ClientRect[];
overIndex: number;
}) => Transform | null;
export type UniqueIdentifier = string | number;
export type RectMap = Map<UniqueIdentifier, ClientRect>;
export interface Disabled {
draggable?: boolean;
droppable?: boolean;
}
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import type { ClientRect, Disabled, RectMap, UniqueIdentifier } from './types';
let ids: Record<string, number> = {};
export function useUniqueId(prefix: string, value?: string) {
return useMemo(() => {
if (value) {
return value;
}
const id = ids[prefix] == null ? 0 : ids[prefix] + 1;
ids[prefix] = id;
return `${prefix}-${id}`;
}, [prefix, value]);
}
/**
* Move an array item to a different position. Returns a new array with the item moved to the new position.
*/
export function arrayMove<T>(array: T[], from: number, to: number): T[] {
const newArray = array.slice();
newArray.splice(
to < 0 ? newArray.length + to : to,
0,
newArray.splice(from, 1)[0]
);
return newArray;
}
export function getSortedRects(items: UniqueIdentifier[], rects: RectMap) {
return items.reduce<ClientRect[]>(
(accumulator, id, index) => {
const rect = rects.get(id);
if (rect) {
accumulator[index] = rect;
}
return accumulator;
},
Array.from({ length: items.length })
);
}
export function itemsEqual(a: UniqueIdentifier[], b: UniqueIdentifier[]) {
if (a === b) {
return true;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
export function normalizeDisabled(disabled: boolean | Disabled): Disabled {
if (typeof disabled === 'boolean') {
return {
draggable: disabled,
droppable: disabled,
};
}
return disabled;
}
@@ -0,0 +1,62 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { AnimatedPlayIcon } from './animated-play-icon';
export default {
title: 'UI/Audio Player/Animated Play Icon',
component: AnimatedPlayIcon,
parameters: {
docs: {
description: {
component:
'An animated icon that transitions between play, pause, and loading states.',
},
},
},
} satisfies Meta<typeof AnimatedPlayIcon>;
const Template: StoryFn<typeof AnimatedPlayIcon> = args => (
<AnimatedPlayIcon {...args} />
);
export const Play = Template.bind({});
Play.args = {
state: 'play',
};
export const Pause = Template.bind({});
Pause.args = {
state: 'pause',
};
export const Loading = Template.bind({});
Loading.args = {
state: 'loading',
};
export const WithStateToggle: StoryFn<typeof AnimatedPlayIcon> = () => {
const [state, setState] = useState<'play' | 'pause' | 'loading'>('play');
const cycleState = () => {
setState(current => {
switch (current) {
case 'play':
return 'pause';
case 'pause':
return 'play';
case 'loading':
return 'play';
default:
return 'play';
}
});
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<AnimatedPlayIcon state={state} />
<button onClick={cycleState}>Toggle State (Current: {state})</button>
</div>
);
};
@@ -0,0 +1,71 @@
import clsx from 'clsx';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import type { LottieRef } from 'lottie-react';
import Lottie from 'lottie-react';
import { useEffect, useRef } from 'react';
import { Loading } from '../loading';
import playandpause from './playandpause.json';
import * as styles from './styles.css';
export interface AnimatedPlayIconProps {
state: 'play' | 'pause' | 'loading';
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
const PlayAndPauseIcon = ({
onClick,
className,
state,
}: {
onClick?: (e: React.MouseEvent) => void;
className?: string;
state: 'play' | 'pause';
}) => {
const lottieRef: LottieRef = useRef(null);
const prevStateRef = useRef(state);
useEffect(() => {
if (!lottieRef.current) return;
const lottie = lottieRef.current;
lottie.setSpeed(2);
// Only animate if state actually changed
if (prevStateRef.current !== state) {
if (state === 'play') {
// Animate from pause to play
lottie.playSegments([120, 160], true);
} else {
// Animate from play to pause
lottie.playSegments([60, 100], true);
}
prevStateRef.current = state;
}
}, [state]);
return (
<Lottie
onClick={onClick}
lottieRef={lottieRef}
className={clsx(styles.root, className)}
animationData={playandpause}
loop={false}
autoplay={false}
/>
);
};
export const AnimatedPlayIcon = ({
state: _state,
className,
onClick,
}: AnimatedPlayIconProps) => {
const state = useDebouncedValue(_state, 25);
if (state === 'loading') {
return <Loading size={40} />;
}
return (
<PlayAndPauseIcon state={state} onClick={onClick} className={className} />
);
};
@@ -0,0 +1,46 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { AnimatedTranscribeIcon } from './animated-transcribe-icon';
export default {
title: 'UI/Audio Player/Animated Transcribe Icon',
component: AnimatedTranscribeIcon,
parameters: {
docs: {
description: {
component:
'An animated icon that shows transcription state with smooth transitions.',
},
},
},
} satisfies Meta<typeof AnimatedTranscribeIcon>;
const Template: StoryFn<typeof AnimatedTranscribeIcon> = args => (
<AnimatedTranscribeIcon {...args} />
);
export const Idle = Template.bind({});
Idle.args = {
state: 'idle',
};
export const Transcribing = Template.bind({});
Transcribing.args = {
state: 'transcribing',
};
export const WithStateToggle: StoryFn<typeof AnimatedTranscribeIcon> = () => {
const [state, setState] = useState<'idle' | 'transcribing'>('idle');
const toggleState = () => {
setState(current => (current === 'idle' ? 'transcribing' : 'idle'));
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<AnimatedTranscribeIcon state={state} />
<button onClick={toggleState}>Toggle State (Current: {state})</button>
</div>
);
};
@@ -0,0 +1,5 @@
export * from './animated-play-icon';
export * from './animated-transcribe-icon';
export * from './collections-icon';
export * from './delete-icon';
export * from './folder-icon';
@@ -1,8 +1,8 @@
{
"v": "5.12.1",
"fr": 60,
"ip": 60,
"op": 103,
"ip": 0,
"op": 161,
"w": 40,
"h": 40,
"nm": "pause to play",
@@ -12,8 +12,160 @@
{
"ddd": 0,
"ind": 1,
"ty": 3,
"nm": "Void::Icon (Stroke)",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [21.125, 20, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"ef": [
{
"ty": 5,
"nm": "Void",
"np": 19,
"mn": "Pseudo/250958",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 0,
"nm": "Width",
"mn": "Pseudo/250958-0001",
"ix": 1,
"v": { "a": 0, "k": 100, "ix": 1 }
},
{
"ty": 0,
"nm": "Height",
"mn": "Pseudo/250958-0002",
"ix": 2,
"v": { "a": 0, "k": 100, "ix": 2 }
},
{
"ty": 0,
"nm": "Offset X",
"mn": "Pseudo/250958-0003",
"ix": 3,
"v": { "a": 0, "k": 0, "ix": 3 }
},
{
"ty": 0,
"nm": "Offset Y",
"mn": "Pseudo/250958-0004",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Roundness",
"mn": "Pseudo/250958-0005",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 6,
"nm": "About",
"mn": "Pseudo/250958-0006",
"ix": 6,
"v": 0
},
{
"ty": 6,
"nm": "Plague of null layers.",
"mn": "Pseudo/250958-0007",
"ix": 7,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0008",
"ix": 8,
"v": 0
},
{
"ty": 6,
"nm": "Following projects",
"mn": "Pseudo/250958-0009",
"ix": 9,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0010",
"ix": 10,
"v": 0
},
{
"ty": 6,
"nm": "through time.",
"mn": "Pseudo/250958-0011",
"ix": 11,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0012",
"ix": 12,
"v": 0
},
{
"ty": 6,
"nm": "Be free of the past.",
"mn": "Pseudo/250958-0013",
"ix": 13,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0014",
"ix": 14,
"v": 0
},
{
"ty": 6,
"nm": "Copyright 2023 Battle Axe Inc",
"mn": "Pseudo/250958-0015",
"ix": 15,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0016",
"ix": 16,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0017",
"ix": 17,
"v": 0
}
]
}
],
"ip": 0,
"op": 5400,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "Icon (Stroke)",
"parent": 1,
"sr": 1,
"ks": {
"o": {
@@ -42,7 +194,7 @@
"ix": 11
},
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
"p": { "a": 0, "k": [0, 0, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": {
"a": 1,
@@ -54,7 +206,7 @@
"s": [100, 100, 100]
},
{
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 1] },
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
"t": 90,
"s": [32, 32, 100]
@@ -67,11 +219,11 @@
},
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
"t": 143,
"s": [115, 115, 100]
},
{ "t": 159, "s": [100, 100, 100] }
{ "t": 160, "s": [100, 100, 100] }
],
"ix": 6,
"l": 2
@@ -200,7 +352,7 @@
},
{
"ddd": 0,
"ind": 2,
"ind": 3,
"ty": 4,
"nm": "Union",
"sr": 1,
@@ -244,14 +396,14 @@
},
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
"t": 83,
"s": [115, 115, 100]
},
{
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
"t": 99,
"t": 100,
"s": [100, 100, 100]
},
{
@@ -406,9 +558,9 @@
},
{
"ddd": 0,
"ind": 3,
"ind": 4,
"ty": 4,
"nm": "形状图层 3",
"nm": "wave",
"sr": 1,
"ks": {
"o": {
@@ -516,9 +668,9 @@
},
{
"ddd": 0,
"ind": 4,
"ind": 5,
"ty": 4,
"nm": "形状图层 2",
"nm": "wave",
"sr": 1,
"ks": {
"o": {
@@ -626,9 +778,9 @@
},
{
"ddd": 0,
"ind": 5,
"ind": 6,
"ty": 4,
"nm": "形状图层 1",
"nm": "circle",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
@@ -1,15 +1,65 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
width: '1em',
display: 'inline-flex',
height: '1em',
display: 'flex',
width: '1em',
alignItems: 'center',
justifyContent: 'center',
});
const magicColor = `rgb(119,117,125)`;
globalStyle(`${root} path[stroke="${magicColor}"]`, {
stroke: 'currentColor',
});
globalStyle(`${root} path[fill="${magicColor}"]`, {
fill: 'currentColor',
});
// replace primary colors to cssVarV2('icon/primary')
const iconPrimaryColors = [
// legacy "--affine-icon-color"
'rgb(119,117,125)',
// --affine-v2-icon-primary
'rgb(122,122,122)',
];
// todo: may need to replace secondary colors & background colors as well?
const backgroundPrimaryColors = [
// --affine-v2-background-primary
'rgb(255,255,255)',
'#ffffff',
];
const backgroundSecondaryColors = [
// --affine-v2-background-secondary
'rgb(245,245,245)',
];
globalStyle(
`${root} :is(${iconPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
{
fill: 'currentColor',
}
);
globalStyle(
`${root} :is(${iconPrimaryColors.map(color => `path[stroke="${color}"]`).join(',')})`,
{
stroke: 'currentColor',
}
);
globalStyle(
`${root} :is(${backgroundPrimaryColors.map(color => `rect[fill="${color}"]`).join(',')})`,
{
fill: 'transparent',
}
);
globalStyle(
`${root} :is(${backgroundPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
{
fill: 'transparent',
}
);
globalStyle(
`${root} :is(${backgroundSecondaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
{
fill: cssVarV2('layer/background/secondary'),
}
);
+1
View File
@@ -76,6 +76,7 @@
"socket.io-client": "^4.8.1",
"swr": "2.3.3",
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
"webm-muxer": "^5.1.0",
"y-protocols": "^1.0.6",
"yjs": "^13.6.21",
"zod": "^3.24.1"
@@ -49,6 +49,7 @@ import {
export const translateSubItem: AISubItemConfig[] = translateLangs.map(lang => {
return {
type: lang,
testId: `action-translate-${lang}`,
handler: actionToHandler('translate', AIStarIconWithAnimation, { lang }),
};
});
@@ -56,6 +57,7 @@ export const translateSubItem: AISubItemConfig[] = translateLangs.map(lang => {
export const toneSubItem: AISubItemConfig[] = textTones.map(tone => {
return {
type: tone,
testId: `action-change-tone-${tone.toLowerCase()}`,
handler: actionToHandler('changeTone', AIStarIconWithAnimation, { tone }),
};
});
@@ -66,6 +68,7 @@ export function createImageFilterSubItem(
return imageFilterStyles.map(style => {
return {
type: style,
testId: `action-image-filter-${style.toLowerCase().replace(' ', '-')}`,
handler: actionToHandler(
'filterImage',
AIImageIconWithAnimation,
@@ -84,6 +87,7 @@ export function createImageProcessingSubItem(
return imageProcessingTypes.map(type => {
return {
type,
testId: `action-image-processing-${type.toLowerCase().replace(' ', '-')}`,
handler: actionToHandler(
'processImage',
AIImageIconWithAnimation,
@@ -146,36 +150,42 @@ const EditAIGroup: AIItemGroupConfig = {
items: [
{
name: 'Translate to',
testId: 'action-translate',
icon: LanguageIcon(),
showWhen: textBlockShowWhen,
subItem: translateSubItem,
},
{
name: 'Change tone to',
testId: 'action-change-tone',
icon: ToneIcon(),
showWhen: textBlockShowWhen,
subItem: toneSubItem,
},
{
name: 'Improve writing',
testId: 'action-improve-writing',
icon: ImproveWritingIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('improveWriting', AIStarIconWithAnimation),
},
{
name: 'Make it longer',
testId: 'action-make-it-longer',
icon: LongerIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('makeLonger', AIStarIconWithAnimation),
},
{
name: 'Make it shorter',
testId: 'action-make-it-shorter',
icon: ShorterIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('makeShorter', AIStarIconWithAnimation),
},
{
name: 'Continue writing',
testId: 'action-continue-writing',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('continueWriting', AIPenIconWithAnimation),
@@ -188,30 +198,35 @@ const DraftAIGroup: AIItemGroupConfig = {
items: [
{
name: 'Write an article about this',
testId: 'action-write-article',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('writeArticle', AIPenIconWithAnimation),
},
{
name: 'Write a tweet about this',
testId: 'action-write-twitter-post',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('writeTwitterPost', AIPenIconWithAnimation),
},
{
name: 'Write a poem about this',
testId: 'action-write-poem',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('writePoem', AIPenIconWithAnimation),
},
{
name: 'Write a blog post about this',
testId: 'action-write-blog-post',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('writeBlogPost', AIPenIconWithAnimation),
},
{
name: 'Brainstorm ideas about this',
testId: 'action-brainstorm',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('brainstorm', AIPenIconWithAnimation),
@@ -224,36 +239,42 @@ const ReviewWIthAIGroup: AIItemGroupConfig = {
items: [
{
name: 'Fix spelling',
testId: 'action-fix-spelling',
icon: DoneIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('fixSpelling', AIStarIconWithAnimation),
},
{
name: 'Fix grammar',
testId: 'action-fix-grammar',
icon: DoneIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('improveGrammar', AIStarIconWithAnimation),
},
{
name: 'Explain this image',
testId: 'action-explain-image',
icon: PenIcon(),
showWhen: imageBlockShowWhen,
handler: actionToHandler('explainImage', AIStarIconWithAnimation),
},
{
name: 'Explain this code',
testId: 'action-explain-code',
icon: ExplainIcon(),
showWhen: codeBlockShowWhen,
handler: actionToHandler('explainCode', AIStarIconWithAnimation),
},
{
name: 'Check code error',
testId: 'action-check-code-error',
icon: ExplainIcon(),
showWhen: codeBlockShowWhen,
handler: actionToHandler('checkCodeErrors', AIStarIconWithAnimation),
},
{
name: 'Explain selection',
testId: 'action-explain-selection',
icon: SelectionIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('explain', AIStarIconWithAnimation),
@@ -266,12 +287,14 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
items: [
{
name: 'Summarize',
testId: 'action-summarize',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('summary', AIPenIconWithAnimation),
},
{
name: 'Generate headings',
testId: 'action-generate-headings',
icon: PenIcon(),
beta: true,
handler: actionToHandler('createHeadings', AIPenIconWithAnimation),
@@ -293,24 +316,28 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
},
{
name: 'Generate an image',
testId: 'action-generate-image',
icon: ImageIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('createImage', AIImageIconWithAnimation),
},
{
name: 'Generate outline',
testId: 'action-generate-outline',
icon: PenIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('writeOutline', AIPenIconWithAnimation),
},
{
name: 'Brainstorm ideas with mind map',
testId: 'action-brainstorm-mindmap',
icon: MindmapIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('brainstormMindmap', AIPenIconWithAnimation),
},
{
name: 'Generate presentation',
testId: 'action-generate-presentation',
icon: PresentationIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('createSlides', AIPresentationIconWithAnimation),
@@ -318,6 +345,7 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
},
{
name: 'Make it real',
testId: 'action-make-it-real',
icon: MakeItRealIcon(),
beta: true,
showWhen: textBlockShowWhen,
@@ -325,6 +353,7 @@ const GenerateWithAIGroup: AIItemGroupConfig = {
},
{
name: 'Find actions',
testId: 'action-find-actions',
icon: SearchIcon(),
showWhen: textBlockShowWhen,
handler: actionToHandler('findActions', AIStarIconWithAnimation),
@@ -338,6 +367,7 @@ const OthersAIGroup: AIItemGroupConfig = {
items: [
{
name: 'Continue with AI',
testId: 'action-continue-with-ai',
icon: CommentIcon(),
handler: host => {
const panel = getAIPanelWidget(host);
@@ -366,6 +396,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
items: [
{
name: 'Explain this image',
testId: 'action-explain-image',
icon: ImageIcon(),
showWhen: () => true,
handler: actionToHandler(
@@ -382,6 +413,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
items: [
{
name: 'Generate an image',
testId: 'action-generate-image',
icon: ImageIcon(),
showWhen: () => true,
handler: actionToHandler(
@@ -393,6 +425,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
},
{
name: 'Image processing',
testId: 'action-image-processing',
icon: ImageIcon(),
showWhen: () => true,
subItem: createImageProcessingSubItem(blockActionTrackerOptions),
@@ -401,6 +434,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
},
{
name: 'AI image filter',
testId: 'action-ai-image-filter',
icon: ImproveWritingIcon(),
showWhen: () => true,
subItem: createImageFilterSubItem(blockActionTrackerOptions),
@@ -409,6 +443,7 @@ export function buildAIImageItemGroups(): AIItemGroupConfig[] {
},
{
name: 'Generate a caption',
testId: 'action-generate-caption',
icon: PenIcon(),
showWhen: () => true,
beta: true,
@@ -432,6 +467,7 @@ export function buildAICodeItemGroups(): AIItemGroupConfig[] {
items: [
{
name: 'Explain this code',
testId: 'action-explain-code',
icon: ExplainIcon(),
showWhen: () => true,
handler: actionToHandler(
@@ -443,6 +479,7 @@ export function buildAICodeItemGroups(): AIItemGroupConfig[] {
},
{
name: 'Check code error',
testId: 'action-check-code-error',
icon: ExplainIcon(),
showWhen: () => true,
handler: actionToHandler(
@@ -10,8 +10,8 @@ import {
buildFinishConfig,
buildGeneratingConfig,
} from '../ai-panel';
import type { AIError, AIItemGroupConfig } from '../components/ai-item/types';
import { AIProvider } from '../provider';
import { type AIItemGroupConfig } from '../components/ai-item/types';
import { type AIError, AIProvider } from '../provider';
import { reportResponse } from '../utils/action-reporter';
import { getAIPanelWidget } from '../utils/ai-widgets';
import { AIContext } from '../utils/context';
@@ -20,8 +20,7 @@ import type { TemplateResult } from 'lit';
import { getContentFromSlice } from '../../utils';
import { AIChatBlockModel } from '../blocks';
import type { AIError } from '../components/ai-item/types';
import { AIProvider } from '../provider';
import { type AIError, AIProvider } from '../provider';
import { reportResponse } from '../utils/action-reporter';
import { getAIPanelWidget } from '../utils/ai-widgets';
import { AIContext } from '../utils/context';
@@ -462,7 +461,6 @@ export function noteBlockOrTextShowWhen(
host: EditorHost
) {
const selected = getCopilotSelectedElems(host);
return selected.some(
el =>
el instanceof NoteBlockModel ||
@@ -85,6 +85,7 @@ export function discard(
return {
name: 'Discard',
icon: DeleteIcon(),
testId: 'answer-discard',
showWhen: () => !!panel.answer,
handler: () => {
panel.discard();
@@ -96,6 +97,7 @@ export function retry(panel: AffineAIPanelWidget): AIItemConfig {
return {
name: 'Retry',
icon: ResetIcon(),
testId: 'answer-retry',
handler: () => {
reportResponse('result:retry');
panel.generate();
@@ -123,6 +125,7 @@ export function createInsertItems<T extends keyof BlockSuitePresets.AIActions>(
icon: html`<div style=${styleMap({ height: '20px', width: '20px' })}>
${LightLoadingIcon}
</div>`,
testId: 'answer-insert-below-loading',
showWhen: () => {
const panel = getAIPanelWidget(host);
const data = ctx.get();
@@ -137,6 +140,8 @@ export function createInsertItems<T extends keyof BlockSuitePresets.AIActions>(
{
name: buttonText,
icon: InsertBelowIcon(),
testId:
buttonText === 'Replace' ? 'answer-replace' : `answer-insert-below`,
showWhen: () => {
const panel = getAIPanelWidget(host);
const data = ctx.get();
@@ -191,6 +196,7 @@ export function asCaption<T extends keyof BlockSuitePresets.AIActions>(
return {
name: 'Use as caption',
icon: PenIcon(),
testId: 'answer-use-as-caption',
showWhen: () => {
const panel = getAIPanelWidget(host);
return id === 'generateCaption' && !!panel.answer;
@@ -553,9 +559,11 @@ export function actionToResponse<T extends keyof BlockSuitePresets.AIActions>(
responses: [
{
name: 'Response',
testId: 'answer-responses',
items: [
{
name: 'Continue in chat',
testId: 'answer-continue-in-chat',
icon: ChatWithAiIcon({}),
handler: () => {
reportResponse('result:continue-in-chat');
@@ -53,6 +53,7 @@ function asCaption<T extends keyof BlockSuitePresets.AIActions>(
return {
name: 'Use as caption',
icon: PenIcon(),
testId: 'answer-use-as-caption',
showWhen: () => {
const panel = getAIPanelWidget(host);
return id === 'generateCaption' && !!panel.answer;
@@ -79,6 +80,7 @@ function createNewNote(host: EditorHost): AIItemConfig {
return {
name: 'Create new note',
icon: PageIcon(),
testId: 'answer-create-new-note',
showWhen: () => {
const panel = getAIPanelWidget(host);
return !!panel.answer && isInsideEdgelessEditor(host);
@@ -147,9 +149,11 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
return [
{
name: 'Response',
testId: 'answer-responses',
items: [
{
name: 'Insert below',
testId: 'answer-insert-below',
icon: InsertBelowIcon(),
showWhen: () =>
!!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)),
@@ -161,6 +165,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
},
{
name: 'Insert above',
testId: 'answer-insert-above',
icon: InsertTopIcon(),
showWhen: () =>
!!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id),
@@ -173,6 +178,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
asCaption(host, id),
{
name: 'Replace selection',
testId: 'answer-replace',
icon: ReplaceIcon(),
showWhen: () =>
!!panel.answer && !EXCLUDING_REPLACE_ACTIONS.includes(id),
@@ -187,10 +193,12 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
},
{
name: '',
testId: 'answer-common-responses',
items: [
{
name: 'Continue in chat',
icon: ChatWithAiIcon(),
testId: 'answer-continue-in-chat',
handler: () => {
reportResponse('result:continue-in-chat');
AIProvider.slots.requestOpenWithChat.next({ host });
@@ -200,6 +208,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
{
name: 'Regenerate',
icon: ResetIcon(),
testId: 'answer-regenerate',
handler: () => {
reportResponse('result:retry');
panel.generate();
@@ -208,6 +217,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
{
name: 'Discard',
icon: DeleteIcon(),
testId: 'answer-discard',
handler: () => {
panel.discard();
},
@@ -225,6 +235,7 @@ export function buildErrorResponseConfig(panel: AffineAIPanelWidget) {
{
name: 'Retry',
icon: ResetIcon(),
testId: 'error-retry',
showWhen: () => true,
handler: () => {
reportResponse('result:retry');
@@ -234,6 +245,7 @@ export function buildErrorResponseConfig(panel: AffineAIPanelWidget) {
{
name: 'Discard',
icon: DeleteIcon(),
testId: 'error-discard',
showWhen: () => !!panel.answer,
handler: () => {
panel.discard();
@@ -13,8 +13,21 @@ export class LitTranscriptionBlock extends BlockComponent<TranscriptionBlockMode
}
`,
];
get lastCalloutBlock() {
for (const child of this.model.children.toReversed()) {
if (child.flavour === 'affine:callout') {
return child;
}
}
return null;
}
override render() {
return this.std.host.renderChildren(this.model);
return this.std.host.renderChildren(this.model, model => {
// if model is the last transcription block, we should render it
return model === this.lastCalloutBlock;
});
}
@property({ type: String, attribute: 'data-block-id' })
@@ -142,6 +142,7 @@ export class ActionWrapper extends WithDisposable(LitElement) {
<slot></slot>
<div
class="action-name"
data-testid="action-name"
@click=${() => (this.promptShow = !this.promptShow)}
>
${icons[item.action] ? icons[item.action] : DoneIcon()}
@@ -152,22 +153,27 @@ export class ActionWrapper extends WithDisposable(LitElement) {
</div>
${this.promptShow
? html`
<div class="answer-prompt">
<div class="answer-prompt" data-testid="answer-prompt">
<div class="subtitle">Answer</div>
${HISTORY_IMAGE_ACTIONS.includes(item.action)
? images &&
html`<chat-content-images
.images=${images}
data-testid="generated-image"
></chat-content-images>`
: nothing}
${answer
? createTextRenderer(this.host, { customHeading: true })(answer)
? createTextRenderer(this.host, {
customHeading: true,
testId: 'chat-message-action-answer',
})(answer)
: nothing}
${originalText
? html`<div class="subtitle prompt">Prompt</div>
${createTextRenderer(this.host, { customHeading: true })(
item.messages[0].content + originalText
)}`
${createTextRenderer(this.host, {
customHeading: true,
testId: 'chat-message-action-prompt',
})(item.messages[0].content + originalText)}`
: nothing}
</div>
`
@@ -1,6 +1,3 @@
import './action-wrapper';
import '../content/images';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -27,7 +24,10 @@ export class ActionImageToText extends WithDisposable(ShadowlessElement) {
})}
>
${answer
? html`<chat-content-images .images=${answer}></chat-content-images>`
? html`<chat-content-images
data-testid="original-images"
.images=${answer}
></chat-content-images>`
: nothing}
</div>
</action-wrapper>`;
@@ -17,13 +17,19 @@ export class ActionImage extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'action-image';
protected override render() {
const images = this.item.messages[0].attachments;
return html`<action-wrapper .host=${this.host} .item=${this.item}>
<div style=${styleMap({ marginBottom: '12px' })}>
${images
? html`<chat-content-images .images=${images}></chat-content-images>`
? html`<chat-content-images
.images=${images}
data-testid="original-image"
></chat-content-images>`
: nothing}
</div>
</action-wrapper>`;
@@ -54,6 +54,7 @@ export class ActionText extends WithDisposable(LitElement) {
border: isCode ? 'none' : '1px solid var(--affine-border-color)',
})}
class="original-text"
data-testid="original-text"
>
${createTextRenderer(this.host, {
customHeading: true,
@@ -47,6 +47,9 @@ export class AILoading extends WithDisposable(LitElement) {
@property({ attribute: false })
accessor stopGenerating!: () => void;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'ai-loading';
override render() {
return html`
<div class="generating-tip">
@@ -1,6 +1,6 @@
import type { Signal } from '@preact/signals-core';
import type { AIError } from '../components/ai-item/types';
import type { AIError } from '../provider';
export type ChatMessage = {
id: string;
@@ -43,6 +43,7 @@ export class ChatPanelChips extends SignalWatcher(
.chips-wrapper {
display: flex;
flex-wrap: wrap;
margin: 0 -4px 0 -4px;
}
.add-button,
.collapse-button,
@@ -99,6 +100,9 @@ export class ChatPanelChips extends SignalWatcher(
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-panel-chips';
@query('.add-button')
accessor addButton!: HTMLDivElement;
@@ -137,7 +141,11 @@ export class ChatPanelChips extends SignalWatcher(
const chips = isCollapsed ? allChips.slice(0, 1) : allChips;
return html`<div class="chips-wrapper">
<div class="add-button" @click=${this._toggleAddDocMenu}>
<div
class="add-button"
data-testid="chat-panel-with-button"
@click=${this._toggleAddDocMenu}
>
${PlusIcon()}
</div>
${repeat(
@@ -14,8 +14,7 @@ import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { ChatAbortIcon, ChatSendIcon } from '../_common/icons';
import type { AIError } from '../components/ai-item/types';
import { AIProvider } from '../provider';
import { type AIError, AIProvider } from '../provider';
import { reportResponse } from '../utils/action-reporter';
import { readBlobAsURL } from '../utils/image';
import type { AINetworkSearchConfig, DocDisplayConfig } from './chat-config';
@@ -220,6 +219,9 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-panel-input-container';
private get _isNetworkActive() {
return (
!!this.networkSearchConfig.visible.value &&
@@ -335,7 +337,10 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
`
: nothing}
${this.chatContextValue.quote
? html`<div class="chat-selection-quote">
? html`<div
class="chat-selection-quote"
data-testid="chat-selection-quote"
>
${repeat(
getFirstTwoLines(this.chatContextValue.quote),
line => line,
@@ -420,6 +425,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
: nothing}
${images.length < MaximumImageCount
? html`<div
data-testid="chat-panel-input-image-upload"
class="image-upload"
aria-disabled=${uploadDisabled}
@click=${uploadDisabled ? undefined : this._uploadImageFiles}
@@ -434,6 +440,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
this.updateContext({ status: 'success' });
reportResponse('aborted:stop');
}}
data-testid="chat-panel-stop"
>
${ChatAbortIcon}
</div>`
@@ -14,8 +14,7 @@ import { repeat } from 'lit/directives/repeat.js';
import { debounce } from 'lodash-es';
import { AffineIcon } from '../_common/icons';
import { type AIError, UnauthorizedError } from '../components/ai-item/types';
import { AIProvider } from '../provider';
import { type AIError, AIProvider, UnauthorizedError } from '../provider';
import {
type ChatContextValue,
type ChatMessage,
@@ -31,7 +30,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
position: relative;
}
.chat-panel-messages {
.chat-panel-messages-container {
display: flex;
flex-direction: column;
gap: 24px;
@@ -157,9 +156,16 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor previewSpecBuilder!: SpecBuilder;
@query('.chat-panel-messages')
@query('.chat-panel-messages-container')
accessor messagesContainer: HTMLDivElement | null = null;
@property({
type: String,
attribute: 'data-testid',
reflect: true,
})
accessor testId = 'chat-panel-messages';
getScrollContainer(): HTMLDivElement | null {
return this.messagesContainer;
}
@@ -168,12 +174,13 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
return this.isLoading ||
!this.host?.doc.get(FeatureFlagService).getFlag('enable_ai_onboarding')
? nothing
: html`<div class="onboarding-wrapper">
: html`<div class="onboarding-wrapper" data-testid="ai-onboarding">
${repeat(
AIPreloadConfig,
config => config.text,
config => {
return html`<div
data-testid=${config.testId}
@click=${() => config.handler()}
class="onboarding-item"
>
@@ -220,7 +227,8 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
return html`
<div
class="chat-panel-messages"
class="chat-panel-messages-container"
data-testid="chat-panel-messages-container"
@scroll=${() => this._debouncedOnScroll()}
>
${filteredItems.length === 0
@@ -232,8 +240,12 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
)}
<div class="messages-placeholder-title" data-loading=${isLoading}>
${this.isLoading
? 'AFFiNE AI is loading history...'
: 'What can I help you with?'}
? html`<span data-testid="chat-panel-loading-state"
>AFFiNE AI is loading history...</span
>`
: html`<span data-testid="chat-panel-empty-state"
>What can I help you with?</span
>`}
</div>
${this._renderAIOnboarding()}
</div> `
@@ -268,7 +280,11 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
)}
</div>
${showDownIndicator && filteredItems.length > 0
? html`<div class="down-indicator" @click=${this._onDownIndicatorClick}>
? html`<div
data-testid="chat-panel-scroll-down-indicator"
class="down-indicator"
@click=${this._onDownIndicatorClick}
>
${ArrowDownIcon()}
</div>`
: nothing}
@@ -44,6 +44,8 @@ export type MenuItem = {
name: string | TemplateResult<1>;
icon: TemplateResult<1>;
action: MenuAction;
suffix?: string | TemplateResult<1>;
testId?: string;
};
export type MenuAction = () => Promise<void> | void;
@@ -140,6 +142,7 @@ export class ChatPanelAddPopover extends SignalWatcher(
{
key: 'tags',
name: 'Tags',
testId: 'ai-chat-with-tags',
icon: TagsIcon(),
action: () => {
this._toggleMode(AddPopoverMode.Tags);
@@ -148,6 +151,7 @@ export class ChatPanelAddPopover extends SignalWatcher(
{
key: 'collections',
name: 'Collections',
testId: 'ai-chat-with-collections',
icon: CollectionsIcon(),
action: () => {
this._toggleMode(AddPopoverMode.Collections);
@@ -176,6 +180,7 @@ export class ChatPanelAddPopover extends SignalWatcher(
{
key: 'files',
name: 'Upload files (pdf, txt, csv)',
testId: 'ai-chat-with-files',
icon: UploadIcon(),
action: this._addFileChip,
},
@@ -330,13 +335,14 @@ export class ChatPanelAddPopover extends SignalWatcher(
${repeat(
items,
item => item.key,
({ key, name, icon, action }, idx) => {
({ key, name, icon, action, testId }, idx) => {
const curIdx = startIndex + idx;
return html`<icon-button
width="280px"
height="30px"
data-id=${key}
data-index=${curIdx}
data-testid=${testId}
.text=${name}
hover=${this._activatedIndex === curIdx}
@click=${() => action()?.catch(console.error)}
@@ -37,6 +37,9 @@ export class ChatContentPureText extends ShadowlessElement {
@property({ attribute: false })
accessor text: string = '';
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-content-pure-text';
protected override render() {
return this.text.length > 0
? html`<div class="chat-content-pure-text">${this.text}</div>`
@@ -16,6 +16,9 @@ export class ChatMessageAction extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor item!: ChatAction;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-message-action';
renderHeader() {
return html`
<div class="user-info">
@@ -12,8 +12,8 @@ import {
EdgelessEditorActions,
PageEditorActions,
} from '../../_common/chat-actions-handle';
import { type AIError } from '../../components/ai-item/types';
import { AIChatErrorRenderer } from '../../messages/error';
import { type AIError } from '../../provider';
import { type ChatMessage, isChatMessage } from '../chat-context';
export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@@ -34,7 +34,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor isLast: boolean = false;
@property({ attribute: false })
@property({ attribute: 'data-status', reflect: true })
accessor status: string = 'idle';
@property({ attribute: false })
@@ -49,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor retry!: () => void;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-message-assistant';
renderHeader() {
const isWithDocs =
'content' in this.item &&
@@ -31,6 +31,9 @@ export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor item!: ChatMessage;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-message-user';
renderContent() {
const { item } = this;
@@ -41,7 +44,7 @@ export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
.images=${item.attachments}
></chat-content-images>`
: nothing}
<div class="text-content-wrapper">
<div class="text-content-wrapper" data-test-id="chat-content-user-text">
<chat-content-pure-text .text=${item.content}></chat-content-pure-text>
</div>
`;

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