Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
a6d937ddc3 chore: bump up apollographql/apollo-ios version to v1.25.3 2026-02-08 07:58:40 +00:00
379 changed files with 5115 additions and 18119 deletions

View File

@@ -25,30 +25,30 @@ const buildType = BUILD_TYPE || 'canary';
const isProduction = buildType === 'stable';
const isBeta = buildType === 'beta';
const isCanary = buildType === 'canary';
const isInternal = buildType === 'internal';
const isSpotEnabled = isBeta || isCanary;
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 },
canary: { front: 1, graphql: 1, doc: 1 },
};
const cpuConfig = {
beta: { front: '1', graphql: '1' },
canary: { front: '500m', graphql: '1' },
beta: { front: '1', graphql: '1', doc: '1' },
canary: { front: '500m', graphql: '1', doc: '500m' },
};
const memoryConfig = {
beta: { front: '2Gi', graphql: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi' },
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
};
const createHelmCommand = ({ isDryRun }) => {
@@ -72,12 +72,10 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
];
const cloudSqlNodeSelector = isBeta
? `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\", \\"cloud.google.com/gke-spot\\": \\"true\\" }`
: `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }`;
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
? [
@@ -86,17 +84,10 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json graphql.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
`--set-json cloud-sql-proxy.nodeSelector="${cloudSqlNodeSelector}"`,
`--set-json cloud-sql-proxy.nodeSelector="{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }"`,
]
: []
);
const spotNodeSelector = `{ \\"cloud.google.com/gke-spot\\": \\"true\\" }`;
const spotScheduling = isSpotEnabled
? [
`--set-json front.nodeSelector="${spotNodeSelector}"`,
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
]
: [];
const cpu = cpuConfig[buildType];
const memory = memoryConfig[buildType];
@@ -105,12 +96,14 @@ 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}"`,
]);
}
@@ -149,8 +142,10 @@ 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,
`--timeout 10m`,
flag,

