Compare commits

..

17 Commits

Author SHA1 Message Date
DarkSky
850e646ab9 fix: electon rendering on windows (#14456)
fix #14450
fix #14401
fix #13983
fix #12766
fix #14404
fix #12019

#### PR Dependency Tree


* **PR #14456** 👈

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

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

## Summary by CodeRabbit

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

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

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


* **PR #14452** 👈

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

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

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

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

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


* **PR #14449** 👈

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

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

## Summary by CodeRabbit

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

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

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

#### PR Dependency Tree


* **PR #14445** 👈

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

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

## Summary by CodeRabbit

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

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

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

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

#### PR Dependency Tree


* **PR #14442** 👈

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

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

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

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

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


* **PR #14440** 👈

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

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

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


* **PR #14438** 👈

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

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

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


* **PR #14434** 👈

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

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

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

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

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

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

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

## Summary by CodeRabbit

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 14:43:22 +08:00
DarkSky
72df9cb457 feat: improve editor performance (#14429)
#### PR Dependency Tree


* **PR #14429** 👈

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**
* HTML import now splits lines on <br> into separate paragraphs while
preserving inline formatting.

* **Bug Fixes**
* Paste falls back to inserting after the first paragraph when no
explicit target is found.

* **Style**
  * Improved page-mode viewport styling for consistent content layout.

* **Tests**
* Added snapshot tests for <br>-based paragraph splitting; re-enabled an
e2e drag-page test.

* **Chores**
* Deferred/deduplicated font loading, inline text caching,
drag-handle/pointer optimizations, and safer inline render
synchronization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 00:43:36 +08:00
DarkSky
98e5747fdc feat: merge service 2026-02-13 21:52:11 +08:00
255 changed files with 9286 additions and 4488 deletions

View File

@@ -33,24 +33,22 @@ const replicaConfig = {
stable: {
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
},
beta: {
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
},
canary: { front: 1, graphql: 1, doc: 1 },
canary: { front: 1, graphql: 1 },
};
const cpuConfig = {
beta: { front: '1', graphql: '1', doc: '1' },
canary: { front: '500m', graphql: '1', doc: '500m' },
beta: { front: '1', graphql: '1' },
canary: { front: '500m', graphql: '1' },
};
const memoryConfig = {
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
beta: { front: '2Gi', graphql: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi' },
};
const createHelmCommand = ({ isDryRun }) => {
@@ -80,7 +78,6 @@ const createHelmCommand = ({ isDryRun }) => {
const serviceAnnotations = [
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json doc.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
].concat(
isProduction || isBeta || isInternal
? [
@@ -98,7 +95,6 @@ const createHelmCommand = ({ isDryRun }) => {
? [
`--set-json front.nodeSelector="${spotNodeSelector}"`,
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
`--set-json doc.nodeSelector="${spotNodeSelector}"`,
]
: [];
@@ -109,14 +105,12 @@ const createHelmCommand = ({ isDryRun }) => {
resources = resources.concat([
`--set front.resources.requests.cpu="${cpu.front}"`,
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
`--set doc.resources.requests.cpu="${cpu.doc}"`,
]);
}
if (memory) {
resources = resources.concat([
`--set front.resources.requests.memory="${memory.front}"`,
`--set graphql.resources.requests.memory="${memory.graphql}"`,
`--set doc.resources.requests.memory="${memory.doc}"`,
]);
}
@@ -155,9 +149,6 @@ const createHelmCommand = ({ isDryRun }) => {
`--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set-string graphql.app.host="${primaryHost}"`,
`--set-string doc.image.tag="${imageTag}"`,
`--set-string doc.app.host="${primaryHost}"`,
`--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations,
...spotScheduling,
...resources,

View File

@@ -1,16 +0,0 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "doc.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "doc.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "doc.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "doc.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -1,63 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "doc.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "doc.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "doc.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "doc.labels" -}}
helm.sh/chart: {{ include "doc.chart" . }}
{{ include "doc.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "doc.selectorLabels" -}}
app.kubernetes.io/name: {{ include "doc.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "doc.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "doc.fullname" .) .Values.global.docService.name }}
{{- else }}
{{- default "default" .Values.global.docService.name }}
{{- end }}
{{- end }}

View File

@@ -1,118 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "doc.fullname" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "doc.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "doc.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "doc.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NODE_OPTIONS
value: "--max-old-space-size=4096"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "doc"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.global.docService.port }}"
- name: AFFINE_SERVER_SUB_PATH
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
ports:
- name: http
containerPort: {{ .Values.global.docService.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "doc.serviceAccountName" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "doc.fullname" . }}-test-connection"
labels:
{{- include "doc.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "doc.fullname" . }}:{{ .Values.global.docService.port }}']
restartPolicy: Never

View File

@@ -88,8 +88,6 @@ spec:
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.app.port }}

View File

@@ -1,19 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "doc.fullname" . }}
name: {{ .Values.global.docService.name }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.doc.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
type: {{ .Values.services.doc.type }}
ports:
- port: {{ .Values.global.docService.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "doc.selectorLabels" . | nindent 4 }}
{{- include "front.selectorLabels" . | nindent 4 }}

View File

@@ -57,6 +57,9 @@ services:
type: ClusterIP
port: 8080
annotations: {}
doc:
type: ClusterIP
annotations: {}
nodeSelector: {}
tolerations: []

View File

@@ -47,12 +47,6 @@ graphql:
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
doc:
service:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
front:
services:
sync:
@@ -71,3 +65,7 @@ front:
name: affine-web
type: ClusterIP
port: 8080
doc:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'

View File

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

View File

@@ -210,18 +210,13 @@ jobs:
e2e-blocksuite-cross-browser-test:
name: E2E BlockSuite Cross Browser Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1]
browser: ['chromium', 'firefox', 'webkit']
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
playwright-platform: ${{ matrix.browser }}
playwright-platform: 'chromium,firefox,webkit'
electron-install: false
full-cache: true
@@ -229,18 +224,64 @@ jobs:
run: yarn workspace @blocksuite/playground build
- name: Run playwright tests
env:
BROWSER: ${{ matrix.browser }}
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
run: |
yarn workspace @blocksuite/integration-test test:unit
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
name: test-results-e2e-bs-cross-browser
path: ./test-results
if-no-files-found: ignore
bundler-matrix:
name: Bundler Matrix (${{ matrix.bundler }})
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
bundler: [webpack, rspack]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: false
electron-install: false
full-cache: true
- name: Run frontend build matrix
env:
AFFINE_BUNDLER: ${{ matrix.bundler }}
run: |
set -euo pipefail
packages=(
"@affine/web"
"@affine/mobile"
"@affine/ios"
"@affine/android"
"@affine/admin"
"@affine/electron-renderer"
)
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
: > "$summary"
for pkg in "${packages[@]}"; do
start=$(date +%s)
yarn affine "$pkg" build
end=$(date +%s)
echo "${pkg},$((end-start))" >> "$summary"
done
- name: Upload bundler timing
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-bundler-${{ matrix.bundler }}
path: ./test-results-bundler-${{ matrix.bundler }}.txt
if-no-files-found: ignore
e2e-test:
name: E2E Test
runs-on: ubuntu-24.04-arm
@@ -307,7 +348,7 @@ jobs:
name: Unit Test
runs-on: ubuntu-latest
needs:
- build-native
- build-native-linux
env:
DISTRIBUTION: web
strategy:
@@ -321,6 +362,7 @@ jobs:
with:
electron-install: true
playwright-install: true
playwright-platform: 'chromium,firefox,webkit'
full-cache: true
- name: Download affine.linux-x64-gnu.node
@@ -341,7 +383,39 @@ jobs:
name: affine
fail_ci_if_error: false
build-native:
build-native-linux:
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
runs-on: ubuntu-latest
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/native
electron-install: false
- name: Setup filename
id: filename
working-directory: ${{ github.workspace }}
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: '@affine/native'
- name: Upload ${{ steps.filename.outputs.filename }}
uses: actions/upload-artifact@v4
if: always()
with:
name: ${{ steps.filename.outputs.filename }}
path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }}
if-no-files-found: error
build-native-macos:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -350,7 +424,6 @@ jobs:
fail-fast: false
matrix:
spec:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: macos-latest, target: x86_64-apple-darwin }
- { os: macos-latest, target: aarch64-apple-darwin }
@@ -383,7 +456,7 @@ jobs:
# Split Windows build because it's too slow
# and other ci jobs required linux native
build-windows-native:
build-native-windows:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -483,7 +556,7 @@ jobs:
name: Native Unit Test
runs-on: ubuntu-latest
needs:
- build-native
- build-native-linux
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -577,8 +650,6 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
strategy:
fail-fast: false
env:
NODE_ENV: test
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -819,11 +890,51 @@ jobs:
- name: Run tests
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
copilot-test-filter:
name: Copilot test filter
runs-on: ubuntu-latest
outputs:
run-api: ${{ steps.decision.outputs.run_api }}
run-e2e: ${{ steps.decision.outputs.run_e2e }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: copilot-filter
with:
filters: |
api:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
e2e:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Decide test scope
id: decision
run: |
if [[ "${{ steps.copilot-filter.outputs.api }}" == "true" ]]; then
echo "run_api=true" >> "$GITHUB_OUTPUT"
else
echo "run_api=false" >> "$GITHUB_OUTPUT"
fi
if [[ "${{ steps.copilot-filter.outputs.e2e }}" == "true" ]]; then
echo "run_e2e=true" >> "$GITHUB_OUTPUT"
else
echo "run_e2e=false" >> "$GITHUB_OUTPUT"
fi
copilot-api-test:
name: Server Copilot Api Test
if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }}
runs-on: ubuntu-latest
needs:
- build-server-native
- copilot-test-filter
env:
NODE_ENV: test
DISTRIBUTION: web
@@ -857,53 +968,29 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: apifilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
electron-install: false
full-cache: true
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run server tests
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
run: yarn affine @affine/server test:copilot:coverage --forbid-only
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
- name: Upload server test coverage results
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -914,6 +1001,7 @@ jobs:
copilot-e2e-test:
name: Frontend Copilot E2E Test
if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }}
runs-on: ubuntu-latest
env:
DISTRIBUTION: web
@@ -928,6 +1016,7 @@ jobs:
shardTotal: [5]
needs:
- build-server-native
- copilot-test-filter
services:
postgres:
image: pgvector/pgvector:pg16
@@ -951,30 +1040,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: e2efilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
playwright-install: true
@@ -983,20 +1049,17 @@ jobs:
hard-link-nm: false
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/copilot-test
with:
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -1006,7 +1069,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
- build-native
- build-native-linux
env:
DISTRIBUTION: web
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -1099,7 +1162,9 @@ jobs:
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
- build-native-linux
- build-native-macos
- build-native-windows
strategy:
fail-fast: false
matrix:
@@ -1182,84 +1247,6 @@ jobs:
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
run: yarn affine @affine-test/affine-desktop e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
desktop-bundle-check:
name: Desktop bundle check (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
strategy:
fail-fast: false
matrix:
spec:
- {
os: macos-latest,
platform: macos,
arch: x64,
target: x86_64-apple-darwin,
test: false,
}
- {
os: macos-latest,
platform: macos,
arch: arm64,
target: aarch64-apple-darwin,
test: true,
}
- {
os: ubuntu-latest,
platform: linux,
arch: x64,
target: x86_64-unknown-linux-gnu,
test: true,
}
- {
os: windows-latest,
platform: windows,
arch: x64,
target: x86_64-pc-windows-msvc,
test: true,
}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
playwright-install: true
hard-link-nm: false
enableScripts: false
- name: Setup filename
id: filename
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Download ${{ steps.filename.outputs.filename }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.filename.outputs.filename }}
path: ./packages/frontend/native
- name: Download web artifact
uses: ./.github/actions/download-web
with:
path: packages/frontend/apps/electron/resources/web-static
- name: Build Desktop Layers
run: yarn affine @affine/electron build
- name: Make bundle (macOS)
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
env:
@@ -1299,6 +1286,14 @@ jobs:
run: |
yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
test-done:
needs:
- analyze
@@ -1312,8 +1307,9 @@ jobs:
- e2e-blocksuite-cross-browser-test
- e2e-mobile-test
- unit-test
- build-native
- build-windows-native
- build-native-linux
- build-native-macos
- build-native-windows
- build-server-native
- build-electron-renderer
- native-unit-test
@@ -1323,10 +1319,10 @@ jobs:
- server-test
- server-e2e-test
- rust-test
- copilot-test-filter
- copilot-api-test
- copilot-e2e-test
- desktop-test
- desktop-bundle-check
- cloud-e2e-test
if: always()
runs-on: ubuntu-latest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should split into multiple blocks', async () => {
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'aaa' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'bbb' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'ccc' }],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should keep inline styles in each split line', async () => {
const html = template(
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa',
attributes: {
bold: true,
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'bbb',
attributes: {
link: 'https://www.google.com/',
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'ccc',
attributes: {
italic: true,
},
},
],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('nested list', async () => {
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);

View File

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

View File

@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
return false;
};
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
const lines: DeltaInsert[][] = [[]];
const pending = [...delta];
while (pending.length > 0) {
const op = pending.shift();
if (!op) continue;
const insert = op.insert;
if (typeof insert !== 'string') {
lines[lines.length - 1].push(op);
continue;
}
if (!insert.includes('\n')) {
if (insert.length === 0) {
continue;
}
lines[lines.length - 1].push(op);
continue;
}
const splitIndex = insert.indexOf('\n');
const linePart = insert.slice(0, splitIndex);
const remainPart = insert.slice(splitIndex + 1);
if (linePart.length > 0) {
lines[lines.length - 1].push({ ...op, insert: linePart });
}
lines.push([]);
if (remainPart) {
pending.unshift({ ...op, insert: remainPart });
}
}
return lines;
};
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
if (!HastUtils.isElement(node)) {
return false;
}
return node.children.some(child => {
if (!HastUtils.isElement(child)) {
return false;
}
return (
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
hasBlockElementDescendant(child)
);
});
};
const getParagraphDeltas = (
node: HtmlAST,
delta: DeltaInsert[]
): DeltaInsert[][] => {
if (!HastUtils.isElement(node)) return [delta];
if (hasBlockElementDescendant(node)) return [delta];
const hasBr = !!HastUtils.querySelector(node, 'br');
if (!hasBr) return [delta];
const hasNewline = delta.some(
op => typeof op.insert === 'string' && op.insert.includes('\n')
);
if (!hasNewline) return [delta];
return splitDeltaByNewline(delta);
};
const openParagraphBlocks = (
deltas: DeltaInsert[][],
type: string,
// AST walker context from html adapter transform pipeline.
walkerContext: any
) => {
for (const delta of deltas) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
children: [],
},
'children'
)
.closeNode();
}
};
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
'affine:paragraph:multi-emitted-nodes';
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
const emittedNodes =
(walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
emittedNodes.add(node as object);
walkerContext.setGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
emittedNodes
);
};
const consumeMultiParagraphEmittedMark = (
walkerContext: any,
node: HtmlAST
) => {
const emittedNodes = walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined;
if (!emittedNodes) {
return false;
}
return emittedNodes.delete(node as object);
};
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o =>
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
!tagsInAncestor(o, ['p', 'li']) &&
HastUtils.isParagraphLike(o.node)
) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
openParagraphBlocks(deltas, 'text', walkerContext);
walkerContext.skipAllChildren();
}
break;
}
case 'p': {
const type = walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text';
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
if (deltas.length > 1) {
openParagraphBlocks(deltas, type, walkerContext);
markMultiParagraphEmitted(walkerContext, o.node);
walkerContext.skipAllChildren();
break;
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text',
type,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
delta,
},
},
children: [],
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
break;
}
case 'p': {
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
break;
}
if (
o.next?.type === 'element' &&
o.next.tagName === 'div' &&

View File

@@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard {
if (this.std.store.readonly) return;
this.std.store.captureSync();
let hasPasteTarget = false;
this.std.command
.chain()
.try<{}>(cmd => [
@@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard {
if (!ctx.parentBlock) {
return;
}
hasPasteTarget = true;
this.std.clipboard
.paste(
e,
this.std.store,
ctx.parentBlock.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
)
.catch(console.error);
return next();
})
.run();
if (hasPasteTarget) return;
// If no valid selection target exists (for example, stale block selection
// right after cut), create/focus the default paragraph and paste after it.
const firstParagraphId = document
.querySelector('affine-page-root')
?.focusFirstParagraph?.()?.id;
const parentModel = firstParagraphId
? this.std.store.getParent(firstParagraphId)
: null;
const paragraphIndex =
firstParagraphId && parentModel
? parentModel.children.findIndex(child => child.id === firstParagraphId)
: -1;
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
this.std.clipboard
.paste(e, this.std.store, parentModel?.id, insertIndex)
.catch(console.error);
};
override mounted() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import { IS_FIREFOX } from '@blocksuite/global/env';
import { LifeCycleWatcher } from '@blocksuite/std';
@@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX
export class FontLoaderService extends LifeCycleWatcher {
static override readonly key = 'font-loader';
private static readonly DEFERRED_LOAD_DELAY_MS = 5000;
private static readonly DEFERRED_LOAD_BATCH_SIZE = 4;
private static readonly DEFERRED_LOAD_BATCH_INTERVAL_MS = 1000;
private _idleLoadTaskId: number | null = null;
private _lazyLoadTimeoutId: number | null = null;
private _deferredFontsQueue: FontConfig[] = [];
private _deferredFontsCursor = 0;
private readonly _loadedFontKeys = new Set<string>();
readonly fontFaces: FontFace[] = [];
get ready() {
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
}
private readonly _fontKey = ({ font, weight, style, url }: FontConfig) => {
return `${font}:${weight}:${style}:${url}`;
};
private readonly _isCriticalCanvasFont = ({
font,
weight,
style,
}: FontConfig) => {
if (style !== FontStyle.Normal) return false;
if (font === FontFamily.Poppins) {
return (
weight === FontWeight.Regular ||
weight === FontWeight.Medium ||
weight === FontWeight.SemiBold
);
}
if (font === FontFamily.Inter) {
return weight === FontWeight.Regular || weight === FontWeight.SemiBold;
}
if (font === FontFamily.Kalam) {
// Mindmap style four uses bold Kalam text.
// We map to SemiBold because this is the strongest shipped Kalam weight.
return weight === FontWeight.SemiBold;
}
return false;
};
private readonly _scheduleDeferredLoad = (fonts: FontConfig[]) => {
if (fonts.length === 0 || typeof window === 'undefined') {
return;
}
this._deferredFontsQueue = fonts;
this._deferredFontsCursor = 0;
const win = window as Window & {
requestIdleCallback?: (
callback: () => void,
options?: { timeout?: number }
) => number;
cancelIdleCallback?: (handle: number) => void;
};
const scheduleBatch = (delayMs: number) => {
this._lazyLoadTimeoutId = window.setTimeout(() => {
this._lazyLoadTimeoutId = null;
const runBatch = () => {
this._idleLoadTaskId = null;
const start = this._deferredFontsCursor;
const end = Math.min(
start + FontLoaderService.DEFERRED_LOAD_BATCH_SIZE,
this._deferredFontsQueue.length
);
const batch = this._deferredFontsQueue.slice(start, end);
this._deferredFontsCursor = end;
this.load(batch);
if (this._deferredFontsCursor < this._deferredFontsQueue.length) {
scheduleBatch(FontLoaderService.DEFERRED_LOAD_BATCH_INTERVAL_MS);
}
};
if (typeof win.requestIdleCallback === 'function') {
this._idleLoadTaskId = win.requestIdleCallback(runBatch, {
timeout: 2000,
});
return;
}
runBatch();
}, delayMs);
};
scheduleBatch(FontLoaderService.DEFERRED_LOAD_DELAY_MS);
};
private readonly _cancelDeferredLoad = () => {
if (typeof window === 'undefined') {
return;
}
const win = window as Window & {
cancelIdleCallback?: (handle: number) => void;
};
if (
this._idleLoadTaskId !== null &&
typeof win.cancelIdleCallback === 'function'
) {
win.cancelIdleCallback(this._idleLoadTaskId);
this._idleLoadTaskId = null;
}
if (this._lazyLoadTimeoutId !== null) {
window.clearTimeout(this._lazyLoadTimeoutId);
this._lazyLoadTimeoutId = null;
}
this._deferredFontsQueue = [];
this._deferredFontsCursor = 0;
};
load(fonts: FontConfig[]) {
this.fontFaces.push(
...fonts.map(font => {
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
return fontFace;
})
);
for (const font of fonts) {
const key = this._fontKey(font);
if (this._loadedFontKeys.has(key)) {
continue;
}
this._loadedFontKeys.add(key);
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
this.fontFaces.push(fontFace);
}
}
override mounted() {
const config = this.std.getOptional(FontConfigIdentifier);
if (config) {
this.load(config);
if (!config || config.length === 0) {
return;
}
const criticalFonts = config.filter(this._isCriticalCanvasFont);
const eagerFonts =
criticalFonts.length > 0 ? criticalFonts : config.slice(0, 3);
const eagerFontKeySet = new Set(eagerFonts.map(this._fontKey));
const deferredFonts = config.filter(
font => !eagerFontKeySet.has(this._fontKey(font))
);
this.load(eagerFonts);
this._scheduleDeferredLoad(deferredFonts);
}
override unmounted() {
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
this._cancelDeferredLoad();
for (const fontFace of this.fontFaces) {
document.fonts.delete(fontFace);
}
this.fontFaces.splice(0, this.fontFaces.length);
this._loadedFontKeys.clear();
}
}

View File

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

View File

@@ -14,6 +14,17 @@ import {
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
type HoveredElemArea = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
padding: number;
containerWidth: number;
};
/**
* Used to control the drag handle visibility in edgeless mode
*
@@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
* 2. Multiple selection is not supported
*/
export class EdgelessWatcher {
private _pendingHoveredElemArea: HoveredElemArea | null = null;
private _lastAppliedHoveredElemArea: HoveredElemArea | null = null;
private _showDragHandleRafId: number | null = null;
private _surfaceElementUpdatedRafId: number | null = null;
private readonly _cloneArea = (area: HoveredElemArea): HoveredElemArea => ({
left: area.left,
top: area.top,
right: area.right,
bottom: area.bottom,
width: area.width,
height: area.height,
padding: area.padding,
containerWidth: area.containerWidth,
});
private readonly _isAreaEqual = (
left: HoveredElemArea | null,
right: HoveredElemArea | null
) => {
if (!left || !right) return false;
return (
left.left === right.left &&
left.top === right.top &&
left.right === right.right &&
left.bottom === right.bottom &&
left.width === right.width &&
left.height === right.height &&
left.padding === right.padding &&
left.containerWidth === right.containerWidth
);
};
private readonly _scheduleShowDragHandleFromSurfaceUpdate = () => {
if (this._surfaceElementUpdatedRafId !== null) return;
this._surfaceElementUpdatedRafId = requestAnimationFrame(() => {
this._surfaceElementUpdatedRafId = null;
if (!this.widget.isGfxDragHandleVisible) return;
this._showDragHandle();
});
};
private readonly _handleEdgelessToolUpdated = (
newTool: ToolOptionWithType
) => {
@@ -43,46 +100,123 @@ export class EdgelessWatcher {
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[0] !== center[0] ||
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
}
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle();
this._updateDragHoverRectTopLevelBlock();
const area = this.hoveredElemArea;
this._showDragHandle(area);
this._updateDragHoverRectTopLevelBlock(area);
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _showDragHandle = () => {
if (!this.widget.anchorBlockId) return;
private readonly _flushShowDragHandle = () => {
this._showDragHandleRafId = null;
if (!this.widget.anchorBlockId.peek()) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoveredElemArea;
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
this._pendingHoveredElemArea = null;
if (!area) return;
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.left = `${area.left}px`;
container.style.top = `${area.top}px`;
container.style.display = 'flex';
if (
this.widget.isGfxDragHandleVisible &&
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
) {
return;
}
if (container.style.transition !== 'none') {
container.style.transition = 'none';
}
const nextPaddingTop = '0px';
if (container.style.paddingTop !== nextPaddingTop) {
container.style.paddingTop = nextPaddingTop;
}
const nextPaddingBottom = '0px';
if (container.style.paddingBottom !== nextPaddingBottom) {
container.style.paddingBottom = nextPaddingBottom;
}
const nextLeft = `${area.left}px`;
if (container.style.left !== nextLeft) {
container.style.left = nextLeft;
}
const nextTop = `${area.top}px`;
if (container.style.top !== nextTop) {
container.style.top = nextTop;
}
if (container.style.display !== 'flex') {
container.style.display = 'flex';
}
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
this._lastAppliedHoveredElemArea = this._cloneArea(area);
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
const nextArea = area ?? this.hoveredElemArea;
this._pendingHoveredElemArea = nextArea;
if (!this._pendingHoveredElemArea) {
return;
}
if (
this.widget.isGfxDragHandleVisible &&
this._showDragHandleRafId === null &&
this._isAreaEqual(
this._lastAppliedHoveredElemArea,
this._pendingHoveredElemArea
)
) {
return;
}
if (this._showDragHandleRafId !== null) {
return;
}
this._showDragHandleRafId = requestAnimationFrame(
this._flushShowDragHandle
);
};
private readonly _updateDragHoverRectTopLevelBlock = (
area?: HoveredElemArea | null
) => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
const nextArea = area ?? this.hoveredElemArea;
if (!nextArea) {
this.widget.dragHoverRect = null;
return;
}
const nextRect = new Rect(
nextArea.left,
nextArea.top,
nextArea.right,
nextArea.bottom
);
const prevRect = this.widget.dragHoverRect;
if (
prevRect &&
prevRect.left === nextRect.left &&
prevRect.top === nextRect.top &&
prevRect.width === nextRect.width &&
prevRect.height === nextRect.height
) {
return;
}
this.widget.dragHoverRect = nextRect;
};
get gfx() {
@@ -123,7 +257,7 @@ export class EdgelessWatcher {
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea() {
get hoveredElemArea(): HoveredElemArea | null {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
@@ -174,6 +308,19 @@ export class EdgelessWatcher {
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
);
disposables.add(() => {
if (this._showDragHandleRafId !== null) {
cancelAnimationFrame(this._showDragHandleRafId);
this._showDragHandleRafId = null;
}
if (this._surfaceElementUpdatedRafId !== null) {
cancelAnimationFrame(this._surfaceElementUpdatedRafId);
this._surfaceElementUpdatedRafId = null;
}
this._pendingHoveredElemArea = null;
this._lastAppliedHoveredElemArea = null;
});
disposables.add(
selection.slots.updated.subscribe(() => {
this.updateAnchorElement();
@@ -216,7 +363,7 @@ export class EdgelessWatcher {
this.widget.hide();
}
if (payload.type === 'update') {
this._showDragHandle();
this._scheduleShowDragHandleFromSurfaceUpdate();
}
}
})
@@ -224,9 +371,10 @@ export class EdgelessWatcher {
if (surface) {
disposables.add(
surface.elementUpdated.subscribe(() => {
surface.elementUpdated.subscribe(({ id }) => {
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle();
if (id !== this.widget.anchorBlockId.peek()) return;
this._scheduleShowDragHandleFromSurfaceUpdate();
}
})
);

View File

@@ -153,6 +153,10 @@ export class PointerEventWatcher {
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
private _lastPointerHitBlockId: string | null = null;
private _lastPointerHitBlockElement: Element | null = null;
/**
* When pointer move on block, should show drag handle
* And update hover block id and path
@@ -169,6 +173,7 @@ export class PointerEventWatcher {
point
);
if (!closestBlock) {
this._lastPointerHitBlockId = null;
this.widget.anchorBlockId.value = null;
return;
}
@@ -237,19 +242,38 @@ export class PointerEventWatcher {
const state = ctx.get('pointerState');
// When pointer is moving, should do nothing
if (state.delta.x !== 0 && state.delta.y !== 0) return;
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) return;
if (!element) {
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
return;
}
// When pointer on drag handle, should do nothing
if (element.closest('.affine-drag-handle-container')) return;
if (!this.widget.rootComponent) return;
const hitBlock = element.closest(`[${BLOCK_ID_ATTR}]`);
const hitBlockId = hitBlock?.getAttribute(BLOCK_ID_ATTR) ?? null;
// Pointer move events are high-frequency. If hovered block identity is
// unchanged and the underlying block element is the same, skip the
// closest-note lookup.
if (
hitBlockId &&
this.widget.isBlockDragHandleVisible &&
hitBlockId === this._lastPointerHitBlockId &&
hitBlock === this._lastPointerHitBlockElement &&
isBlockIdEqual(this.widget.anchorBlockId.peek(), hitBlockId)
) {
return;
}
this._lastPointerHitBlockId = hitBlockId;
this._lastPointerHitBlockElement = hitBlock;
// When pointer out of note block hover area or inside database, should hide drag handle
const point = new Point(state.raw.x, state.raw.y);
@@ -354,6 +378,8 @@ export class PointerEventWatcher {
reset() {
this._lastHoveredBlockId = null;
this._lastShowedBlock = null;
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
}
watch() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js';
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
export class RenderService<TextAttributes extends BaseTextAttributes> {
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
private _pendingRemoteInlineRangeSync = false;
const yText = this.editor.yText;
private _carriageReturnValidationCounter = 0;
if (yText.toString().includes('\r')) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
this.render();
private _renderVersion = 0;
private readonly _syncRemoteInlineRange = () => {
const inlineRange = this.editor.inlineRange$.peek();
if (!inlineRange || transaction.local) return;
if (!inlineRange) return;
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
@@ -50,7 +40,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
const startIndex = absoluteStart?.index;
const endIndex = absoluteEnd?.index;
if (!startIndex || !endIndex) return;
if (startIndex == null || endIndex == null) return;
const newInlineRange: InlineRange = {
index: startIndex,
@@ -59,7 +49,31 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
if (!this.editor.isValidInlineRange(newInlineRange)) return;
this.editor.setInlineRange(newInlineRange);
this.editor.syncInlineRange();
};
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
const yText = this.editor.yText;
if (
(this._carriageReturnValidationCounter++ & 0x3f) === 0 &&
yText.toString().includes('\r')
) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
if (!transaction.local) {
this._pendingRemoteInlineRangeSync = true;
}
this.render();
};
mount = () => {
@@ -70,6 +84,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
editor.disposables.add({
dispose: () => {
yText.unobserve(this._onYTextChange);
this._pendingRemoteInlineRangeSync = false;
},
});
};
@@ -82,6 +97,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
render = () => {
if (!this.editor.rootElement) return;
const renderVersion = ++this._renderVersion;
this._rendering = true;
const rootElement = this.editor.rootElement;
@@ -152,11 +168,21 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
this.editor
.waitForUpdate()
.then(() => {
if (renderVersion !== this._renderVersion) return;
if (this._pendingRemoteInlineRangeSync) {
this._pendingRemoteInlineRangeSync = false;
this._syncRemoteInlineRange();
}
this._rendering = false;
this.editor.slots.renderComplete.next();
this.editor.syncInlineRange();
})
.catch(console.error);
.catch(error => {
if (renderVersion === this._renderVersion) {
this._rendering = false;
}
console.error(error);
});
};
rerenderWholeEditor = () => {

View File

@@ -9,7 +9,12 @@ import {
isVElement,
isVLine,
} from './guard.js';
import { calculateTextLength, getTextNodesFromElement } from './text.js';
import {
calculateTextLength,
getInlineRootTextCache,
getTextNodesFromElement,
invalidateInlineRootTextCache,
} from './text.js';
export function nativePointToTextPoint(
node: unknown,
@@ -67,19 +72,6 @@ export function textPointToDomPoint(
if (!rootElement.contains(text)) return null;
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
let index = 0;
for (const text of texts.slice(0, goalIndex)) {
index += calculateTextLength(text);
}
if (text.wholeText !== ZERO_WIDTH_FOR_EMPTY_LINE) {
index += offset;
}
const textParentElement = text.parentElement;
if (!textParentElement) {
throw new BlockSuiteError(
@@ -97,9 +89,44 @@ export function textPointToDomPoint(
);
}
const textOffset = text.wholeText === ZERO_WIDTH_FOR_EMPTY_LINE ? 0 : offset;
for (let attempt = 0; attempt < 2; attempt++) {
const { textNodes, textNodeIndexMap, prefixLengths, lineIndexMap } =
getInlineRootTextCache(rootElement);
if (textNodes.length === 0) return null;
const goalIndex = textNodeIndexMap.get(text);
const lineIndex = lineIndexMap.get(lineElement);
if (goalIndex !== undefined && lineIndex !== undefined) {
const index = (prefixLengths[goalIndex] ?? 0) + textOffset;
return { text, index: index + lineIndex };
}
if (attempt === 0) {
// MutationObserver marks cache dirty asynchronously; force one sync retry
// when a newly-added node is queried within the same task.
invalidateInlineRootTextCache(rootElement);
}
}
// Fallback to linear scan when cache still misses. This keeps behavior
// stable even if MutationObserver-based invalidation lags behind.
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
if (goalIndex < 0) return null;
let index = textOffset;
for (const beforeText of texts.slice(0, goalIndex)) {
index += calculateTextLength(beforeText);
}
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
lineElement
);
if (lineIndex < 0) return null;
return { text, index: index + lineIndex };
}

View File

@@ -8,6 +8,92 @@ export function calculateTextLength(text: Text): number {
}
}
type InlineRootTextCache = {
dirty: boolean;
observer: MutationObserver | null;
textNodes: Text[];
textNodeIndexMap: WeakMap<Text, number>;
prefixLengths: number[];
lineIndexMap: WeakMap<Element, number>;
};
const inlineRootTextCaches = new WeakMap<HTMLElement, InlineRootTextCache>();
const buildInlineRootTextCache = (
rootElement: HTMLElement,
cache: InlineRootTextCache
) => {
const textSpanElements = Array.from(
rootElement.querySelectorAll('[data-v-text="true"]')
);
const textNodes: Text[] = [];
const textNodeIndexMap = new WeakMap<Text, number>();
const prefixLengths: number[] = [];
let prefixLength = 0;
for (const textSpanElement of textSpanElements) {
const textNode = Array.from(textSpanElement.childNodes).find(
(node): node is Text => node instanceof Text
);
if (!textNode) continue;
prefixLengths.push(prefixLength);
textNodeIndexMap.set(textNode, textNodes.length);
textNodes.push(textNode);
prefixLength += calculateTextLength(textNode);
}
const lineIndexMap = new WeakMap<Element, number>();
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
for (const [index, line] of lineElements.entries()) {
lineIndexMap.set(line, index);
}
cache.textNodes = textNodes;
cache.textNodeIndexMap = textNodeIndexMap;
cache.prefixLengths = prefixLengths;
cache.lineIndexMap = lineIndexMap;
cache.dirty = false;
};
export function invalidateInlineRootTextCache(rootElement: HTMLElement) {
const cache = inlineRootTextCaches.get(rootElement);
if (cache) {
cache.dirty = true;
}
}
export function getInlineRootTextCache(rootElement: HTMLElement) {
let cache = inlineRootTextCaches.get(rootElement);
if (!cache) {
cache = {
dirty: true,
observer: null,
textNodes: [],
textNodeIndexMap: new WeakMap(),
prefixLengths: [],
lineIndexMap: new WeakMap(),
};
inlineRootTextCaches.set(rootElement, cache);
}
if (!cache.observer && typeof MutationObserver !== 'undefined') {
cache.observer = new MutationObserver(() => {
cache!.dirty = true;
});
cache.observer.observe(rootElement, {
subtree: true,
childList: true,
characterData: true,
});
}
if (cache.dirty) {
buildInlineRootTextCache(rootElement, cache);
}
return cache;
}
export function getTextNodesFromElement(element: Element): Text[] {
const textSpanElements = Array.from(
element.querySelectorAll('[data-v-text="true"]')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,10 @@ describe('frame', () => {
expect(rect!.width).toBeGreaterThan(0);
expect(rect!.height).toBeGreaterThan(0);
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
const [titleX, titleY] = service.viewport.toModelCoordFromClientCoord([
rect!.x,
rect!.y,
]);
expect(titleX).toBeCloseTo(0);
expect(titleY).toBeLessThan(0);
@@ -66,10 +69,11 @@ describe('frame', () => {
if (!nestedTitle) return;
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
nestedTitleRect.x,
nestedTitleRect.y
);
const [nestedTitleX, nestedTitleY] =
service.viewport.toModelCoordFromClientCoord([
nestedTitleRect.x,
nestedTitleRect.y,
]);
expect(nestedTitleX).toBeGreaterThan(20);
expect(nestedTitleY).toBeGreaterThan(20);

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,28 @@
import { getCurrentUserQuery } from '@affine/graphql';
import { JobExecutor } from '../../../base/job/queue/executor';
import { DatabaseDocReader, DocReader } from '../../../core/doc';
import { createApp } from '../create-app';
import { e2e } from '../test';
e2e('should init doc service', async t => {
type TestFlavor = 'doc' | 'graphql' | 'sync' | 'renderer' | 'front';
const createFlavorApp = async (flavor: TestFlavor) => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'doc';
await using app = await createApp();
globalThis.env.FLAVOR = flavor;
return await createApp({
tapModule(module) {
module.overrideProvider(JobExecutor).useValue({
onConfigInit: async () => {},
onConfigChanged: async () => {},
onModuleDestroy: async () => {},
});
},
});
};
e2e('should init doc service', async t => {
await using app = await createFlavorApp('doc');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'doc');
@@ -15,9 +31,7 @@ e2e('should init doc service', async t => {
});
e2e('should init graphql service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'graphql';
await using app = await createApp();
await using app = await createFlavorApp('graphql');
const res = await app.GET('/info').expect(200);
@@ -28,28 +42,25 @@ e2e('should init graphql service', async t => {
});
e2e('should init sync service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'sync';
await using app = await createApp();
await using app = await createFlavorApp('sync');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'sync');
});
e2e('should init renderer service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'renderer';
await using app = await createApp();
await using app = await createFlavorApp('renderer');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'renderer');
});
e2e('should init front service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'front';
await using app = await createApp();
await using app = await createFlavorApp('front');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'front');
const docReader = app.get(DocReader);
t.true(docReader instanceof DatabaseDocReader);
});

View File

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

View File

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

View File

@@ -159,8 +159,11 @@ export function buildAppModule(env: Env) {
// basic
.use(...FunctionalityModules)
// enable indexer module on graphql server and doc service
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
// enable indexer module on graphql, doc and front service
.useIf(
() => env.flavors.graphql || env.flavors.doc || env.flavors.front,
IndexerModule
)
// auth
.use(UserModule, AuthModule, PermissionModule)
@@ -202,8 +205,8 @@ export function buildAppModule(env: Env) {
AccessTokenModule,
QueueDashboardModule
)
// doc service only
.useIf(() => env.flavors.doc, DocServiceModule)
// doc service and front service
.useIf(() => env.flavors.doc || env.flavors.front, DocServiceModule)
// worker for and self-hosted API only for self-host and local development only
.useIf(() => env.dev || env.selfhosted, WorkerModule, SelfhostModule)
// static frontend routes for front flavor

View File

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

View File

@@ -31,8 +31,8 @@ export class JobExecutor implements OnModuleDestroy {
? difference(QUEUES, [Queue.DOC, Queue.INDEXER])
: [];
// NOTE(@forehalo): only enable doc queue in doc service
if (env.flavors.doc) {
// Enable doc/indexer queues in both doc and front service.
if (env.flavors.doc || env.flavors.front) {
queues.push(Queue.DOC);
// NOTE(@fengmk2): Once the index task cannot be processed in time, it needs to be separated from the doc service and deployed independently.
queues.push(Queue.INDEXER);

View File

@@ -175,7 +175,7 @@ export class R2StorageProvider extends S3StorageProvider {
body: Readable | Buffer | Uint8Array | string,
options: { contentType?: string; contentLength?: number } = {}
) {
return this.client.putObject(key, body as any, {
return this.client.putObject(key, this.normalizeBody(body), {
contentType: options.contentType,
contentLength: options.contentLength,
});
@@ -192,13 +192,24 @@ export class R2StorageProvider extends S3StorageProvider {
key,
uploadId,
partNumber,
body as any,
this.normalizeBody(body),
{ contentLength: options.contentLength }
);
return result.etag;
}
private normalizeBody(body: Readable | Buffer | Uint8Array | string) {
// s3mini does not accept Node.js Readable directly.
// Convert it to Web ReadableStream for compatibility.
if (body instanceof Readable) {
return Readable.toWeb(body);
} else if (typeof body === 'string') {
return this.encoder.encode(body);
}
return body;
}
override async get(
key: string,
signedUrl?: boolean

View File

@@ -281,7 +281,7 @@ export class S3StorageProvider implements StorageProvider {
this.logger.verbose(`Read object \`${key}\``);
return {
body: Readable.fromWeb(obj.body as any),
body: Readable.fromWeb(obj.body),
metadata: {
contentType: contentType ?? 'application/octet-stream',
contentLength: contentLength ?? 0,

View File

@@ -22,12 +22,14 @@ function firstNonEmpty(...values: Array<string | undefined>) {
}
export function getRequestClientIp(req: Request) {
return firstNonEmpty(
req.get('CF-Connecting-IP'),
firstForwardedForIp(req.get('X-Forwarded-For')),
req.get('X-Real-IP'),
req.ip
)!;
return (
firstNonEmpty(
req.get('CF-Connecting-IP'),
firstForwardedForIp(req.get('X-Forwarded-For')),
req.get('X-Real-IP'),
req.ip
) ?? ''
);
}
export function getRequestTrackerId(req: Request) {
@@ -39,6 +41,7 @@ export function getRequestTrackerId(req: Request) {
req.get('X-Real-IP'),
req.get('CF-Ray'),
req.ip
)!
) ??
''
);
}

View File

@@ -180,7 +180,7 @@ export async function assertSsrFSafeUrl(
let addresses: string[];
try {
addresses = await resolveHostAddresses(hostname);
} catch (error) {
} catch {
throw createSsrfBlockedError('unresolvable_hostname', {
url: url.toString(),
hostname,

View File

@@ -109,3 +109,45 @@ test('should record page view when rendering shared page', async t => {
docContent.restore();
record.restore();
});
test('should return markdown content and skip page view when accept is text/markdown', async t => {
const docId = randomUUID();
const { app, adapter, models, docReader } = t.context;
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'markdown');
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
await models.doc.publish(workspace.id, docId);
const markdown = Sinon.stub(docReader, 'getDocMarkdown').resolves({
title: 'markdown-doc',
markdown: '# markdown-doc',
});
const docContent = Sinon.stub(docReader, 'getDocContent');
const record = Sinon.stub(
models.workspaceAnalytics,
'recordDocView'
).resolves();
const res = await app
.GET(`/workspace/${workspace.id}/${docId}`)
.set('accept', 'text/markdown')
.expect(200);
t.true(markdown.calledOnceWithExactly(workspace.id, docId, false));
t.is(res.text, '# markdown-doc');
t.true((res.headers['content-type'] as string).startsWith('text/markdown'));
t.true(docContent.notCalled);
t.true(record.notCalled);
markdown.restore();
docContent.restore();
record.restore();
});

View File

@@ -44,6 +44,12 @@ const staticPaths = new Set([
'trash',
]);
const markdownType = new Set([
'text/markdown',
'application/markdown',
'text/x-markdown',
]);
@Controller('/workspace')
export class DocRendererController {
private readonly logger = new Logger(DocRendererController.name);
@@ -68,6 +74,21 @@ export class DocRendererController {
.digest('hex');
}
private async allowDocPreview(workspaceId: string, docId: string) {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) return false;
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
if (!allowUrlPreview) {
// if page is private, but workspace url preview is on
allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
}
return allowUrlPreview;
}
@Public()
@Get('/*path')
async render(@Req() req: Request, @Res() res: Response) {
@@ -81,28 +102,55 @@ export class DocRendererController {
let opts: RenderOptions | null = null;
// /workspace/:workspaceId/{:docId | staticPaths}
const [, , workspaceId, subPath, ...restPaths] = req.path.split('/');
const [, , workspaceId, sub, ...rest] = req.path.split('/');
const isWorkspace =
workspaceId && sub && !staticPaths.has(sub) && rest.length === 0;
const isDocPath = isWorkspace && workspaceId !== sub;
if (
isDocPath &&
req.accepts().some(t => markdownType.has(t.toLowerCase()))
) {
try {
const allowPreview = await this.allowDocPreview(workspaceId, sub);
if (!allowPreview) {
res.status(404).end();
return;
}
const markdown = await this.doc.getDocMarkdown(workspaceId, sub, false);
if (markdown) {
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.send(markdown.markdown);
return;
}
} catch (e) {
this.logger.error('failed to render markdown page', e);
}
res.status(404).end();
return;
}
// /:workspaceId/:docId
if (workspaceId && !staticPaths.has(subPath) && restPaths.length === 0) {
if (isWorkspace) {
try {
opts =
workspaceId === subPath
? await this.getWorkspaceContent(workspaceId)
: await this.getPageContent(workspaceId, subPath);
opts = isDocPath
? await this.getPageContent(workspaceId, sub)
: await this.getWorkspaceContent(workspaceId);
metrics.doc.counter('render').add(1);
if (opts && workspaceId !== subPath) {
if (opts && isDocPath) {
void this.models.workspaceAnalytics
.recordDocView({
workspaceId,
docId: subPath,
visitorId: this.buildVisitorId(req, workspaceId, subPath),
docId: sub,
visitorId: this.buildVisitorId(req, workspaceId, sub),
isGuest: true,
})
.catch(error => {
this.logger.warn(
`Failed to record shared page view: ${workspaceId}/${subPath}`,
`Failed to record shared page view: ${workspaceId}/${sub}`,
error as Error
);
});
@@ -124,20 +172,7 @@ export class DocRendererController {
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) {
return null;
}
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
if (!allowUrlPreview) {
// if page is private, but workspace url preview is on
allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
}
if (allowUrlPreview) {
if (await this.allowDocPreview(workspaceId, docId)) {
return this.doc.getDocContent(workspaceId, docId);
}

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