View File

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,63 @@
{{/*
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

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

View File

@@ -0,0 +1,12 @@
{{- 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

@@ -0,0 +1,15 @@
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

@@ -30,12 +30,9 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi
memory: 4Gi
probe:
initialDelaySeconds: 20

View File

@@ -88,6 +88,8 @@ 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

@@ -29,9 +29,6 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 2Gi
requests:
cpu: '1'
memory: 2Gi
@@ -57,9 +54,6 @@ services:
type: ClusterIP
port: 8080
annotations: {}
doc:
type: ClusterIP
annotations: {}
nodeSelector: {}
tolerations: []

View File

@@ -27,11 +27,8 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
cpu: '2'
memory: 2Gi
probe:

View File

@@ -47,6 +47,12 @@ 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:
@@ -65,7 +71,3 @@ front:
name: affine-web
type: ClusterIP
port: 8080
doc:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'

View File

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

View File

@@ -210,13 +210,18 @@ 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: 'chromium,firefox,webkit'
playwright-platform: ${{ matrix.browser }}
electron-install: false
full-cache: true
@@ -224,64 +229,18 @@ jobs:
run: yarn workspace @blocksuite/playground build
- name: Run playwright tests
run: |
yarn workspace @blocksuite/integration-test test:unit
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
env:
BROWSER: ${{ matrix.browser }}
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-bs-cross-browser
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
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
@@ -348,7 +307,7 @@ jobs:
name: Unit Test
runs-on: ubuntu-latest
needs:
- build-native-linux
- build-native
env:
DISTRIBUTION: web
strategy:
@@ -362,7 +321,6 @@ jobs:
with:
electron-install: true
playwright-install: true
playwright-platform: 'chromium,firefox,webkit'
full-cache: true
- name: Download affine.linux-x64-gnu.node
@@ -383,39 +341,7 @@ jobs:
name: affine
fail_ci_if_error: false
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:
build-native:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -424,6 +350,7 @@ 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 }
@@ -456,7 +383,7 @@ jobs:
# Split Windows build because it's too slow
# and other ci jobs required linux native
build-native-windows:
build-windows-native:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -556,7 +483,7 @@ jobs:
name: Native Unit Test
runs-on: ubuntu-latest
needs:
- build-native-linux
- build-native
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -650,6 +577,8 @@ 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
@@ -890,51 +819,11 @@ 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
@@ -968,29 +857,53 @@ 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 }}
@@ -1001,7 +914,6 @@ 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
@@ -1016,7 +928,6 @@ jobs:
shardTotal: [5]
needs:
- build-server-native
- copilot-test-filter
services:
postgres:
image: pgvector/pgvector:pg16
@@ -1040,7 +951,30 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: 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
@@ -1049,17 +983,20 @@ 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 }}
@@ -1069,7 +1006,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
- build-native-linux
- build-native
env:
DISTRIBUTION: web
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -1162,9 +1099,7 @@ jobs:
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native-linux
- build-native-macos
- build-native-windows
- build-native
strategy:
fail-fast: false
matrix:
@@ -1247,6 +1182,84 @@ 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:
@@ -1286,14 +1299,6 @@ 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
@@ -1307,9 +1312,8 @@ jobs:
- e2e-blocksuite-cross-browser-test
- e2e-mobile-test
- unit-test
- build-native-linux
- build-native-macos
- build-native-windows
- build-native
- build-windows-native
- build-server-native
- build-electron-renderer
- native-unit-test
@@ -1319,10 +1323,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,7 +16,6 @@ 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,44 +201,13 @@ jobs:
nmHoistingLimits: workspaces
env:
npm_config_arch: ${{ matrix.spec.arch }}
- 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
- name: Download and overwrite packaged artifacts
uses: actions/download-artifact@v4
with:
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
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)"
}
}
}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out
- name: Make squirrel.windows installer
run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
@@ -298,44 +267,13 @@ jobs:
arch: arm64
runs-on: ${{ matrix.spec.runner }}
steps:
- 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
- name: Download and overwrite installer artifacts
uses: actions/download-artifact@v4
with:
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
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)"
}
}
}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
- name: Save artifacts
run: |

View File

@@ -30,43 +30,13 @@ jobs:
run: |
cd ${{ env.ARCHIVE_DIR }}/out
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
- name: collect signed file diff
shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0}
- name: zip file
shell: cmd
run: |
$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."
cd ${{ env.ARCHIVE_DIR }}
7za a signed.zip .\out\*
- name: upload
uses: actions/upload-artifact@v4
with:
name: signed-${{ inputs.artifact-name }}
path: ${{ env.ARCHIVE_DIR }}/signed-diff
path: ${{ env.ARCHIVE_DIR }}/signed.zip

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, .oxlintrc.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, 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,157 +2101,6 @@ 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

@@ -1,95 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { describe, expect, test } from 'vitest';
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
type InsertCall = {
range: {
index: number;
length: number;
};
text: string;
attributes?: AffineTextAttributes;
};
describe('insertUrlTextSegments', () => {
test('should replace selected text on first insert and append remaining segments', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 4, length: 6 };
const segments = [
{ text: 'hi - ' },
{ text: 'https://google.com', link: 'https://google.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 4, length: 6 },
text: 'hi - ',
},
{
range: { index: 9, length: 0 },
text: 'https://google.com',
attributes: {
link: 'https://google.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
});
test('should keep insertion range length zero when there is no selected text', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 2, length: 0 };
const segments = [
{ text: 'prefix ' },
{ text: 'https://a.com', link: 'https://a.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 2, length: 0 },
text: 'prefix ',
},
{
range: { index: 9, length: 0 },
text: 'https://a.com',
attributes: {
link: 'https://a.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
});
});

View File

@@ -135,10 +135,14 @@ export class DatabaseBlockDataSource extends DataSourceBase {
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const enableNumberFormat = featureFlagService.getFlag(
'enable_database_number_formatting'
);
const enableTableVirtualScroll = featureFlagService.getFlag(
'enable_table_virtual_scroll'
);
return {
enable_number_formatting: enableNumberFormat ?? false,
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
};
});

View File

@@ -1,56 +0,0 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
splitTextByUrl,
type UrlTextSegment,
} from '@blocksuite/affine-shared/utils';
import type { InlineRange } from '@blocksuite/std/inline';
type UrlPasteInlineEditor = Pick<
AffineInlineEditor,
'insertText' | 'setInlineRange'
>;
export function analyzeTextForUrlPaste(text: string) {
const segments = splitTextByUrl(text);
const firstSegment = segments[0];
const singleUrl =
segments.length === 1 && firstSegment?.link && firstSegment.text === text
? firstSegment.link
: undefined;
return {
segments,
singleUrl,
};
}
export function insertUrlTextSegments(
inlineEditor: UrlPasteInlineEditor,
inlineRange: InlineRange,
segments: UrlTextSegment[]
) {
let index = inlineRange.index;
let replacedSelection = false;
segments.forEach(segment => {
if (!segment.text) return;
const attributes: AffineTextAttributes | undefined = segment.link
? { link: segment.link }
: undefined;
inlineEditor.insertText(
{
index,
length: replacedSelection ? 0 : inlineRange.length,
},
segment.text,
attributes
);
replacedSelection = true;
index += segment.text.length;
});
inlineEditor.setInlineRange({
index,
length: 0,
});
}

View File

@@ -8,7 +8,10 @@ import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
@@ -23,7 +26,6 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
richTextCellStyle,
richTextContainerStyle,
@@ -269,13 +271,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
if (isValidUrl(text)) {
const std = this.std;
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
@@ -301,10 +300,22 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
return;
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
override connectedCallback() {

View File

@@ -4,7 +4,10 @@ import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
@@ -17,7 +20,6 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
headerAreaIconStyle,
titleCellStyle,
@@ -93,9 +95,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineEditor || !inlineRange) return;
e.preventDefault();
e.stopPropagation();
if (!inlineRange) return;
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
@@ -121,15 +121,14 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const std = this.std;
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
inlineEditor?.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
@@ -140,7 +139,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
},
},
});
inlineEditor.setInlineRange({
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
@@ -152,10 +151,22 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
return;
} else {
inlineEditor?.insertText(inlineRange, text, {
link: text,
});
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor?.insertText(inlineRange, text);
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
insertDelta = (delta: DeltaInsert) => {
@@ -229,8 +240,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste,
true
this._onPaste
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {

View File

@@ -26,11 +26,6 @@ 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;
@@ -68,11 +63,6 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
width: 100%;
height: 100%;
}
affine-edgeless-image .resizable-img {
position: relative;
overflow: hidden;
}
`;
resourceController = new ResourceController(
@@ -80,12 +70,6 @@ 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;
}
@@ -112,134 +96,6 @@ 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();
@@ -252,32 +108,14 @@ 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',
@@ -300,13 +138,6 @@ 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}>
@@ -318,7 +149,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
class="drag-target"
draggable="false"
loading="lazy"
src=${imageUrl ?? ''}
src=${blobUrl}
alt=${caption}
@error=${this._handleError}
/>

View File

@@ -37,126 +37,6 @@ 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 =>
@@ -208,37 +88,41 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
!tagsInAncestor(o, ['p', 'li']) &&
HastUtils.isParagraphLike(o.node)
) {
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
openParagraphBlocks(deltas, 'text', walkerContext);
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();
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,
type: walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text',
text: {
'$blocksuite:internal:text$': true,
delta,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
@@ -308,9 +192,6 @@ 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,7 +86,6 @@ export class PageClipboard extends ReadOnlyClipboard {
if (this.std.store.readonly) return;
this.std.store.captureSync();
let hasPasteTarget = false;
this.std.command
.chain()
.try<{}>(cmd => [
@@ -145,39 +144,18 @@ 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 !== undefined ? ctx.blockIndex + 1 : 1
ctx.blockIndex ? 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,11 +33,7 @@ import {
ReleaseFromGroupIcon,
UnlockIcon,
} from '@blocksuite/icons/lit';
import {
batchAddChildren,
batchRemoveChildren,
type GfxModel,
} from '@blocksuite/std/gfx';
import type { GfxModel } from '@blocksuite/std/gfx';
import { html } from 'lit';
import { renderAlignmentMenu } from './alignment';
@@ -65,13 +61,14 @@ export const builtinMiscToolbarConfig = {
const group = firstModel.group;
batchRemoveChildren(group, [firstModel]);
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(firstModel);
firstModel.index = ctx.gfx.layer.generateIndex();
const parent = group.group;
if (parent && parent instanceof GroupElementModel) {
batchAddChildren(parent, [firstModel]);
parent.addChild(firstModel);
}
},
},
@@ -258,12 +255,9 @@ export const builtinMiscToolbarConfig = {
// release other elements from their groups and group with top element
otherElements.forEach(element => {
if (element.group) {
batchRemoveChildren(element.group, [element]);
}
if (topElement.group) {
batchAddChildren(topElement.group, [element]);
}
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
element.group?.removeChild(element);
topElement.group?.addChild(element);
});
if (otherElements.length === 0) {

View File

@@ -40,146 +40,10 @@ 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 }) => {
@@ -188,31 +52,13 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
}
getConnectors(id: string) {
const connectorIds = this._connectorIdsByEndpoint.get(id);
const connectors = this.getElementsByType(
'connector'
) as unknown[] as ConnectorElementModel[];
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;
return connectors.filter(
connector => connector.source?.id === id || connector.target?.id === id
);
}
override getElementsByType<K extends keyof SurfaceElementModelMap>(

View File

@@ -3,10 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
@@ -458,60 +456,4 @@ describe('kanban', () => {
expect(next?.hideEmpty).toBe(true);
});
});
describe('detail selection', () => {
it('should avoid recursive selection update when exiting select edit mode', () => {
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof requestAnimationFrame);
try {
let selection: DetailSelection;
let beforeExitCalls = 0;
const cell = {
beforeEnterEditMode: () => true,
beforeExitEditingMode: () => {
beforeExitCalls += 1;
selection.selection = {
propertyId: 'status',
isEditing: false,
};
},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
} satisfies DataViewCellLifeCycle;
const field = {
isFocus$: signal(false),
isEditing$: signal(false),
cell,
focus: () => {},
blur: () => {},
};
const detail = {
querySelector: () => field,
};
selection = new DetailSelection(detail);
selection.selection = {
propertyId: 'status',
isEditing: true,
};
selection.selection = {
propertyId: 'status',
isEditing: false,
};
expect(beforeExitCalls).toBe(1);
expect(field.isEditing$.value).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});

View File

@@ -1,101 +0,0 @@
import { describe, expect, test } from 'vitest';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);
const schemaFormats = NumberFormatSchema.options;
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
expect(menuFormats).toHaveLength(schemaFormats.length);
});
test('formats grouped decimal numbers with Intl grouping rules', () => {
const value = 11451.4;
const decimals = 1;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
});
test('formats percent values with Intl percent rules', () => {
const value = 0.1234;
const decimals = 2;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'percent',
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
});
test('formats currency values with Intl currency rules', () => {
const value = 11451.4;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
}).format(value);
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
});
test('parses grouped number string pasted from clipboard', () => {
expect(parseNumber('11,451.4')).toBe(11451.4);
});
test('keeps regular decimal parsing', () => {
expect(parseNumber('123.45')).toBe(123.45);
});
test('supports comma as decimal separator in locale-specific input', () => {
expect(parseNumber('11451,4', ',')).toBe(11451.4);
});
});

View File

@@ -22,6 +22,7 @@ import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { cacheComputed } from './utils/cache.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
@@ -74,38 +75,12 @@ export class DataViewRootUILogic {
return new (logic(view))(this, view);
}
private readonly _viewsCache = new Map<
string,
{ mode: string; logic: DataViewUILogicBase }
>();
private readonly views$ = computed(() => {
const viewDataList = this.dataSource.viewDataList$.value;
const validIds = new Set(viewDataList.map(viewData => viewData.id));
for (const cachedId of this._viewsCache.keys()) {
if (!validIds.has(cachedId)) {
this._viewsCache.delete(cachedId);
}
}
return viewDataList.map(viewData => {
const cached = this._viewsCache.get(viewData.id);
if (cached && cached.mode === viewData.mode) {
return cached.logic;
}
const logic = this.createDataViewUILogic(viewData.id);
this._viewsCache.set(viewData.id, {
mode: viewData.mode,
logic,
});
return logic;
});
});
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
this.createDataViewUILogic(viewId)
);
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.value.map(logic => [logic.view.id, logic])
this.views$.list.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();

View File

@@ -1,6 +1,7 @@
import type { KanbanCardSelection } from '../../view-presets';
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
import type { RecordDetail } from './detail.js';
import { RecordField } from './field.js';
type DetailViewSelection = {
@@ -8,39 +9,16 @@ type DetailViewSelection = {
isEditing: boolean;
};
type DetailSelectionHost = {
querySelector: (selector: string) => unknown;
};
const isSameDetailSelection = (
current?: DetailViewSelection,
next?: DetailViewSelection
) => {
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return (
current.propertyId === next.propertyId &&
current.isEditing === next.isEditing
);
};
export class DetailSelection {
_selection?: DetailViewSelection;
onSelect = (selection?: DetailViewSelection) => {
if (isSameDetailSelection(this._selection, selection)) {
return;
}
const old = this._selection;
this._selection = selection;
if (old) {
this.blur(old);
}
if (selection && isSameDetailSelection(this._selection, selection)) {
this._selection = selection;
if (selection) {
this.focus(selection);
}
};
@@ -71,7 +49,7 @@ export class DetailSelection {
}
}
constructor(private readonly viewEle: DetailSelectionHost) {}
constructor(private readonly viewEle: RecordDetail) {}
blur(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
@@ -133,10 +111,8 @@ export class DetailSelection {
}
focusFirstCell() {
const firstField = this.viewEle.querySelector(
'affine-data-view-record-field'
) as RecordField | undefined;
const firstId = firstField?.column.id;
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
?.column.id;
if (firstId) {
this.selection = {
propertyId: firstId,
@@ -168,12 +144,11 @@ export class DetailSelection {
getSelectCard(selection: KanbanCardSelection) {
const { groupKey, cardId } = selection.cards[0];
const group = this.viewEle.querySelector(
`affine-data-view-kanban-group[data-key="${groupKey}"]`
) as HTMLElement | undefined;
return group?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
return this.viewEle
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
}
}

View File

@@ -12,5 +12,6 @@ export type PropertyDataUpdater<
> = (data: Data) => Partial<Data>;
export interface DatabaseFlags {
enable_number_formatting: boolean;
enable_table_virtual_scroll: boolean;
}

View File

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

View File

@@ -24,11 +24,17 @@ export class NumberCell extends BaseCellRenderer<
private accessor _inputEle!: HTMLInputElement;
private _getFormattedString(value: number | undefined = this.value) {
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const decimals = this.property.data$.value.decimal ?? 0;
const formatMode = (this.property.data$.value.format ??
'number') as NumberFormat;
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
return value != undefined
? enableNewFormatting
? formatNumber(value, formatMode, decimals)
: value.toString()
: '';
}
private readonly _keydown = (e: KeyboardEvent) => {
@@ -52,7 +58,9 @@ export class NumberCell extends BaseCellRenderer<
return;
}
const value = parseNumber(str);
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
if (isNaN(value)) {
if (this._inputEle) {
this._inputEle.value = this.value

View File

@@ -3,7 +3,6 @@ import zod from 'zod';
import { t } from '../../core/logical/type-presets.js';
import { propertyType } from '../../core/property/property-config.js';
import { NumberPropertySchema } from './types.js';
import { parseNumber } from './utils/formatter.js';
export const numberPropertyType = propertyType('number');
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
@@ -22,7 +21,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
default: () => null,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
const num = value ? parseNumber(value) : NaN;
const num = value ? Number(value) : NaN;
return { value: isNaN(num) ? null : num };
},
toJson: ({ value }) => value ?? null,

View File

@@ -64,6 +64,9 @@ export class MobileTableColumnHeader extends SignalWatcher(
};
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
title: {
@@ -73,36 +76,41 @@ export class MobileTableColumnHeader extends SignalWatcher(
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
},
});
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [

View File

@@ -205,39 +205,47 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [

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 '../selection.js';
import type { TableSingleView } from '../table-view-manager.js';
} from '../../index.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import { TableClipboardController } from './controller/clipboard.js';
import { TableDragController } from './controller/drag.js';
import { TableHotkeysController } from './controller/hotkeys.js';

View File

@@ -205,39 +205,47 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [

View File

@@ -337,7 +337,6 @@ export const popViewOptions = (
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
@@ -351,9 +350,16 @@ export const popViewOptions = (
items.push(
menu.group({
items: [
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
menu.action({
name: 'Layout',
postfix: html` <div
style="font-size: 14px;text-transform: capitalize;"
>
${view.type}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
@@ -373,10 +379,10 @@ export const popViewOptions = (
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
const data: MenuButtonData = {
content: () => html`
<div
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
@@ -386,7 +392,7 @@ export const popViewOptions = (
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
if (!id) {
return;
}
view.manager.viewChangeType(id, meta.type);
@@ -397,35 +403,55 @@ export const popViewOptions = (
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
return html` <affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.data="${data}"
.menu="${menu}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
});
const subHandler = popMenu(target, {
options: {
title: {
onBack: reopen,
text: 'Layout',
},
items: [
menu => {
const result = menu.renderItems(viewTypes);
if (result.length) {
return html` <div style="display: flex">${result}</div>`;
}
return html``;
},
// menu.toggleSwitch({
// name: 'Show block icon',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
// menu.toggleSwitch({
// name: 'Show Vertical lines',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
],
},
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
prefix: LayoutIcon(),
}),
],
})
);
@@ -460,6 +486,7 @@ export const popViewOptions = (
],
})
);
let handler: ReturnType<typeof popMenu>;
handler = popMenu(target, {
options: {
title: {

View File

@@ -60,9 +60,10 @@ 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(() => {
void themeService.theme$.value;
themeService.theme$;
this._emphasisColor = this._getEmphasisColor();
});
}

View File

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

View File

@@ -26,7 +26,6 @@
"@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",
@@ -34,9 +33,6 @@
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",

View File

@@ -1,152 +0,0 @@
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,80 +4,7 @@ import {
MindmapElementModel,
} from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/std';
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) ??
[]
);
};
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
export const createGroupCommand: Command<
{ elements: GfxModel[] | string[] },
@@ -112,118 +39,96 @@ export const createGroupFromSelectedCommand: Command<
{},
{ groupId: string }
> = (ctx, next) => {
measureOperation('edgeless:create-group-from-selected', () => {
const { std } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection, surface } = gfx;
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;
let groupId: string | undefined;
std.store.transact(() => {
const [_, result] = std.command.exec(createGroupCommand, {
elements: selection.selectedElements,
});
const parent = selection.firstElement.group as GroupElementModel;
if (!result.groupId) {
return;
}
groupId = result.groupId;
const group = surface.getElementById(groupId);
if (parent !== null && group) {
batchRemoveChildren(parent, selection.selectedElements);
batchAddChildren(parent, [group]);
}
if (parent !== null) {
selection.selectedElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(element);
});
}
if (!groupId) {
return;
}
selection.set({
editing: false,
elements: [groupId],
});
next({ groupId });
const [_, result] = std.command.exec(createGroupCommand, {
elements: selection.selectedElements,
});
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
) => {
measureOperation('edgeless:ungroup', () => {
const { std, group } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const parent = group.group;
const elements = [...group.childElements];
const { std, group } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const parent = group.group as GroupElementModel;
const elements = group.childElements;
if (group instanceof MindmapElementModel) {
return;
}
if (group instanceof MindmapElementModel) {
return;
}
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
);
if (parent !== null) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(group);
}
std.store.transact(() => {
if (parent !== null) {
batchRemoveChildren(parent, [group]);
}
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);
}
});
selection.set({
editing: false,
elements: orderedElements.map(ele => ele.id),
});
next();
elements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(element);
});
// 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();
});
});
if (parent !== null) {
elements.forEach(element => {
parent.addChild(element);
});
}
selection.set({
editing: false,
elements: elements.map(ele => ele.id),
});
next();
};

View File

@@ -1,25 +0,0 @@
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,9 +32,6 @@
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"

View File

@@ -1,73 +0,0 @@
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

@@ -1,177 +0,0 @@
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

@@ -1,65 +0,0 @@
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,18 +8,11 @@ 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')
@@ -36,11 +29,6 @@ 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() {
@@ -54,7 +42,6 @@ export class SnapExtension extends InteractivityExtension {
return pre;
}, [] as GfxModel[])
);
alignStride.reset();
},
onDragMove(context: ExtensionDragMoveContext) {
if (
@@ -66,22 +53,14 @@ 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,8 +6,6 @@ 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?: {
/**
@@ -37,9 +35,6 @@ 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';
@@ -80,11 +75,6 @@ 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: [],
@@ -97,7 +87,6 @@ export class SnapOverlay extends Overlay {
};
this._distributedAlignLines = [];
this._skippedElements.clear();
this._distributeCooldown.reset();
super.clear();
}
@@ -684,24 +673,13 @@ export class SnapOverlay extends Overlay {
}
}
const shouldTryDistribute =
this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
this._distributeCooldown.shouldRun();
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(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);
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
}
this._renderer?.refresh();
@@ -798,26 +776,24 @@ export class SnapOverlay extends Overlay {
});
const verticalBounds: Bound[] = [];
const horizBounds: Bound[] = [];
const allCandidateElements = new Set<GfxModel>();
const allBounds: Bound[] = [];
vertCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
const bound = candidate.elementBound;
verticalBounds.push(bound);
allCandidateElements.add(candidate);
verticalBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
horizCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
const bound = candidate.elementBound;
horizBounds.push(bound);
allCandidateElements.add(candidate);
horizBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
this._referenceBounds = {
horizontal: horizBounds,
vertical: verticalBounds,
all: [...allCandidateElements].map(element => element.elementBound),
all: allBounds,
};
}

View File

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

View File

@@ -1,25 +0,0 @@
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,4 +1,3 @@
/* eslint-disable @typescript-eslint/await-thenable */
import type {
Template,
TemplateCategory,

View File

@@ -155,22 +155,9 @@ 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(() => {
const childElementIds = this.props.childElementIds;
if (!childElementIds) return;
childIds.forEach(childId => {
delete childElementIds[childId];
});
this.props.childElementIds &&
delete this.props.childElementIds[element.id];
});
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { isValidUrl, splitTextByUrl } from '../../utils/url.js';
import { isValidUrl } from '../../utils/url.js';
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
test('basic case', () => {
@@ -85,55 +85,3 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
});
});
describe('splitTextByUrl', () => {
test('should split text and keep url part as link segment', () => {
expect(splitTextByUrl('hi - https://google.com')).toEqual([
{ text: 'hi - ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should support prefixed url token without swallowing prefix text', () => {
expect(splitTextByUrl('-https://google.com')).toEqual([
{ text: '-' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should trim tail punctuations from url token', () => {
expect(splitTextByUrl('visit https://google.com, now')).toEqual([
{ text: 'visit ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
{ text: ', now' },
]);
});
test('should convert domain token in plain text', () => {
expect(splitTextByUrl('google.com and text')).toEqual([
{
text: 'google.com',
link: 'https://google.com',
},
{ text: ' and text' },
]);
});
test('should normalize www domain token link while preserving display text', () => {
expect(splitTextByUrl('www.google.com')).toEqual([
{
text: 'www.google.com',
link: 'https://www.google.com',
},
]);
});
});

View File

@@ -9,7 +9,7 @@ import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import type { AffineTextAttributes } from '../../types/index.js';
import type { HtmlDeltaConverter } from '../html/delta-converter.js';
import { 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.from({ length: sortedColumns.length }, () => '*'),
widths: Array(sortedColumns.length).fill('*'),
body: tableBody,
},
margin: [0, 5, 0, 5],

View File

@@ -2,6 +2,7 @@ import { type Store, StoreExtension } from '@blocksuite/store';
import { type Signal, signal } from '@preact/signals-core';
export interface BlockSuiteFlags {
enable_database_number_formatting: boolean;
enable_database_attachment_note: boolean;
enable_database_full_width: boolean;
enable_block_query: boolean;
@@ -27,6 +28,7 @@ export class FeatureFlagService extends StoreExtension {
static override key = 'feature-flag-server';
private readonly _flags: Signal<BlockSuiteFlags> = signal({
enable_database_number_formatting: false,
enable_database_attachment_note: false,
enable_database_full_width: false,
enable_block_query: false,

View File

@@ -1,4 +1,3 @@
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';
@@ -21,171 +20,33 @@ 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[]) {
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);
}
this.fontFaces.push(
...fonts.map(font => {
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
return fontFace;
})
);
}
override mounted() {
const config = this.std.getOptional(FontConfigIdentifier);
if (!config || config.length === 0) {
return;
if (config) {
this.load(config);
}
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._cancelDeferredLoad();
for (const fontFace of this.fontFaces) {
document.fonts.delete(fontFace);
}
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
this.fontFaces.splice(0, this.fontFaces.length);
this._loadedFontKeys.clear();
}
}

View File

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

View File

@@ -95,107 +95,28 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
return result?.allowed ?? false;
}
const URL_SCHEME_IN_TOKEN_REGEXP =
/(?:https?:\/\/|ftp:\/\/|sftp:\/\/|mailto:|tel:|www\.)/i;
const URL_LEADING_DELIMITER_REGEXP = /^[-([{<'"~]+/;
const URL_TRAILING_DELIMITER_REGEXP = /[)\]}>.,;:!?'"]+$/;
export type UrlTextSegment = {
text: string;
link?: string;
};
function appendUrlTextSegment(
segments: UrlTextSegment[],
segment: UrlTextSegment
) {
if (!segment.text) return;
const last = segments[segments.length - 1];
if (last && !last.link && !segment.link) {
last.text += segment.text;
return;
}
segments.push(segment);
}
function splitTokenByUrl(token: string, baseUrl: string): UrlTextSegment[] {
const schemeMatch = token.match(URL_SCHEME_IN_TOKEN_REGEXP);
const schemeIndex = schemeMatch?.index;
if (typeof schemeIndex === 'number' && schemeIndex > 0) {
return [
{ text: token.slice(0, schemeIndex) },
...splitTokenByUrl(token.slice(schemeIndex), baseUrl),
];
}
const leading = token.match(URL_LEADING_DELIMITER_REGEXP)?.[0] ?? '';
const withoutLeading = token.slice(leading.length);
const trailing =
withoutLeading.match(URL_TRAILING_DELIMITER_REGEXP)?.[0] ?? '';
const core = trailing
? withoutLeading.slice(0, withoutLeading.length - trailing.length)
: withoutLeading;
if (core && isValidUrl(core, baseUrl)) {
const segments: UrlTextSegment[] = [];
appendUrlTextSegment(segments, { text: leading });
appendUrlTextSegment(segments, { text: core, link: normalizeUrl(core) });
appendUrlTextSegment(segments, { text: trailing });
return segments;
}
return [{ text: token }];
}
/**
* Split plain text into mixed segments, where only URL segments carry link metadata.
* This is used by paste handlers so text like `example:https://google.com` keeps
* normal text while only URL parts are linkified.
*/
export function splitTextByUrl(text: string, baseUrl = location.origin) {
const chunks = text.match(/\s+|\S+/g);
if (!chunks) {
return [];
}
const segments: UrlTextSegment[] = [];
chunks.forEach(chunk => {
if (/^\s+$/.test(chunk)) {
appendUrlTextSegment(segments, { text: chunk });
return;
}
splitTokenByUrl(chunk, baseUrl).forEach(segment => {
appendUrlTextSegment(segments, segment);
});
});
return segments;
}
// https://en.wikipedia.org/wiki/Top-level_domain
const COMMON_TLDS = new Set([
'cat',
'co',
'com',
'de',
'dev',
'edu',
'eu',
'gov',
'info',
'io',
'jp',
'me',
'mil',
'moe',
'net',
'org',
'pro',
'ru',
'net',
'edu',
'gov',
'co',
'io',
'me',
'moe',
'mil',
'top',
'uk',
'dev',
'xyz',
'info',
'cat',
'ru',
'de',
'jp',
'uk',
'pro',
]);
function isCommonTLD(url: URL) {

View File

@@ -14,17 +14,6 @@ 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
*
@@ -32,52 +21,6 @@ type HoveredElemArea = {
* 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
) => {
@@ -100,123 +43,46 @@ 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) {
const area = this.hoveredElemArea;
this._showDragHandle(area);
this._updateDragHoverRectTopLevelBlock(area);
this._showDragHandle();
this._updateDragHoverRectTopLevelBlock();
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _flushShowDragHandle = () => {
this._showDragHandleRafId = null;
if (!this.widget.anchorBlockId.peek()) return;
private readonly _showDragHandle = () => {
if (!this.widget.anchorBlockId) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
this._pendingHoveredElemArea = null;
const area = this.hoveredElemArea;
if (!area) return;
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';
}
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';
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
this._lastAppliedHoveredElemArea = this._cloneArea(area);
};
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
) => {
private readonly _updateDragHoverRectTopLevelBlock = () => {
if (!this.widget.dragHoverRect) return;
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;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
};
get gfx() {
@@ -257,7 +123,7 @@ export class EdgelessWatcher {
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea(): HoveredElemArea | null {
get hoveredElemArea() {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
@@ -308,19 +174,6 @@ 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();
@@ -363,7 +216,7 @@ export class EdgelessWatcher {
this.widget.hide();
}
if (payload.type === 'update') {
this._scheduleShowDragHandleFromSurfaceUpdate();
this._showDragHandle();
}
}
})
@@ -371,10 +224,9 @@ export class EdgelessWatcher {
if (surface) {
disposables.add(
surface.elementUpdated.subscribe(({ id }) => {
surface.elementUpdated.subscribe(() => {
if (this.widget.isGfxDragHandleVisible) {
if (id !== this.widget.anchorBlockId.peek()) return;
this._scheduleShowDragHandleFromSurfaceUpdate();
this._showDragHandle();
}
})
);

View File

@@ -153,10 +153,6 @@ 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
@@ -173,7 +169,6 @@ export class PointerEventWatcher {
point
);
if (!closestBlock) {
this._lastPointerHitBlockId = null;
this.widget.anchorBlockId.value = null;
return;
}
@@ -242,38 +237,19 @@ 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) {
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
return;
}
if (!element) 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);
@@ -378,8 +354,6 @@ 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(() => {
void this.gfx.tool.currentToolName$.value;
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;
void std.selection.value;
std.selection.value;
// wait cursor updated
requestAnimationFrame(() => {
this._scrollCurrentBlockIntoView();

View File

@@ -1,5 +1,5 @@
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
// @ts-expect-error -- mammoth.browser has no compatible type declaration for this subpath.
// @ts-ignore
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,11 +171,9 @@ export class Unzip {
const fileExt =
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
const mime = extMimeMap.get(fileExt ?? '');
const content = new File(
[new Uint8Array(this.unzipped![path]).buffer],
fileName,
mime ? { type: mime } : undefined
) as Blob;
const content = new File([this.unzipped![path]], fileName, {
type: mime ?? '',
}) 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(
docs
.map(job.docToSnapshot)
snapshots
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
.map(async snapshot => {
// Use the title and id as the snapshot file name

View File

@@ -34,7 +34,6 @@
- [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)
@@ -43,6 +42,5 @@
- [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

@@ -1,27 +0,0 @@
[**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

@@ -1,34 +0,0 @@
[**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,63 +356,3 @@ 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

@@ -1,165 +0,0 @@
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 {
} catch (error) {
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,8 +5,6 @@ export {
SortOrder,
} from '../utils/layer.js';
export {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
@@ -96,8 +94,6 @@ 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,7 +11,6 @@ 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 {
@@ -56,20 +55,6 @@ 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';
@@ -396,18 +381,11 @@ 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])
);
@@ -429,16 +407,6 @@ 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)
@@ -455,39 +423,13 @@ 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,8 +101,6 @@ export class LayerManager extends GfxExtension {
layers: Layer[] = [];
private readonly _groupChildSnapshot = new Map<string, string[]>();
slots = {
layerUpdated: new Subject<{
type: 'delete' | 'add' | 'update';
@@ -150,43 +148,6 @@ 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;
@@ -526,29 +487,6 @@ 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
@@ -574,17 +512,6 @@ 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();
@@ -595,8 +522,7 @@ export class LayerManager extends GfxExtension {
*/
private _updateLayer(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
props?: Record<string, unknown>
) {
const modelType = this._getModelType(element);
const isLocalElem = element instanceof GfxLocalElementModel;
@@ -613,16 +539,7 @@ export class LayerManager extends GfxExtension {
};
if (shouldUpdateGroupChildren) {
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);
this._reset();
return true;
}
@@ -664,13 +581,6 @@ export class LayerManager extends GfxExtension {
element
);
}
if (isContainer) {
this._syncGroupChildSnapshot(
element as GfxModel & GfxGroupCompatibleInterface
);
}
this._insertIntoLayer(element as GfxModel, modelType);
if (isContainer) {
@@ -738,26 +648,7 @@ export class LayerManager extends GfxExtension {
const isLocalElem = element instanceof GfxLocalElementModel;
if (isGroup) {
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._reset();
this.slots.layerUpdated.next({
type: 'delete',
initiatingElement: element as GfxModel,
@@ -789,7 +680,6 @@ export class LayerManager extends GfxExtension {
override unmounted() {
this.slots.layerUpdated.complete();
this._groupChildSnapshot.clear();
this._disposable.dispose();
}
@@ -887,10 +777,9 @@ export class LayerManager extends GfxExtension {
update(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
props?: Record<string, unknown>
) {
if (this._updateLayer(element, props, oldValues)) {
if (this._updateLayer(element, props)) {
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'update',
@@ -978,11 +867,7 @@ 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,
payload.oldValues
);
this.update(surface.getElementById(payload.id)!, payload.props);
}
})
);

View File

@@ -6,7 +6,6 @@ import { signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import { measureOperation } from '../../perf.js';
import {
type GfxGroupCompatibleInterface,
isGfxGroupCompatibleModel,
@@ -75,10 +74,6 @@ 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;
@@ -138,44 +133,6 @@ 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: {
@@ -290,26 +247,6 @@ 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) => {
@@ -323,7 +260,6 @@ 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) => {
@@ -334,10 +270,7 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
sameTypeElements.splice(index, 1);
}
this._parentGroupMap.delete(model.id);
if (isGfxGroupCompatibleModel(model)) {
this._removeGroupFromChildrenIndex(model.id);
if (this._groupLikeModels.has(model.id)) {
this._groupLikeModels.delete(model.id);
}
};
@@ -371,9 +304,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
element,
{
onChange: payload => {
this._emitElementUpdated(model.model, {
...payload,
id,
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
});
},
skipFieldInit: true,
@@ -418,10 +351,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
val,
{
onChange: payload => {
this._emitElementUpdated(model.model, {
...payload,
id: key,
});
(this.elementUpdated.next(payload),
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
}));
},
skipFieldInit: true,
}
@@ -438,12 +371,9 @@ 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 => {
@@ -451,17 +381,11 @@ 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);
}
{
@@ -471,16 +395,6 @@ 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;
}
@@ -489,8 +403,6 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
this.deleted.subscribe(() => {
elementsYMap.unobserve(onElementsMapChange);
subscription.unsubscribe();
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
});
}
@@ -588,71 +500,6 @@ 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 }
) {
@@ -679,9 +526,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
const elementModel = this._createElementFromProps(props, {
onChange: payload => {
this._emitElementUpdated(elementModel.model, {
...payload,
id,
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
elementModel.model.propsUpdated.next({ key });
});
},
});
@@ -713,48 +560,24 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
return;
}
measureOperation('edgeless:delete-element', () => {
const deleteElementIds = new Set<string>();
const orderedDeleteIds: string[] = [];
const deleteBlockIds = new Set<string>();
this.store.transact(() => {
const element = this.getElementById(id)!;
const group = this.getGroup(id);
this._collectElementsToDelete(
id,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
if (orderedDeleteIds.length === 0) {
return;
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.store.transact(() => {
orderedDeleteIds.forEach(elementId => {
const element = this.getElementById(elementId);
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group?.removeChild(element as GfxModel);
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);
});
});
this.elements.getValue()!.delete(id);
});
}
@@ -784,31 +607,18 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
}
getGroup(elem: string | GfxModel): GfxGroupModel | null {
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 =
elem =
typeof elem === 'string'
? ((this.getElementById(elem) ??
this.store.getBlock(elem)?.model) as GfxModel)
: elem;
if (!model) return null;
if (!elem) return null;
assertType<GfxModel>(model);
assertType<GfxModel>(elem);
for (const group of this._groupLikeModels.values()) {
if (group.hasChild(model)) {
this._parentGroupMap.set(id, group.id);
if (group.hasChild(elem)) {
return group;
}
}

View File

@@ -1,31 +0,0 @@
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

@@ -1,76 +0,0 @@
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,10 +41,6 @@ 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;
@@ -108,14 +104,6 @@ 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;
@@ -127,90 +115,26 @@ 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(update =>
this._refreshViewportByViewportUpdate(update)
)
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
);
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
})
this.viewport.sizeUpdated.subscribe(() => viewportUpdateCallback())
);
}
override disconnectedCallback(): void {
this._clearPendingViewportRefreshTimer();
super.disconnectedCallback();
}
override render() {
return html``;
}

View File

@@ -10,15 +10,25 @@ import type { InlineRange } from '../types.js';
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
export class RenderService<TextAttributes extends BaseTextAttributes> {
private _pendingRemoteInlineRangeSync = false;
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
private _carriageReturnValidationCounter = 0;
const yText = this.editor.yText;
private _renderVersion = 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 readonly _syncRemoteInlineRange = () => {
const inlineRange = this.editor.inlineRange$.peek();
if (!inlineRange) return;
if (!inlineRange || transaction.local) return;
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
@@ -40,7 +50,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
const startIndex = absoluteStart?.index;
const endIndex = absoluteEnd?.index;
if (startIndex == null || endIndex == null) return;
if (!startIndex || !endIndex) return;
const newInlineRange: InlineRange = {
index: startIndex,
@@ -49,31 +59,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
if (!this.editor.isValidInlineRange(newInlineRange)) return;
this.editor.setInlineRange(newInlineRange);
};
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();
this.editor.syncInlineRange();
};
mount = () => {
@@ -84,7 +70,6 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
editor.disposables.add({
dispose: () => {
yText.unobserve(this._onYTextChange);
this._pendingRemoteInlineRangeSync = false;
},
});
};
@@ -97,7 +82,6 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
render = () => {
if (!this.editor.rootElement) return;
const renderVersion = ++this._renderVersion;
this._rendering = true;
const rootElement = this.editor.rootElement;
@@ -168,21 +152,11 @@ 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(error => {
if (renderVersion === this._renderVersion) {
this._rendering = false;
}
console.error(error);
});
.catch(console.error);
};
rerenderWholeEditor = () => {

View File

@@ -9,12 +9,7 @@ import {
isVElement,
isVLine,
} from './guard.js';
import {
calculateTextLength,
getInlineRootTextCache,
getTextNodesFromElement,
invalidateInlineRootTextCache,
} from './text.js';
import { calculateTextLength, getTextNodesFromElement } from './text.js';
export function nativePointToTextPoint(
node: unknown,
@@ -72,6 +67,19 @@ 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(
@@ -89,44 +97,9 @@ 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,92 +8,6 @@ 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,11 +7,6 @@ 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.
*
@@ -31,65 +26,19 @@ type BatchGroupContainer = GfxGroupCompatibleInterface & {
* The result should be `[G1, G4, E6]`
*/
export function getTopElements(elements: GfxModel[]): GfxModel[] {
const uniqueElements = [...new Set(elements)];
const selected = new Set(uniqueElements);
const topElements: GfxModel[] = [];
const results = new Set(elements);
for (const element of uniqueElements) {
let ancestor = element.group;
let hasSelectedAncestor = false;
elements = [...new Set(elements)];
while (ancestor) {
if (selected.has(ancestor as GfxModel)) {
hasSelectedAncestor = true;
break;
elements.forEach(e1 => {
elements.forEach(e2 => {
if (isGfxGroupCompatibleModel(e1) && e1.hasDescendant(e2)) {
results.delete(e2);
}
ancestor = ancestor.group;
}
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);
});
return [...results];
}
function traverse(
@@ -115,9 +64,7 @@ function traverse(
});
}
if (postCallBack) {
postCallBack(element);
}
postCallBack && postCallBack(element);
};
innerTraverse(element);

View File

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

View File

@@ -382,7 +382,6 @@ 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/block/zod.js';
import { BlockSchema, type BlockSchemaType } from '../model/index.js';
import { SchemaValidateError } from './error.js';
/**

View File

@@ -1,6 +1,9 @@
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';
import {
BlockModel,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index';
type SliceData = {
content: DraftModel[];

View File

@@ -3,11 +3,14 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick } from '@blocksuite/global/utils';
import { Subject } from 'rxjs';
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 {
BlockModel,
type BlockSchemaType,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index.js';
import type { Schema } from '../schema/index.js';
import { AssetsManager } from './assets.js';
import { BaseBlockTransformer } from './base.js';
import type {

View File

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

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