mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-18 23:07:02 +08:00
Compare commits
29 Commits
v0.26.1
...
v0.26.3-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850e646ab9 | ||
|
|
728e02cab7 | ||
|
|
792164edd1 | ||
|
|
e3177e6837 | ||
|
|
42f2d2b337 | ||
|
|
9d7f4acaf1 | ||
|
|
9a1f600fc9 | ||
|
|
0f906ad623 | ||
|
|
09aa65c52a | ||
|
|
25227a09f7 | ||
|
|
c0694c589b | ||
|
|
819402d9f1 | ||
|
|
33bc3e2fe9 | ||
|
|
2b71b3f345 | ||
|
|
3bc28ba78c | ||
|
|
72df9cb457 | ||
|
|
98e5747fdc | ||
|
|
4460604dd3 | ||
|
|
b4be9118ad | ||
|
|
b46bf91575 | ||
|
|
3ad482351b | ||
|
|
03b1d15a8f | ||
|
|
52c7b04a01 | ||
|
|
1c0f873c9d | ||
|
|
8b68574820 | ||
|
|
bb01bb1aef | ||
|
|
8192a492d9 | ||
|
|
31e11b2563 | ||
|
|
5a36acea7b |
33
.github/actions/deploy/deploy.mjs
vendored
33
.github/actions/deploy/deploy.mjs
vendored
@@ -25,30 +25,30 @@ const buildType = BUILD_TYPE || 'canary';
|
|||||||
|
|
||||||
const isProduction = buildType === 'stable';
|
const isProduction = buildType === 'stable';
|
||||||
const isBeta = buildType === 'beta';
|
const isBeta = buildType === 'beta';
|
||||||
|
const isCanary = buildType === 'canary';
|
||||||
const isInternal = buildType === 'internal';
|
const isInternal = buildType === 'internal';
|
||||||
|
const isSpotEnabled = isBeta || isCanary;
|
||||||
|
|
||||||
const replicaConfig = {
|
const replicaConfig = {
|
||||||
stable: {
|
stable: {
|
||||||
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
|
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
|
||||||
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
|
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
|
||||||
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
|
|
||||||
},
|
},
|
||||||
beta: {
|
beta: {
|
||||||
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
|
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
|
||||||
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
|
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
|
||||||
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
|
|
||||||
},
|
},
|
||||||
canary: { front: 1, graphql: 1, doc: 1 },
|
canary: { front: 1, graphql: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const cpuConfig = {
|
const cpuConfig = {
|
||||||
beta: { front: '1', graphql: '1', doc: '1' },
|
beta: { front: '1', graphql: '1' },
|
||||||
canary: { front: '500m', graphql: '1', doc: '500m' },
|
canary: { front: '500m', graphql: '1' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const memoryConfig = {
|
const memoryConfig = {
|
||||||
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
|
beta: { front: '2Gi', graphql: '1Gi' },
|
||||||
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
|
canary: { front: '512Mi', graphql: '512Mi' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const createHelmCommand = ({ isDryRun }) => {
|
const createHelmCommand = ({ isDryRun }) => {
|
||||||
@@ -72,10 +72,12 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
|
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
|
||||||
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
|
`--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 = [
|
const serviceAnnotations = [
|
||||||
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
`--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 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(
|
].concat(
|
||||||
isProduction || isBeta || isInternal
|
isProduction || isBeta || isInternal
|
||||||
? [
|
? [
|
||||||
@@ -84,10 +86,17 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
|
`--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 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.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
|
||||||
`--set-json cloud-sql-proxy.nodeSelector="{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }"`,
|
`--set-json cloud-sql-proxy.nodeSelector="${cloudSqlNodeSelector}"`,
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
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 cpu = cpuConfig[buildType];
|
||||||
const memory = memoryConfig[buildType];
|
const memory = memoryConfig[buildType];
|
||||||
@@ -96,14 +105,12 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
resources = resources.concat([
|
resources = resources.concat([
|
||||||
`--set front.resources.requests.cpu="${cpu.front}"`,
|
`--set front.resources.requests.cpu="${cpu.front}"`,
|
||||||
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
|
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
|
||||||
`--set doc.resources.requests.cpu="${cpu.doc}"`,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
if (memory) {
|
if (memory) {
|
||||||
resources = resources.concat([
|
resources = resources.concat([
|
||||||
`--set front.resources.requests.memory="${memory.front}"`,
|
`--set front.resources.requests.memory="${memory.front}"`,
|
||||||
`--set graphql.resources.requests.memory="${memory.graphql}"`,
|
`--set graphql.resources.requests.memory="${memory.graphql}"`,
|
||||||
`--set doc.resources.requests.memory="${memory.doc}"`,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +149,8 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set graphql.replicaCount=${replica.graphql}`,
|
`--set graphql.replicaCount=${replica.graphql}`,
|
||||||
`--set-string graphql.image.tag="${imageTag}"`,
|
`--set-string graphql.image.tag="${imageTag}"`,
|
||||||
`--set-string graphql.app.host="${primaryHost}"`,
|
`--set-string graphql.app.host="${primaryHost}"`,
|
||||||
`--set-string doc.image.tag="${imageTag}"`,
|
|
||||||
`--set-string doc.app.host="${primaryHost}"`,
|
|
||||||
`--set doc.replicaCount=${replica.doc}`,
|
|
||||||
...serviceAnnotations,
|
...serviceAnnotations,
|
||||||
|
...spotScheduling,
|
||||||
...resources,
|
...resources,
|
||||||
`--timeout 10m`,
|
`--timeout 10m`,
|
||||||
flag,
|
flag,
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
1. Get the application URL by running these commands:
|
|
||||||
{{- if contains "NodePort" .Values.service.type }}
|
|
||||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "doc.fullname" . }})
|
|
||||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
|
||||||
echo http://$NODE_IP:$NODE_PORT
|
|
||||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
|
||||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
|
||||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "doc.fullname" . }}'
|
|
||||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "doc.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
|
||||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
|
||||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
|
||||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "doc.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
|
||||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
|
||||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
|
||||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{{/*
|
|
||||||
Expand the name of the chart.
|
|
||||||
*/}}
|
|
||||||
{{- define "doc.name" -}}
|
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create a default fully qualified app name.
|
|
||||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
|
||||||
If release name contains chart name it will be used as a full name.
|
|
||||||
*/}}
|
|
||||||
{{- define "doc.fullname" -}}
|
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
||||||
{{- if contains $name .Release.Name }}
|
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create chart name and version as used by the chart label.
|
|
||||||
*/}}
|
|
||||||
{{- define "doc.chart" -}}
|
|
||||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Common labels
|
|
||||||
*/}}
|
|
||||||
{{- define "doc.labels" -}}
|
|
||||||
helm.sh/chart: {{ include "doc.chart" . }}
|
|
||||||
{{ include "doc.selectorLabels" . }}
|
|
||||||
{{- if .Chart.AppVersion }}
|
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
||||||
{{- end }}
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
monitoring: enabled
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Selector labels
|
|
||||||
*/}}
|
|
||||||
{{- define "doc.selectorLabels" -}}
|
|
||||||
app.kubernetes.io/name: {{ include "doc.name" . }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create the name of the service account to use
|
|
||||||
*/}}
|
|
||||||
{{- define "doc.serviceAccountName" -}}
|
|
||||||
{{- if .Values.serviceAccount.create }}
|
|
||||||
{{- default (include "doc.fullname" .) .Values.global.docService.name }}
|
|
||||||
{{- else }}
|
|
||||||
{{- default "default" .Values.global.docService.name }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: {{ include "doc.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "doc.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
replicas: {{ .Values.replicaCount }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "doc.selectorLabels" . | nindent 6 }}
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
{{- with .Values.podAnnotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
{{- include "doc.selectorLabels" . | nindent 8 }}
|
|
||||||
spec:
|
|
||||||
{{- with .Values.imagePullSecrets }}
|
|
||||||
imagePullSecrets:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
serviceAccountName: {{ include "doc.serviceAccountName" . }}
|
|
||||||
containers:
|
|
||||||
- name: {{ .Chart.Name }}
|
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
||||||
env:
|
|
||||||
- name: AFFINE_PRIVATE_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: "{{ .Values.global.secret.secretName }}"
|
|
||||||
key: key
|
|
||||||
- name: NODE_ENV
|
|
||||||
value: "{{ .Values.env }}"
|
|
||||||
- name: NODE_OPTIONS
|
|
||||||
value: "--max-old-space-size=4096"
|
|
||||||
- name: NO_COLOR
|
|
||||||
value: "1"
|
|
||||||
- name: DEPLOYMENT_TYPE
|
|
||||||
value: "{{ .Values.global.deployment.type }}"
|
|
||||||
- name: DEPLOYMENT_PLATFORM
|
|
||||||
value: "{{ .Values.global.deployment.platform }}"
|
|
||||||
- name: SERVER_FLAVOR
|
|
||||||
value: "doc"
|
|
||||||
- name: AFFINE_ENV
|
|
||||||
value: "{{ .Release.Namespace }}"
|
|
||||||
- name: DATABASE_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: pg-postgresql
|
|
||||||
key: postgres-password
|
|
||||||
- name: DATABASE_URL
|
|
||||||
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
|
|
||||||
- name: REDIS_SERVER_ENABLED
|
|
||||||
value: "true"
|
|
||||||
- name: REDIS_SERVER_HOST
|
|
||||||
value: "{{ .Values.global.redis.host }}"
|
|
||||||
- name: REDIS_SERVER_PORT
|
|
||||||
value: "{{ .Values.global.redis.port }}"
|
|
||||||
- name: REDIS_SERVER_USER
|
|
||||||
value: "{{ .Values.global.redis.username }}"
|
|
||||||
- name: REDIS_SERVER_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: redis
|
|
||||||
key: redis-password
|
|
||||||
- name: REDIS_SERVER_DATABASE
|
|
||||||
value: "{{ .Values.global.redis.database }}"
|
|
||||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
|
||||||
value: "{{ .Values.global.indexer.provider }}"
|
|
||||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
|
||||||
value: "{{ .Values.global.indexer.endpoint }}"
|
|
||||||
- name: AFFINE_INDEXER_SEARCH_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: indexer
|
|
||||||
key: indexer-apiKey
|
|
||||||
- name: AFFINE_SERVER_PORT
|
|
||||||
value: "{{ .Values.global.docService.port }}"
|
|
||||||
- name: AFFINE_SERVER_SUB_PATH
|
|
||||||
value: "{{ .Values.app.path }}"
|
|
||||||
- name: AFFINE_SERVER_HOST
|
|
||||||
value: "{{ .Values.app.host }}"
|
|
||||||
- name: AFFINE_SERVER_HTTPS
|
|
||||||
value: "{{ .Values.app.https }}"
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: {{ .Values.global.docService.port }}
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /info
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
|
|
||||||
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /info
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
|
|
||||||
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
|
||||||
{{- with .Values.nodeSelector }}
|
|
||||||
nodeSelector:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{{- if .Values.serviceAccount.create -}}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: {{ include "doc.serviceAccountName" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "doc.labels" . | nindent 4 }}
|
|
||||||
{{- with .Values.serviceAccount.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
metadata:
|
|
||||||
name: "{{ include "doc.fullname" . }}-test-connection"
|
|
||||||
labels:
|
|
||||||
{{- include "doc.labels" . | nindent 4 }}
|
|
||||||
annotations:
|
|
||||||
"helm.sh/hook": test
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: wget
|
|
||||||
image: busybox
|
|
||||||
command: ['wget']
|
|
||||||
args: ['{{ include "doc.fullname" . }}:{{ .Values.global.docService.port }}']
|
|
||||||
restartPolicy: Never
|
|
||||||
5
.github/helm/affine/charts/doc/values.yaml
vendored
5
.github/helm/affine/charts/doc/values.yaml
vendored
@@ -30,9 +30,12 @@ podSecurityContext:
|
|||||||
fsGroup: 2000
|
fsGroup: 2000
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
limits:
|
||||||
cpu: '1'
|
cpu: '1'
|
||||||
memory: 4Gi
|
memory: 4Gi
|
||||||
|
requests:
|
||||||
|
cpu: '1'
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ spec:
|
|||||||
value: "{{ .Values.app.host }}"
|
value: "{{ .Values.app.host }}"
|
||||||
- name: AFFINE_SERVER_HTTPS
|
- name: AFFINE_SERVER_HTTPS
|
||||||
value: "{{ .Values.app.https }}"
|
value: "{{ .Values.app.https }}"
|
||||||
- name: DOC_SERVICE_ENDPOINT
|
|
||||||
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
|
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: {{ .Values.app.port }}
|
containerPort: {{ .Values.app.port }}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "doc.fullname" . }}
|
name: {{ .Values.global.docService.name }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "doc.labels" . | nindent 4 }}
|
{{- include "front.labels" . | nindent 4 }}
|
||||||
{{- with .Values.service.annotations }}
|
{{- with .Values.services.doc.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
type: {{ .Values.service.type }}
|
type: {{ .Values.services.doc.type }}
|
||||||
ports:
|
ports:
|
||||||
- port: {{ .Values.global.docService.port }}
|
- port: {{ .Values.global.docService.port }}
|
||||||
targetPort: http
|
targetPort: http
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: http
|
name: http
|
||||||
selector:
|
selector:
|
||||||
{{- include "doc.selectorLabels" . | nindent 4 }}
|
{{- include "front.selectorLabels" . | nindent 4 }}
|
||||||
10
.github/helm/affine/charts/front/values.yaml
vendored
10
.github/helm/affine/charts/front/values.yaml
vendored
@@ -29,9 +29,12 @@ podSecurityContext:
|
|||||||
fsGroup: 2000
|
fsGroup: 2000
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: '1'
|
||||||
|
memory: 2Gi
|
||||||
requests:
|
requests:
|
||||||
cpu: '2'
|
cpu: '1'
|
||||||
memory: 4Gi
|
memory: 2Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
@@ -54,6 +57,9 @@ services:
|
|||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8080
|
port: 8080
|
||||||
annotations: {}
|
annotations: {}
|
||||||
|
doc:
|
||||||
|
type: ClusterIP
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
nodeSelector: {}
|
nodeSelector: {}
|
||||||
tolerations: []
|
tolerations: []
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ podSecurityContext:
|
|||||||
fsGroup: 2000
|
fsGroup: 2000
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: '1'
|
||||||
|
memory: 4Gi
|
||||||
requests:
|
requests:
|
||||||
cpu: '2'
|
cpu: '1'
|
||||||
memory: 2Gi
|
memory: 2Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
|
|||||||
10
.github/helm/affine/values.yaml
vendored
10
.github/helm/affine/values.yaml
vendored
@@ -47,12 +47,6 @@ graphql:
|
|||||||
annotations:
|
annotations:
|
||||||
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
||||||
|
|
||||||
doc:
|
|
||||||
service:
|
|
||||||
type: ClusterIP
|
|
||||||
annotations:
|
|
||||||
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
|
||||||
|
|
||||||
front:
|
front:
|
||||||
services:
|
services:
|
||||||
sync:
|
sync:
|
||||||
@@ -71,3 +65,7 @@ front:
|
|||||||
name: affine-web
|
name: affine-web
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8080
|
port: 8080
|
||||||
|
doc:
|
||||||
|
type: ClusterIP
|
||||||
|
annotations:
|
||||||
|
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
||||||
|
|||||||
6
.github/workflows/auto-labeler.yml
vendored
6
.github/workflows/auto-labeler.yml
vendored
@@ -1,6 +1,10 @@
|
|||||||
name: 'Pull Request Labeler'
|
name: 'Pull Request Labeler'
|
||||||
on:
|
on:
|
||||||
- pull_request_target
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
triage:
|
triage:
|
||||||
|
|||||||
296
.github/workflows/build-test.yml
vendored
296
.github/workflows/build-test.yml
vendored
@@ -210,18 +210,13 @@ jobs:
|
|||||||
e2e-blocksuite-cross-browser-test:
|
e2e-blocksuite-cross-browser-test:
|
||||||
name: E2E BlockSuite Cross Browser Test
|
name: E2E BlockSuite Cross Browser Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
shard: [1]
|
|
||||||
browser: ['chromium', 'firefox', 'webkit']
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
playwright-install: true
|
playwright-install: true
|
||||||
playwright-platform: ${{ matrix.browser }}
|
playwright-platform: 'chromium,firefox,webkit'
|
||||||
electron-install: false
|
electron-install: false
|
||||||
full-cache: true
|
full-cache: true
|
||||||
|
|
||||||
@@ -229,18 +224,64 @@ jobs:
|
|||||||
run: yarn workspace @blocksuite/playground build
|
run: yarn workspace @blocksuite/playground build
|
||||||
|
|
||||||
- name: Run playwright tests
|
- name: Run playwright tests
|
||||||
env:
|
run: |
|
||||||
BROWSER: ${{ matrix.browser }}
|
yarn workspace @blocksuite/integration-test test:unit
|
||||||
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
|
name: test-results-e2e-bs-cross-browser
|
||||||
path: ./test-results
|
path: ./test-results
|
||||||
if-no-files-found: ignore
|
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:
|
e2e-test:
|
||||||
name: E2E Test
|
name: E2E Test
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
@@ -307,7 +348,7 @@ jobs:
|
|||||||
name: Unit Test
|
name: Unit Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- build-native
|
- build-native-linux
|
||||||
env:
|
env:
|
||||||
DISTRIBUTION: web
|
DISTRIBUTION: web
|
||||||
strategy:
|
strategy:
|
||||||
@@ -321,6 +362,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
electron-install: true
|
electron-install: true
|
||||||
playwright-install: true
|
playwright-install: true
|
||||||
|
playwright-platform: 'chromium,firefox,webkit'
|
||||||
full-cache: true
|
full-cache: true
|
||||||
|
|
||||||
- name: Download affine.linux-x64-gnu.node
|
- name: Download affine.linux-x64-gnu.node
|
||||||
@@ -341,7 +383,39 @@ jobs:
|
|||||||
name: affine
|
name: affine
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
|
|
||||||
build-native:
|
build-native-linux:
|
||||||
|
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: ./.github/actions/setup-node
|
||||||
|
with:
|
||||||
|
extra-flags: workspaces focus @affine/native
|
||||||
|
electron-install: false
|
||||||
|
- name: Setup filename
|
||||||
|
id: filename
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")
|
||||||
|
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Build AFFiNE native
|
||||||
|
uses: ./.github/actions/build-rust
|
||||||
|
with:
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
package: '@affine/native'
|
||||||
|
- name: Upload ${{ steps.filename.outputs.filename }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: ${{ steps.filename.outputs.filename }}
|
||||||
|
path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
build-native-macos:
|
||||||
name: Build AFFiNE native (${{ matrix.spec.target }})
|
name: Build AFFiNE native (${{ matrix.spec.target }})
|
||||||
runs-on: ${{ matrix.spec.os }}
|
runs-on: ${{ matrix.spec.os }}
|
||||||
env:
|
env:
|
||||||
@@ -350,7 +424,6 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
spec:
|
spec:
|
||||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
|
||||||
- { os: macos-latest, target: x86_64-apple-darwin }
|
- { os: macos-latest, target: x86_64-apple-darwin }
|
||||||
- { os: macos-latest, target: aarch64-apple-darwin }
|
- { os: macos-latest, target: aarch64-apple-darwin }
|
||||||
|
|
||||||
@@ -383,7 +456,7 @@ jobs:
|
|||||||
|
|
||||||
# Split Windows build because it's too slow
|
# Split Windows build because it's too slow
|
||||||
# and other ci jobs required linux native
|
# and other ci jobs required linux native
|
||||||
build-windows-native:
|
build-native-windows:
|
||||||
name: Build AFFiNE native (${{ matrix.spec.target }})
|
name: Build AFFiNE native (${{ matrix.spec.target }})
|
||||||
runs-on: ${{ matrix.spec.os }}
|
runs-on: ${{ matrix.spec.os }}
|
||||||
env:
|
env:
|
||||||
@@ -483,7 +556,7 @@ jobs:
|
|||||||
name: Native Unit Test
|
name: Native Unit Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- build-native
|
- build-native-linux
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -577,8 +650,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- build-server-native
|
- build-server-native
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
env:
|
env:
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||||
@@ -819,11 +890,51 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
|
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:
|
copilot-api-test:
|
||||||
name: Server Copilot Api Test
|
name: Server Copilot Api Test
|
||||||
|
if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- build-server-native
|
- build-server-native
|
||||||
|
- copilot-test-filter
|
||||||
env:
|
env:
|
||||||
NODE_ENV: test
|
NODE_ENV: test
|
||||||
DISTRIBUTION: web
|
DISTRIBUTION: web
|
||||||
@@ -857,53 +968,29 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Setup Node.js
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
electron-install: false
|
electron-install: false
|
||||||
full-cache: true
|
full-cache: true
|
||||||
|
|
||||||
- name: Download server-native.node
|
- name: Download server-native.node
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: server-native.node
|
name: server-native.node
|
||||||
path: ./packages/backend/native
|
path: ./packages/backend/native
|
||||||
|
|
||||||
- name: Prepare Server Test Environment
|
- name: Prepare Server Test Environment
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
|
||||||
env:
|
env:
|
||||||
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
|
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
|
||||||
uses: ./.github/actions/server-test-env
|
uses: ./.github/actions/server-test-env
|
||||||
|
|
||||||
- name: Run server tests
|
- 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
|
run: yarn affine @affine/server test:copilot:coverage --forbid-only
|
||||||
env:
|
env:
|
||||||
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
|
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
|
||||||
|
|
||||||
- name: Upload server test coverage results
|
- name: Upload server test coverage results
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@@ -914,6 +1001,7 @@ jobs:
|
|||||||
|
|
||||||
copilot-e2e-test:
|
copilot-e2e-test:
|
||||||
name: Frontend Copilot E2E Test
|
name: Frontend Copilot E2E Test
|
||||||
|
if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DISTRIBUTION: web
|
DISTRIBUTION: web
|
||||||
@@ -928,6 +1016,7 @@ jobs:
|
|||||||
shardTotal: [5]
|
shardTotal: [5]
|
||||||
needs:
|
needs:
|
||||||
- build-server-native
|
- build-server-native
|
||||||
|
- copilot-test-filter
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg16
|
||||||
@@ -951,30 +1040,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Setup Node.js
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
playwright-install: true
|
playwright-install: true
|
||||||
@@ -983,20 +1049,17 @@ jobs:
|
|||||||
hard-link-nm: false
|
hard-link-nm: false
|
||||||
|
|
||||||
- name: Download server-native.node
|
- name: Download server-native.node
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: server-native.node
|
name: server-native.node
|
||||||
path: ./packages/backend/native
|
path: ./packages/backend/native
|
||||||
|
|
||||||
- name: Prepare Server Test Environment
|
- name: Prepare Server Test Environment
|
||||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
|
||||||
env:
|
env:
|
||||||
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
|
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
|
||||||
uses: ./.github/actions/server-test-env
|
uses: ./.github/actions/server-test-env
|
||||||
|
|
||||||
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
- 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
|
uses: ./.github/actions/copilot-test
|
||||||
with:
|
with:
|
||||||
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||||
@@ -1006,7 +1069,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- build-server-native
|
- build-server-native
|
||||||
- build-native
|
- build-native-linux
|
||||||
env:
|
env:
|
||||||
DISTRIBUTION: web
|
DISTRIBUTION: web
|
||||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||||
@@ -1099,7 +1162,9 @@ jobs:
|
|||||||
runs-on: ${{ matrix.spec.os }}
|
runs-on: ${{ matrix.spec.os }}
|
||||||
needs:
|
needs:
|
||||||
- build-electron-renderer
|
- build-electron-renderer
|
||||||
- build-native
|
- build-native-linux
|
||||||
|
- build-native-macos
|
||||||
|
- build-native-windows
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -1182,84 +1247,6 @@ jobs:
|
|||||||
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
|
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
|
||||||
run: yarn affine @affine-test/affine-desktop e2e
|
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)
|
- name: Make bundle (macOS)
|
||||||
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
|
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
|
||||||
env:
|
env:
|
||||||
@@ -1299,6 +1286,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts
|
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:
|
test-done:
|
||||||
needs:
|
needs:
|
||||||
- analyze
|
- analyze
|
||||||
@@ -1312,8 +1307,9 @@ jobs:
|
|||||||
- e2e-blocksuite-cross-browser-test
|
- e2e-blocksuite-cross-browser-test
|
||||||
- e2e-mobile-test
|
- e2e-mobile-test
|
||||||
- unit-test
|
- unit-test
|
||||||
- build-native
|
- build-native-linux
|
||||||
- build-windows-native
|
- build-native-macos
|
||||||
|
- build-native-windows
|
||||||
- build-server-native
|
- build-server-native
|
||||||
- build-electron-renderer
|
- build-electron-renderer
|
||||||
- native-unit-test
|
- native-unit-test
|
||||||
@@ -1323,10 +1319,10 @@ jobs:
|
|||||||
- server-test
|
- server-test
|
||||||
- server-e2e-test
|
- server-e2e-test
|
||||||
- rust-test
|
- rust-test
|
||||||
|
- copilot-test-filter
|
||||||
- copilot-api-test
|
- copilot-api-test
|
||||||
- copilot-e2e-test
|
- copilot-e2e-test
|
||||||
- desktop-test
|
- desktop-test
|
||||||
- desktop-bundle-check
|
|
||||||
- cloud-e2e-test
|
- cloud-e2e-test
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
1
.github/workflows/pr-title-lint.yml
vendored
1
.github/workflows/pr-title-lint.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
|||||||
check-pull-request-title:
|
check-pull-request-title:
|
||||||
name: Check pull request title
|
name: Check pull request title
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
|||||||
78
.github/workflows/release-desktop.yml
vendored
78
.github/workflows/release-desktop.yml
vendored
@@ -201,13 +201,44 @@ jobs:
|
|||||||
nmHoistingLimits: workspaces
|
nmHoistingLimits: workspaces
|
||||||
env:
|
env:
|
||||||
npm_config_arch: ${{ matrix.spec.arch }}
|
npm_config_arch: ${{ matrix.spec.arch }}
|
||||||
- name: Download and overwrite packaged artifacts
|
- name: Download packaged artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||||
|
path: packaged-unsigned
|
||||||
|
- name: unzip packaged artifacts
|
||||||
|
run: Expand-Archive -Path packaged-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out
|
||||||
|
- name: Download signed packaged file diff
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||||
path: .
|
path: signed-packaged-diff
|
||||||
- name: unzip file
|
- name: Apply signed packaged file diff
|
||||||
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$DiffRoot = 'signed-packaged-diff/files'
|
||||||
|
$TargetRoot = 'packages/frontend/apps/electron/out'
|
||||||
|
if (!(Test-Path -LiteralPath $DiffRoot)) {
|
||||||
|
throw "Signed diff directory not found: $DiffRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
|
||||||
|
|
||||||
|
$ManifestPath = 'signed-packaged-diff/manifest.json'
|
||||||
|
if (Test-Path -LiteralPath $ManifestPath) {
|
||||||
|
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
|
||||||
|
foreach ($Entry in $ManifestEntries) {
|
||||||
|
$TargetPath = Join-Path $TargetRoot $Entry.path
|
||||||
|
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
|
||||||
|
throw "Applied signed file not found: $($Entry.path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
|
||||||
|
if ($TargetHash -ne $Entry.sha256) {
|
||||||
|
throw "Signed file hash mismatch: $($Entry.path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
- name: Make squirrel.windows installer
|
- name: Make squirrel.windows installer
|
||||||
run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||||
@@ -267,13 +298,44 @@ jobs:
|
|||||||
arch: arm64
|
arch: arm64
|
||||||
runs-on: ${{ matrix.spec.runner }}
|
runs-on: ${{ matrix.spec.runner }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download and overwrite installer artifacts
|
- name: Download installer artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||||
|
path: installer-unsigned
|
||||||
|
- name: unzip installer artifacts
|
||||||
|
run: Expand-Archive -Path installer-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
|
||||||
|
- name: Download signed installer file diff
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||||
path: .
|
path: signed-installer-diff
|
||||||
- name: unzip file
|
- name: Apply signed installer file diff
|
||||||
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$DiffRoot = 'signed-installer-diff/files'
|
||||||
|
$TargetRoot = 'packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make'
|
||||||
|
if (!(Test-Path -LiteralPath $DiffRoot)) {
|
||||||
|
throw "Signed diff directory not found: $DiffRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
|
||||||
|
|
||||||
|
$ManifestPath = 'signed-installer-diff/manifest.json'
|
||||||
|
if (Test-Path -LiteralPath $ManifestPath) {
|
||||||
|
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
|
||||||
|
foreach ($Entry in $ManifestEntries) {
|
||||||
|
$TargetPath = Join-Path $TargetRoot $Entry.path
|
||||||
|
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
|
||||||
|
throw "Applied signed file not found: $($Entry.path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
|
||||||
|
if ($TargetHash -ne $Entry.sha256) {
|
||||||
|
throw "Signed file hash mismatch: $($Entry.path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
- name: Save artifacts
|
- name: Save artifacts
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
72
.github/workflows/sync-i18n.yml
vendored
72
.github/workflows/sync-i18n.yml
vendored
@@ -1,72 +0,0 @@
|
|||||||
name: Sync I18n with Crowdin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- canary
|
|
||||||
paths:
|
|
||||||
- 'packages/frontend/i18n/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
synchronize-with-crowdin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Crowdin action
|
|
||||||
id: crowdin
|
|
||||||
uses: crowdin/github-action@v2
|
|
||||||
with:
|
|
||||||
upload_sources: true
|
|
||||||
upload_translations: false
|
|
||||||
download_translations: true
|
|
||||||
auto_approve_imported: true
|
|
||||||
import_eq_suggestions: true
|
|
||||||
export_only_approved: true
|
|
||||||
skip_untranslated_strings: true
|
|
||||||
localization_branch_name: l10n_crowdin_translations
|
|
||||||
create_pull_request: true
|
|
||||||
pull_request_title: 'chore(i18n): sync translations'
|
|
||||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
|
||||||
pull_request_base_branch_name: 'canary'
|
|
||||||
config: packages/frontend/i18n/crowdin.yml
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
|
||||||
i18n-codegen:
|
|
||||||
needs: synchronize-with-crowdin
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: l10n_crowdin_translations
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: ./.github/actions/setup-node
|
|
||||||
with:
|
|
||||||
electron-install: false
|
|
||||||
full-cache: true
|
|
||||||
|
|
||||||
- name: Run i18n codegen
|
|
||||||
run: yarn affine @affine/i18n build
|
|
||||||
|
|
||||||
- name: Commit changes
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add .
|
|
||||||
git commit -m "chore(i18n): i18n codegen"
|
|
||||||
git push origin l10n_crowdin_translations
|
|
||||||
40
.github/workflows/windows-signer.yml
vendored
40
.github/workflows/windows-signer.yml
vendored
@@ -30,13 +30,43 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd ${{ env.ARCHIVE_DIR }}/out
|
cd ${{ env.ARCHIVE_DIR }}/out
|
||||||
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
|
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
|
||||||
- name: zip file
|
- name: collect signed file diff
|
||||||
shell: cmd
|
shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0}
|
||||||
run: |
|
run: |
|
||||||
cd ${{ env.ARCHIVE_DIR }}
|
$OutDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'out'
|
||||||
7za a signed.zip .\out\*
|
$DiffDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'signed-diff'
|
||||||
|
$FilesDir = Join-Path $DiffDir 'files'
|
||||||
|
New-Item -ItemType Directory -Path $FilesDir -Force | Out-Null
|
||||||
|
|
||||||
|
$SignedFiles = [regex]::Matches('${{ inputs.files }}', '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
|
||||||
|
if ($SignedFiles.Count -eq 0) {
|
||||||
|
throw 'No files to sign were provided.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$Manifest = @()
|
||||||
|
foreach ($RelativePath in $SignedFiles) {
|
||||||
|
$SourcePath = Join-Path $OutDir $RelativePath
|
||||||
|
if (!(Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
|
||||||
|
throw "Signed file not found: $RelativePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$TargetPath = Join-Path $FilesDir $RelativePath
|
||||||
|
$TargetDir = Split-Path -Parent $TargetPath
|
||||||
|
if ($TargetDir) {
|
||||||
|
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force
|
||||||
|
$Manifest += [PSCustomObject]@{
|
||||||
|
path = $RelativePath
|
||||||
|
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Manifest | ConvertTo-Json -Depth 4 | Out-File -FilePath (Join-Path $DiffDir 'manifest.json') -Encoding utf8
|
||||||
|
Write-Host "Collected $($SignedFiles.Count) signed files."
|
||||||
- name: upload
|
- name: upload
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: signed-${{ inputs.artifact-name }}
|
name: signed-${{ inputs.artifact-name }}
|
||||||
path: ${{ env.ARCHIVE_DIR }}/signed.zip
|
path: ${{ env.ARCHIVE_DIR }}/signed-diff
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"correctness": "error",
|
"correctness": "error",
|
||||||
"perf": "error"
|
"perf": "error"
|
||||||
},
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true,
|
||||||
|
"es2026": true
|
||||||
|
},
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/node_modules",
|
"**/node_modules",
|
||||||
".yarn",
|
".yarn",
|
||||||
@@ -44,6 +48,34 @@
|
|||||||
"**/test-blocks.json"
|
"**/test-blocks.json"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"no-empty-static-block": "error",
|
||||||
|
"no-misleading-character-class": "error",
|
||||||
|
"no-new-native-nonconstructor": "error",
|
||||||
|
"no-unused-private-class-members": "error",
|
||||||
|
"no-useless-backreference": "error",
|
||||||
|
"react/display-name": "error",
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/exhaustive-deps": "warn",
|
||||||
|
"@typescript-eslint/prefer-for-of": "error",
|
||||||
|
"@typescript-eslint/no-unsafe-function-type": "error",
|
||||||
|
"@typescript-eslint/no-wrapper-object-types": "error",
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"group": ["**/dist"],
|
||||||
|
"message": "Don't import from dist",
|
||||||
|
"allowTypeImports": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": ["**/src"],
|
||||||
|
"message": "Don't import from src",
|
||||||
|
"allowTypeImports": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"no-await-in-loop": "allow",
|
"no-await-in-loop": "allow",
|
||||||
"no-redeclare": "allow",
|
"no-redeclare": "allow",
|
||||||
"promise/no-callback-in-promise": "allow",
|
"promise/no-callback-in-promise": "allow",
|
||||||
@@ -70,6 +102,14 @@
|
|||||||
"no-func-assign": "error",
|
"no-func-assign": "error",
|
||||||
"no-global-assign": "error",
|
"no-global-assign": "error",
|
||||||
"no-unused-vars": "error",
|
"no-unused-vars": "error",
|
||||||
|
"no-unused-expressions": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allowShortCircuit": true,
|
||||||
|
"allowTernary": true,
|
||||||
|
"allowTaggedTemplates": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"no-ex-assign": "error",
|
"no-ex-assign": "error",
|
||||||
"no-loss-of-precision": "error",
|
"no-loss-of-precision": "error",
|
||||||
"no-fallthrough": "error",
|
"no-fallthrough": "error",
|
||||||
@@ -126,6 +166,7 @@
|
|||||||
"react/no-render-return-value": "error",
|
"react/no-render-return-value": "error",
|
||||||
"react/jsx-no-target-blank": "error",
|
"react/jsx-no-target-blank": "error",
|
||||||
"react/jsx-no-comment-textnodes": "error",
|
"react/jsx-no-comment-textnodes": "error",
|
||||||
|
"react/no-array-index-key": "off",
|
||||||
"typescript/consistent-type-imports": "error",
|
"typescript/consistent-type-imports": "error",
|
||||||
"typescript/no-non-null-assertion": "error",
|
"typescript/no-non-null-assertion": "error",
|
||||||
"typescript/triple-slash-reference": "error",
|
"typescript/triple-slash-reference": "error",
|
||||||
@@ -241,6 +282,42 @@
|
|||||||
"typescript/consistent-type-imports": "off",
|
"typescript/consistent-type-imports": "off",
|
||||||
"import/no-cycle": "off"
|
"import/no-cycle": "off"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"packages/**/*.{ts,tsx}",
|
||||||
|
"tools/**/*.{ts,tsx}",
|
||||||
|
"blocksuite/**/*.{ts,tsx}"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"react/exhaustive-deps": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"additionalHooks": "(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/__tests__/**/*",
|
||||||
|
"**/*.stories.tsx",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/tests/**/*",
|
||||||
|
"scripts/**/*",
|
||||||
|
"**/benchmark/**/*",
|
||||||
|
"**/__debug__/**/*",
|
||||||
|
"**/e2e/**/*"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["**/*.{ts,js,mjs}"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
13
.vscode/settings.template.json
vendored
13
.vscode/settings.template.json
vendored
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"eslint.packageManager": "yarn",
|
"prisma.pinToPrisma6": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnSaveMode": "file",
|
"editor.formatOnSaveMode": "file",
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
"testid",
|
"testid",
|
||||||
"schemars"
|
"schemars"
|
||||||
],
|
],
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
|
"*.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*, .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*, crowdin*, cypress.*, 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.config.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json",
|
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, .oxlintrc.json, oxlint.json, nyc.config.*",
|
||||||
"Cargo.toml": "Cargo.lock",
|
"Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml",
|
||||||
"README.md": "LICENSE, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md"
|
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
|
||||||
|
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"
|
||||||
},
|
},
|
||||||
"[rust]": {
|
"[rust]": {
|
||||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||||
@@ -32,5 +34,6 @@
|
|||||||
"vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"],
|
"vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"],
|
||||||
"rust-analyzer.check.extraEnv": {
|
"rust-analyzer.check.extraEnv": {
|
||||||
"DATABASE_URL": "sqlite:affine.db"
|
"DATABASE_URL": "sqlite:affine.db"
|
||||||
}
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
}
|
}
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -21,23 +21,6 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<div align="left" valign="middle">
|
|
||||||
<a href="https://runblaze.dev">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.runblaze.dev/logo_dark.png">
|
|
||||||
<img align="right" src="https://www.runblaze.dev/logo_light.png" height="102px"/>
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<br style="display: none;"/>
|
|
||||||
|
|
||||||
_Special thanks to [Blaze](https://runblaze.dev) for their support of this project. They provide high-performance Apple Silicon macOS and Linux (AMD64 & ARM64) runners for GitHub Actions, greatly reducing our automated build times._
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://affine.pro">Home Page</a> |
|
<a href="https://affine.pro">Home Page</a> |
|
||||||
<a href="https://affine.pro/redirect/discord">Discord</a> |
|
<a href="https://affine.pro/redirect/discord">Discord</a> |
|
||||||
@@ -107,10 +90,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------- |
|
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Vist the AFFiNE Community](https://community.affine.pro) |
|
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
|
||||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||||
|
|
||||||
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
||||||
|
|
||||||
@@ -169,8 +152,10 @@ Welcome to the AFFiNE blog section! Here, you’ll find the latest insights, tip
|
|||||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||||
|
|
||||||
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
||||||
|
- [y-octo](https://github.com/y-crdt/y-octo) - 🐙 y-octo is a native, high-performance, thread-safe YJS CRDT implementation, serving as the core engine enabling the AFFiNE Client/Server to achieve "local-first" functionality.
|
||||||
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
|
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
|
||||||
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync.
|
|
||||||
|
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync on web.
|
||||||
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
|
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
|
||||||
- [React](https://github.com/facebook/react) - The library for web and native user interfaces.
|
- [React](https://github.com/facebook/react) - The library for web and native user interfaces.
|
||||||
- [napi-rs](https://github.com/napi-rs/napi-rs) - A framework for building compiled Node.js add-ons in Rust via Node-API.
|
- [napi-rs](https://github.com/napi-rs/napi-rs) - A framework for building compiled Node.js add-ons in Rust via Node-API.
|
||||||
@@ -221,12 +206,6 @@ See [BUILDING.md] for instructions on how to build AFFiNE from source code.
|
|||||||
We welcome contributions from everyone.
|
We welcome contributions from everyone.
|
||||||
See [docs/contributing/tutorial.md](./docs/contributing/tutorial.md) for details.
|
See [docs/contributing/tutorial.md](./docs/contributing/tutorial.md) for details.
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
|
||||||
|
|
||||||
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
### Editions
|
### Editions
|
||||||
|
|||||||
@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
|
|||||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
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 () => {
|
test('nested list', async () => {
|
||||||
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);
|
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
|||||||
|
|
||||||
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
||||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||||
const enableNumberFormat = featureFlagService.getFlag(
|
|
||||||
'enable_database_number_formatting'
|
|
||||||
);
|
|
||||||
const enableTableVirtualScroll = featureFlagService.getFlag(
|
const enableTableVirtualScroll = featureFlagService.getFlag(
|
||||||
'enable_table_virtual_scroll'
|
'enable_table_virtual_scroll'
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
enable_number_formatting: enableNumberFormat ?? false,
|
|
||||||
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
|
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,10 +8,7 @@ import type {
|
|||||||
AffineInlineEditor,
|
AffineInlineEditor,
|
||||||
AffineTextAttributes,
|
AffineTextAttributes,
|
||||||
} from '@blocksuite/affine-shared/types';
|
} from '@blocksuite/affine-shared/types';
|
||||||
import {
|
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||||
getViewportElement,
|
|
||||||
isValidUrl,
|
|
||||||
} from '@blocksuite/affine-shared/utils';
|
|
||||||
import {
|
import {
|
||||||
BaseCellRenderer,
|
BaseCellRenderer,
|
||||||
createFromBaseCellRenderer,
|
createFromBaseCellRenderer,
|
||||||
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
|
|
||||||
import { EditorHostKey } from '../../context/host-context.js';
|
import { EditorHostKey } from '../../context/host-context.js';
|
||||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||||
|
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||||
import {
|
import {
|
||||||
richTextCellStyle,
|
richTextCellStyle,
|
||||||
richTextContainerStyle,
|
richTextContainerStyle,
|
||||||
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
?.getData('text/plain')
|
?.getData('text/plain')
|
||||||
?.replace(/\r?\n|\r/g, '\n');
|
?.replace(/\r?\n|\r/g, '\n');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||||
|
|
||||||
if (isValidUrl(text)) {
|
if (singleUrl) {
|
||||||
const std = this.std;
|
const std = this.std;
|
||||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
const result = std
|
||||||
|
?.getOptional(ParseDocUrlProvider)
|
||||||
|
?.parseDocUrl(singleUrl);
|
||||||
if (result) {
|
if (result) {
|
||||||
const text = ' ';
|
const text = ' ';
|
||||||
inlineEditor.insertText(inlineRange, text, {
|
inlineEditor.insertText(inlineRange, text, {
|
||||||
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
segment: 'database',
|
segment: 'database',
|
||||||
parentFlavour: 'affine:database',
|
parentFlavour: 'affine:database',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
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() {
|
override connectedCallback() {
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import {
|
|||||||
ParseDocUrlProvider,
|
ParseDocUrlProvider,
|
||||||
TelemetryProvider,
|
TelemetryProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import {
|
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||||
getViewportElement,
|
|
||||||
isValidUrl,
|
|
||||||
} from '@blocksuite/affine-shared/utils';
|
|
||||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||||
import { IS_MAC } from '@blocksuite/global/env';
|
import { IS_MAC } from '@blocksuite/global/env';
|
||||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||||
@@ -20,6 +17,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
import { EditorHostKey } from '../../context/host-context.js';
|
import { EditorHostKey } from '../../context/host-context.js';
|
||||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||||
|
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||||
import {
|
import {
|
||||||
headerAreaIconStyle,
|
headerAreaIconStyle,
|
||||||
titleCellStyle,
|
titleCellStyle,
|
||||||
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
const inlineRange = inlineEditor?.getInlineRange();
|
const inlineRange = inlineEditor?.getInlineRange();
|
||||||
if (!inlineRange) return;
|
if (!inlineEditor || !inlineRange) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (e.clipboardData) {
|
if (e.clipboardData) {
|
||||||
try {
|
try {
|
||||||
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
||||||
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
?.getData('text/plain')
|
?.getData('text/plain')
|
||||||
?.replace(/\r?\n|\r/g, '\n');
|
?.replace(/\r?\n|\r/g, '\n');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
e.preventDefault();
|
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||||
e.stopPropagation();
|
if (singleUrl) {
|
||||||
if (isValidUrl(text)) {
|
|
||||||
const std = this.std;
|
const std = this.std;
|
||||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
const result = std
|
||||||
|
?.getOptional(ParseDocUrlProvider)
|
||||||
|
?.parseDocUrl(singleUrl);
|
||||||
if (result) {
|
if (result) {
|
||||||
const text = ' ';
|
const text = ' ';
|
||||||
inlineEditor?.insertText(inlineRange, text, {
|
inlineEditor.insertText(inlineRange, text, {
|
||||||
reference: {
|
reference: {
|
||||||
type: 'LinkedPage',
|
type: 'LinkedPage',
|
||||||
pageId: result.docId,
|
pageId: result.docId,
|
||||||
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
inlineEditor?.setInlineRange({
|
inlineEditor.setInlineRange({
|
||||||
index: inlineRange.index + text.length,
|
index: inlineRange.index + text.length,
|
||||||
length: 0,
|
length: 0,
|
||||||
});
|
});
|
||||||
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
segment: 'database',
|
segment: 'database',
|
||||||
parentFlavour: 'affine:database',
|
parentFlavour: 'affine:database',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
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) => {
|
insertDelta = (delta: DeltaInsert) => {
|
||||||
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
this.disposables.addFromEvent(
|
this.disposables.addFromEvent(
|
||||||
this.richText.value,
|
this.richText.value,
|
||||||
'paste',
|
'paste',
|
||||||
this._onPaste
|
this._onPaste,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
if (inlineEditor) {
|
if (inlineEditor) {
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
|
|
||||||
@Peekable()
|
@Peekable()
|
||||||
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
|
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`
|
static override styles = css`
|
||||||
affine-edgeless-image {
|
affine-edgeless-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -63,6 +68,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
affine-edgeless-image .resizable-img {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
resourceController = new ResourceController(
|
resourceController = new ResourceController(
|
||||||
@@ -70,6 +80,12 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
'Image'
|
'Image'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _lodThumbnailUrl: string | null = null;
|
||||||
|
private _lodSourceUrl: string | null = null;
|
||||||
|
private _lodGeneratingSourceUrl: string | null = null;
|
||||||
|
private _lodGenerationToken = 0;
|
||||||
|
private _lastShouldUseLod = false;
|
||||||
|
|
||||||
get blobUrl() {
|
get blobUrl() {
|
||||||
return this.resourceController.blobUrl$.value;
|
return this.resourceController.blobUrl$.value;
|
||||||
}
|
}
|
||||||
@@ -96,6 +112,134 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _isLargeImage() {
|
||||||
|
const { width = 0, height = 0, size = 0 } = this.model.props;
|
||||||
|
const pixels = width * height;
|
||||||
|
return (
|
||||||
|
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
|
||||||
|
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
|
||||||
|
return (
|
||||||
|
Boolean(blobUrl) &&
|
||||||
|
this._isLargeImage() &&
|
||||||
|
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _revokeLodThumbnail() {
|
||||||
|
if (!this._lodThumbnailUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(this._lodThumbnailUrl);
|
||||||
|
this._lodThumbnailUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetLodSource(blobUrl: string | null) {
|
||||||
|
if (this._lodSourceUrl === blobUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lodGenerationToken += 1;
|
||||||
|
this._lodGeneratingSourceUrl = null;
|
||||||
|
this._lodSourceUrl = blobUrl;
|
||||||
|
this._revokeLodThumbnail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createImageElement(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error('Failed to load image'));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createThumbnailBlob(image: HTMLImageElement) {
|
||||||
|
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
|
||||||
|
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
|
||||||
|
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
|
||||||
|
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||||
|
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return Promise.resolve<Blob | null>(null);
|
||||||
|
}
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'low';
|
||||||
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
|
|
||||||
|
return new Promise<Blob | null>(resolve => {
|
||||||
|
canvas.toBlob(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _ensureLodThumbnail(blobUrl: string) {
|
||||||
|
if (
|
||||||
|
this._lodThumbnailUrl ||
|
||||||
|
this._lodGeneratingSourceUrl === blobUrl ||
|
||||||
|
!this._shouldUseLod(blobUrl)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ++this._lodGenerationToken;
|
||||||
|
this._lodGeneratingSourceUrl = blobUrl;
|
||||||
|
|
||||||
|
void this._createImageElement(blobUrl)
|
||||||
|
.then(image => this._createThumbnailBlob(image))
|
||||||
|
.then(blob => {
|
||||||
|
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrl = URL.createObjectURL(blob);
|
||||||
|
if (token !== this._lodGenerationToken || !this.isConnected) {
|
||||||
|
URL.revokeObjectURL(thumbnailUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._revokeLodThumbnail();
|
||||||
|
this._lodThumbnailUrl = thumbnailUrl;
|
||||||
|
|
||||||
|
if (this._shouldUseLod(this.blobUrl)) {
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (token !== this._lodGenerationToken || !this.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (token === this._lodGenerationToken) {
|
||||||
|
this._lodGeneratingSourceUrl = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateLodFromViewport(zoom: number) {
|
||||||
|
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
|
||||||
|
if (shouldUseLod === this._lastShouldUseLod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastShouldUseLod = shouldUseLod;
|
||||||
|
if (shouldUseLod && this.blobUrl) {
|
||||||
|
this._ensureLodThumbnail(this.blobUrl);
|
||||||
|
}
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
|
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.model.props.sourceId$.subscribe(() => {
|
this.model.props.sourceId$.subscribe(() => {
|
||||||
|
this._resetLodSource(null);
|
||||||
this.refreshData();
|
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() {
|
override renderGfxBlock() {
|
||||||
const blobUrl = this.blobUrl;
|
const blobUrl = this.blobUrl;
|
||||||
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
|
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
|
||||||
|
this._resetLodSource(blobUrl);
|
||||||
|
|
||||||
const containerStyleMap = styleMap({
|
const containerStyleMap = styleMap({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { loading, icon, description, error, needUpload } = resovledState;
|
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`
|
return html`
|
||||||
<div class="affine-image-container" style=${containerStyleMap}>
|
<div class="affine-image-container" style=${containerStyleMap}>
|
||||||
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
class="drag-target"
|
class="drag-target"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src=${blobUrl}
|
src=${imageUrl ?? ''}
|
||||||
alt=${caption}
|
alt=${caption}
|
||||||
@error=${this._handleError}
|
@error=${this._handleError}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
|
|||||||
return false;
|
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 = {
|
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||||
flavour: ParagraphBlockSchema.model.flavour,
|
flavour: ParagraphBlockSchema.model.flavour,
|
||||||
toMatch: o =>
|
toMatch: o =>
|
||||||
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
|||||||
!tagsInAncestor(o, ['p', 'li']) &&
|
!tagsInAncestor(o, ['p', 'li']) &&
|
||||||
HastUtils.isParagraphLike(o.node)
|
HastUtils.isParagraphLike(o.node)
|
||||||
) {
|
) {
|
||||||
walkerContext
|
const delta = deltaConverter.astToDelta(o.node);
|
||||||
.openNode(
|
const deltas = getParagraphDeltas(o.node, delta);
|
||||||
{
|
openParagraphBlocks(deltas, 'text', walkerContext);
|
||||||
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();
|
walkerContext.skipAllChildren();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'p': {
|
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(
|
walkerContext.openNode(
|
||||||
{
|
{
|
||||||
type: 'block',
|
type: 'block',
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
flavour: 'affine:paragraph',
|
flavour: 'affine:paragraph',
|
||||||
props: {
|
props: {
|
||||||
type: walkerContext.getGlobalContext('hast:blockquote')
|
type,
|
||||||
? 'quote'
|
|
||||||
: 'text',
|
|
||||||
text: {
|
text: {
|
||||||
'$blocksuite:internal:text$': true,
|
'$blocksuite:internal:text$': true,
|
||||||
delta: deltaConverter.astToDelta(o.node),
|
delta,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
children: [],
|
children: [],
|
||||||
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'p': {
|
case 'p': {
|
||||||
|
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
o.next?.type === 'element' &&
|
o.next?.type === 'element' &&
|
||||||
o.next.tagName === 'div' &&
|
o.next.tagName === 'div' &&
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard {
|
|||||||
|
|
||||||
if (this.std.store.readonly) return;
|
if (this.std.store.readonly) return;
|
||||||
this.std.store.captureSync();
|
this.std.store.captureSync();
|
||||||
|
let hasPasteTarget = false;
|
||||||
this.std.command
|
this.std.command
|
||||||
.chain()
|
.chain()
|
||||||
.try<{}>(cmd => [
|
.try<{}>(cmd => [
|
||||||
@@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard {
|
|||||||
if (!ctx.parentBlock) {
|
if (!ctx.parentBlock) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
hasPasteTarget = true;
|
||||||
this.std.clipboard
|
this.std.clipboard
|
||||||
.paste(
|
.paste(
|
||||||
e,
|
e,
|
||||||
this.std.store,
|
this.std.store,
|
||||||
ctx.parentBlock.model.id,
|
ctx.parentBlock.model.id,
|
||||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
|
||||||
)
|
)
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
})
|
})
|
||||||
.run();
|
.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() {
|
override mounted() {
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import {
|
|||||||
ReleaseFromGroupIcon,
|
ReleaseFromGroupIcon,
|
||||||
UnlockIcon,
|
UnlockIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
import {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
|
type GfxModel,
|
||||||
|
} from '@blocksuite/std/gfx';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
|
|
||||||
import { renderAlignmentMenu } from './alignment';
|
import { renderAlignmentMenu } from './alignment';
|
||||||
@@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = {
|
|||||||
|
|
||||||
const group = firstModel.group;
|
const group = firstModel.group;
|
||||||
|
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
batchRemoveChildren(group, [firstModel]);
|
||||||
group.removeChild(firstModel);
|
|
||||||
|
|
||||||
firstModel.index = ctx.gfx.layer.generateIndex();
|
firstModel.index = ctx.gfx.layer.generateIndex();
|
||||||
|
|
||||||
const parent = group.group;
|
const parent = group.group;
|
||||||
if (parent && parent instanceof GroupElementModel) {
|
if (parent && parent instanceof GroupElementModel) {
|
||||||
parent.addChild(firstModel);
|
batchAddChildren(parent, [firstModel]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -255,9 +258,12 @@ export const builtinMiscToolbarConfig = {
|
|||||||
|
|
||||||
// release other elements from their groups and group with top element
|
// release other elements from their groups and group with top element
|
||||||
otherElements.forEach(element => {
|
otherElements.forEach(element => {
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
if (element.group) {
|
||||||
element.group?.removeChild(element);
|
batchRemoveChildren(element.group, [element]);
|
||||||
topElement.group?.addChild(element);
|
}
|
||||||
|
if (topElement.group) {
|
||||||
|
batchAddChildren(topElement.group, [element]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (otherElements.length === 0) {
|
if (otherElements.length === 0) {
|
||||||
|
|||||||
@@ -40,10 +40,146 @@ export const SurfaceBlockSchemaExtension =
|
|||||||
|
|
||||||
export class SurfaceBlockModel extends BaseSurfaceModel {
|
export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||||
private readonly _disposables: DisposableGroup = new DisposableGroup();
|
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() {
|
override _init() {
|
||||||
this._extendElement(elementsCtorMap);
|
this._extendElement(elementsCtorMap);
|
||||||
super._init();
|
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
|
this.store.provider
|
||||||
.getAll(surfaceMiddlewareIdentifier)
|
.getAll(surfaceMiddlewareIdentifier)
|
||||||
.forEach(({ middleware }) => {
|
.forEach(({ middleware }) => {
|
||||||
@@ -52,13 +188,31 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getConnectors(id: string) {
|
getConnectors(id: string) {
|
||||||
const connectors = this.getElementsByType(
|
const connectorIds = this._connectorIdsByEndpoint.get(id);
|
||||||
'connector'
|
|
||||||
) as unknown[] as ConnectorElementModel[];
|
|
||||||
|
|
||||||
return connectors.filter(
|
if (!connectorIds?.size) {
|
||||||
connector => connector.source?.id === id || connector.target?.id === id
|
return [];
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const staleConnectorIds: string[] = [];
|
||||||
|
const connectors: ConnectorElementModel[] = [];
|
||||||
|
|
||||||
|
connectorIds.forEach(connectorId => {
|
||||||
|
const model = this.getElementById(connectorId);
|
||||||
|
|
||||||
|
if (!this._isConnectorModel(model)) {
|
||||||
|
staleConnectorIds.push(connectorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectors.push(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
staleConnectorIds.forEach(connectorId => {
|
||||||
|
this._removeConnectorFromIndex(connectorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return connectors;
|
||||||
}
|
}
|
||||||
|
|
||||||
override getElementsByType<K extends keyof SurfaceElementModelMap>(
|
override getElementsByType<K extends keyof SurfaceElementModelMap>(
|
||||||
|
|||||||
517
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
517
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
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';
|
||||||
|
import { textPropertyModelConfig } from '../property-presets/text/define.js';
|
||||||
|
import {
|
||||||
|
canGroupable,
|
||||||
|
ensureKanbanGroupColumn,
|
||||||
|
pickKanbanGroupColumn,
|
||||||
|
resolveKanbanGroupBy,
|
||||||
|
} from '../view-presets/kanban/group-by-utils.js';
|
||||||
|
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
|
||||||
|
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
|
||||||
|
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
|
||||||
|
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
|
||||||
|
|
||||||
|
type Column = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestPropertyMeta = {
|
||||||
|
type: string;
|
||||||
|
config: {
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
|
propertyData: {
|
||||||
|
default: () => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
jsonValue: {
|
||||||
|
type: (options: {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
dataSource: DataSource;
|
||||||
|
}) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockDataSource = {
|
||||||
|
properties$: ReturnType<typeof signal<string[]>>;
|
||||||
|
provider: {
|
||||||
|
getAll: () => Map<unknown, unknown>;
|
||||||
|
};
|
||||||
|
serviceGetOrCreate: (key: unknown, create: () => unknown) => unknown;
|
||||||
|
propertyTypeGet: (propertyId: string) => string | undefined;
|
||||||
|
propertyMetaGet: (type: string) => TestPropertyMeta | undefined;
|
||||||
|
propertyDataGet: (propertyId: string) => Record<string, unknown>;
|
||||||
|
propertyDataTypeGet: (propertyId: string) => unknown;
|
||||||
|
propertyAdd: (
|
||||||
|
_position: unknown,
|
||||||
|
ops?: {
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
) => string;
|
||||||
|
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asDataSource = (dataSource: object): DataSource =>
|
||||||
|
dataSource as DataSource;
|
||||||
|
|
||||||
|
const toTestMeta = <TData extends Record<string, unknown>>(
|
||||||
|
type: string,
|
||||||
|
config: {
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
|
propertyData: {
|
||||||
|
default: () => TData;
|
||||||
|
};
|
||||||
|
jsonValue: {
|
||||||
|
type: (options: { data: TData; dataSource: DataSource }) => unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
): TestPropertyMeta => ({
|
||||||
|
type,
|
||||||
|
config: {
|
||||||
|
kanbanGroup: config.kanbanGroup,
|
||||||
|
propertyData: {
|
||||||
|
default: () => config.propertyData.default(),
|
||||||
|
},
|
||||||
|
jsonValue: {
|
||||||
|
type: ({ data, dataSource }) =>
|
||||||
|
config.jsonValue.type({
|
||||||
|
data: data as TData,
|
||||||
|
dataSource,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const immutableBooleanMeta = toTestMeta('immutable-boolean', {
|
||||||
|
...checkboxPropertyModelConfig.config,
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockDataSource = (columns: Column[]): MockDataSource => {
|
||||||
|
const properties$ = signal(columns.map(column => column.id));
|
||||||
|
const typeById = new Map(columns.map(column => [column.id, column.type]));
|
||||||
|
const dataById = new Map(
|
||||||
|
columns.map(column => [column.id, column.data ?? {}])
|
||||||
|
);
|
||||||
|
const services = new Map<unknown, unknown>();
|
||||||
|
|
||||||
|
const metaEntries: Array<[string, TestPropertyMeta]> = [
|
||||||
|
[
|
||||||
|
checkboxPropertyModelConfig.type,
|
||||||
|
toTestMeta(
|
||||||
|
checkboxPropertyModelConfig.type,
|
||||||
|
checkboxPropertyModelConfig.config
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
selectPropertyModelConfig.type,
|
||||||
|
toTestMeta(
|
||||||
|
selectPropertyModelConfig.type,
|
||||||
|
selectPropertyModelConfig.config
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
multiSelectPropertyModelConfig.type,
|
||||||
|
toTestMeta(
|
||||||
|
multiSelectPropertyModelConfig.type,
|
||||||
|
multiSelectPropertyModelConfig.config
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
textPropertyModelConfig.type,
|
||||||
|
toTestMeta(textPropertyModelConfig.type, textPropertyModelConfig.config),
|
||||||
|
],
|
||||||
|
[immutableBooleanMeta.type, immutableBooleanMeta],
|
||||||
|
];
|
||||||
|
const metaByType = new Map(metaEntries);
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value != null
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
let autoColumnId = 0;
|
||||||
|
|
||||||
|
const dataSource = {
|
||||||
|
properties$,
|
||||||
|
provider: {
|
||||||
|
getAll: () => new Map<unknown, unknown>(),
|
||||||
|
},
|
||||||
|
serviceGetOrCreate: (key: unknown, create: () => unknown) => {
|
||||||
|
if (!services.has(key)) {
|
||||||
|
services.set(key, create());
|
||||||
|
}
|
||||||
|
return services.get(key);
|
||||||
|
},
|
||||||
|
propertyTypeGet: (propertyId: string) => typeById.get(propertyId),
|
||||||
|
propertyMetaGet: (type: string) => metaByType.get(type),
|
||||||
|
propertyDataGet: (propertyId: string) => asRecord(dataById.get(propertyId)),
|
||||||
|
propertyDataTypeGet: (propertyId: string) => {
|
||||||
|
const type = typeById.get(propertyId);
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = metaByType.get(type);
|
||||||
|
if (!meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return meta.config.jsonValue.type({
|
||||||
|
data: asRecord(dataById.get(propertyId)),
|
||||||
|
dataSource: asDataSource(dataSource),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
propertyAdd: (
|
||||||
|
_position: unknown,
|
||||||
|
ops?: {
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const type = ops?.type ?? selectPropertyModelConfig.type;
|
||||||
|
const id = `auto-${++autoColumnId}`;
|
||||||
|
const meta = metaByType.get(type);
|
||||||
|
const data = meta?.config.propertyData.default() ?? {};
|
||||||
|
|
||||||
|
typeById.set(id, type);
|
||||||
|
dataById.set(id, data);
|
||||||
|
properties$.value = [...properties$.value, id];
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => {
|
||||||
|
dataById.set(propertyId, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return dataSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDragController = () => {
|
||||||
|
type DragLogic = ConstructorParameters<typeof KanbanDragController>[0];
|
||||||
|
return new KanbanDragController({} as DragLogic);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('kanban', () => {
|
||||||
|
describe('group-by define', () => {
|
||||||
|
it('boolean group should not include ungroup bucket', () => {
|
||||||
|
const booleanGroup = groupByMatchers.find(
|
||||||
|
group => group.name === 'boolean'
|
||||||
|
);
|
||||||
|
expect(booleanGroup).toBeDefined();
|
||||||
|
|
||||||
|
const keys = booleanGroup!
|
||||||
|
.defaultKeys(t.boolean.instance())
|
||||||
|
.map(group => group.key);
|
||||||
|
|
||||||
|
expect(keys).toEqual(['true', 'false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boolean group should fallback invalid values to false bucket', () => {
|
||||||
|
const booleanGroup = groupByMatchers.find(
|
||||||
|
group => group.name === 'boolean'
|
||||||
|
);
|
||||||
|
expect(booleanGroup).toBeDefined();
|
||||||
|
|
||||||
|
const groups = booleanGroup!.valuesGroup(undefined, t.boolean.instance());
|
||||||
|
expect(groups).toEqual([{ key: 'false', value: false }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('columns materialization', () => {
|
||||||
|
it('appends missing properties while preserving existing order and state', () => {
|
||||||
|
const columns = [{ id: 'status', hide: true }, { id: 'title' }];
|
||||||
|
|
||||||
|
const next = materializeKanbanColumns(columns, [
|
||||||
|
'title',
|
||||||
|
'status',
|
||||||
|
'date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(next).toEqual([
|
||||||
|
{ id: 'status', hide: true },
|
||||||
|
{ id: 'title' },
|
||||||
|
{ id: 'date' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops stale columns that no longer exist in data source', () => {
|
||||||
|
const columns = [{ id: 'title' }, { id: 'removed', hide: true }];
|
||||||
|
|
||||||
|
const next = materializeKanbanColumns(columns, ['title']);
|
||||||
|
|
||||||
|
expect(next).toEqual([{ id: 'title' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original reference when columns are already materialized', () => {
|
||||||
|
const columns = [{ id: 'title' }, { id: 'status', hide: true }];
|
||||||
|
|
||||||
|
const next = materializeKanbanColumns(columns, ['title', 'status']);
|
||||||
|
|
||||||
|
expect(next).toBe(columns);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drag indicator', () => {
|
||||||
|
it('shows drop preview when insert position exists', () => {
|
||||||
|
const controller = createDragController();
|
||||||
|
const position = {
|
||||||
|
group: {} as KanbanGroup,
|
||||||
|
position: 'end' as const,
|
||||||
|
};
|
||||||
|
controller.getInsertPosition = vi.fn().mockReturnValue(position);
|
||||||
|
|
||||||
|
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||||
|
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
|
||||||
|
|
||||||
|
const result = controller.showIndicator({} as MouseEvent, undefined);
|
||||||
|
|
||||||
|
expect(result).toBe(position);
|
||||||
|
expect(displaySpy).toHaveBeenCalledWith(
|
||||||
|
position.group,
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(removeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes drop preview when insert position does not exist', () => {
|
||||||
|
const controller = createDragController();
|
||||||
|
controller.getInsertPosition = vi.fn().mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||||
|
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
|
||||||
|
|
||||||
|
const result = controller.showIndicator({} as MouseEvent, undefined);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(displaySpy).not.toHaveBeenCalled();
|
||||||
|
expect(removeSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards hovered card to drop preview for precise insertion cursor', () => {
|
||||||
|
const controller = createDragController();
|
||||||
|
const hoveredCard = document.createElement(
|
||||||
|
'affine-data-view-kanban-card'
|
||||||
|
) as KanbanCard;
|
||||||
|
const positionCard = document.createElement(
|
||||||
|
'affine-data-view-kanban-card'
|
||||||
|
) as KanbanCard;
|
||||||
|
const position = {
|
||||||
|
group: {} as KanbanGroup,
|
||||||
|
card: positionCard,
|
||||||
|
position: { before: true, id: 'card-id' } as const,
|
||||||
|
};
|
||||||
|
controller.getInsertPosition = vi.fn().mockReturnValue(position);
|
||||||
|
|
||||||
|
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||||
|
|
||||||
|
controller.showIndicator({} as MouseEvent, hoveredCard);
|
||||||
|
|
||||||
|
expect(displaySpy).toHaveBeenCalledWith(
|
||||||
|
position.group,
|
||||||
|
hoveredCard,
|
||||||
|
position.card
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group-by utils', () => {
|
||||||
|
it('allows only kanban-enabled property types to group', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{ id: 'text', type: textPropertyModelConfig.type },
|
||||||
|
{ id: 'select', type: selectPropertyModelConfig.type },
|
||||||
|
{ id: 'multi-select', type: multiSelectPropertyModelConfig.type },
|
||||||
|
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'text')).toBe(false);
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'select')).toBe(true);
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'multi-select')).toBe(true);
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'checkbox')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers mutable group column over immutable ones', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'immutable-bool',
|
||||||
|
type: 'immutable-boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'checkbox',
|
||||||
|
type: checkboxPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(pickKanbanGroupColumn(asDataSource(dataSource))).toBe('checkbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates default status select column when no groupable column exists', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'text',
|
||||||
|
type: textPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusColumnId = ensureKanbanGroupColumn(asDataSource(dataSource));
|
||||||
|
|
||||||
|
expect(statusColumnId).toBeTruthy();
|
||||||
|
expect(dataSource.propertyTypeGet(statusColumnId!)).toBe(
|
||||||
|
selectPropertyModelConfig.type
|
||||||
|
);
|
||||||
|
const options =
|
||||||
|
(
|
||||||
|
dataSource.propertyDataGet(statusColumnId!) as {
|
||||||
|
options?: { value: string }[];
|
||||||
|
}
|
||||||
|
).options ?? [];
|
||||||
|
expect(options.map(option => option.value)).toEqual([
|
||||||
|
'Todo',
|
||||||
|
'In Progress',
|
||||||
|
'Done',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults hideEmpty to true for non-option groups', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'checkbox',
|
||||||
|
type: checkboxPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource));
|
||||||
|
expect(next?.columnId).toBe('checkbox');
|
||||||
|
expect(next?.hideEmpty).toBe(true);
|
||||||
|
expect(next?.name).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults hideEmpty to false for select grouping', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
type: selectPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource));
|
||||||
|
expect(next?.columnId).toBe('select');
|
||||||
|
expect(next?.hideEmpty).toBe(false);
|
||||||
|
expect(next?.name).toBe('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves sort and explicit hideEmpty when resolving groupBy', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'checkbox',
|
||||||
|
type: checkboxPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const current: GroupBy = {
|
||||||
|
type: 'groupBy',
|
||||||
|
columnId: 'checkbox',
|
||||||
|
name: 'boolean',
|
||||||
|
sort: { desc: true },
|
||||||
|
hideEmpty: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource), current);
|
||||||
|
|
||||||
|
expect(next?.columnId).toBe('checkbox');
|
||||||
|
expect(next?.sort).toEqual({ desc: true });
|
||||||
|
expect(next?.hideEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces current non-groupable column with a valid kanban column', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{ id: 'text', type: textPropertyModelConfig.type },
|
||||||
|
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource), {
|
||||||
|
type: 'groupBy',
|
||||||
|
columnId: 'text',
|
||||||
|
name: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next?.columnId).toBe('checkbox');
|
||||||
|
expect(next?.name).toBe('boolean');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js';
|
|||||||
import { dataViewCommonStyle } from './common/css-variable.js';
|
import { dataViewCommonStyle } from './common/css-variable.js';
|
||||||
import type { DataSource } from './data-source/index.js';
|
import type { DataSource } from './data-source/index.js';
|
||||||
import type { DataViewSelection } from './types.js';
|
import type { DataViewSelection } from './types.js';
|
||||||
import { cacheComputed } from './utils/cache.js';
|
|
||||||
import { renderUniLit } from './utils/uni-component/index.js';
|
import { renderUniLit } from './utils/uni-component/index.js';
|
||||||
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
||||||
import type { SingleView } from './view-manager/single-view.js';
|
import type { SingleView } from './view-manager/single-view.js';
|
||||||
@@ -75,12 +74,38 @@ export class DataViewRootUILogic {
|
|||||||
|
|
||||||
return new (logic(view))(this, view);
|
return new (logic(view))(this, view);
|
||||||
}
|
}
|
||||||
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
|
private readonly _viewsCache = new Map<
|
||||||
this.createDataViewUILogic(viewId)
|
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 viewsMap$ = computed(() => {
|
private readonly viewsMap$ = computed(() => {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
this.views$.list.value.map(logic => [logic.view.id, logic])
|
this.views$.value.map(logic => [logic.view.id, logic])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
private readonly _uiRef = signal<DataViewRootUI>();
|
private readonly _uiRef = signal<DataViewRootUI>();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { KanbanCardSelection } from '../../view-presets';
|
import type { KanbanCardSelection } from '../../view-presets';
|
||||||
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
||||||
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
||||||
import type { RecordDetail } from './detail.js';
|
|
||||||
import { RecordField } from './field.js';
|
import { RecordField } from './field.js';
|
||||||
|
|
||||||
type DetailViewSelection = {
|
type DetailViewSelection = {
|
||||||
@@ -9,16 +8,39 @@ type DetailViewSelection = {
|
|||||||
isEditing: boolean;
|
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 {
|
export class DetailSelection {
|
||||||
_selection?: DetailViewSelection;
|
_selection?: DetailViewSelection;
|
||||||
|
|
||||||
onSelect = (selection?: DetailViewSelection) => {
|
onSelect = (selection?: DetailViewSelection) => {
|
||||||
|
if (isSameDetailSelection(this._selection, selection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const old = this._selection;
|
const old = this._selection;
|
||||||
|
this._selection = selection;
|
||||||
if (old) {
|
if (old) {
|
||||||
this.blur(old);
|
this.blur(old);
|
||||||
}
|
}
|
||||||
this._selection = selection;
|
if (selection && isSameDetailSelection(this._selection, selection)) {
|
||||||
if (selection) {
|
|
||||||
this.focus(selection);
|
this.focus(selection);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -49,7 +71,7 @@ export class DetailSelection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private readonly viewEle: RecordDetail) {}
|
constructor(private readonly viewEle: DetailSelectionHost) {}
|
||||||
|
|
||||||
blur(selection: DetailViewSelection) {
|
blur(selection: DetailViewSelection) {
|
||||||
const container = this.getFocusCellContainer(selection);
|
const container = this.getFocusCellContainer(selection);
|
||||||
@@ -111,8 +133,10 @@ export class DetailSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focusFirstCell() {
|
focusFirstCell() {
|
||||||
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
|
const firstField = this.viewEle.querySelector(
|
||||||
?.column.id;
|
'affine-data-view-record-field'
|
||||||
|
) as RecordField | undefined;
|
||||||
|
const firstId = firstField?.column.id;
|
||||||
if (firstId) {
|
if (firstId) {
|
||||||
this.selection = {
|
this.selection = {
|
||||||
propertyId: firstId,
|
propertyId: firstId,
|
||||||
@@ -144,11 +168,12 @@ export class DetailSelection {
|
|||||||
|
|
||||||
getSelectCard(selection: KanbanCardSelection) {
|
getSelectCard(selection: KanbanCardSelection) {
|
||||||
const { groupKey, cardId } = selection.cards[0];
|
const { groupKey, cardId } = selection.cards[0];
|
||||||
|
const group = this.viewEle.querySelector(
|
||||||
|
`affine-data-view-kanban-group[data-key="${groupKey}"]`
|
||||||
|
) as HTMLElement | undefined;
|
||||||
|
|
||||||
return this.viewEle
|
return group?.querySelector(
|
||||||
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
|
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
||||||
?.querySelector(
|
) as KanbanCard | undefined;
|
||||||
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
|
||||||
) as KanbanCard | undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,12 +247,13 @@ export const groupByMatchers: GroupByConfig[] = [
|
|||||||
matchType: t.boolean.instance(),
|
matchType: t.boolean.instance(),
|
||||||
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
||||||
defaultKeys: _t => [
|
defaultKeys: _t => [
|
||||||
ungroups,
|
|
||||||
{ key: 'true', value: true },
|
{ key: 'true', value: true },
|
||||||
{ key: 'false', value: false },
|
{ key: 'false', value: false },
|
||||||
],
|
],
|
||||||
valuesGroup: (v, _t) =>
|
valuesGroup: (v, _t) =>
|
||||||
typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }],
|
typeof v !== 'boolean'
|
||||||
|
? [{ key: 'false', value: false }]
|
||||||
|
: [{ key: v.toString(), value: v }],
|
||||||
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
||||||
view: createUniComponentFromWebComponent(BooleanGroupView),
|
view: createUniComponentFromWebComponent(BooleanGroupView),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { css, html, unsafeCSS } from 'lit';
|
|||||||
import { property, query } from 'lit/decorators.js';
|
import { property, query } from 'lit/decorators.js';
|
||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import { canGroupable } from '../../view-presets/kanban/group-by-utils.js';
|
||||||
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
|
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
|
||||||
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
||||||
import { dataViewCssVariable } from '../common/css-variable.js';
|
import { dataViewCssVariable } from '../common/css-variable.js';
|
||||||
@@ -278,6 +279,9 @@ export const selectGroupByProperty = (
|
|||||||
if (property.type$.value === 'title') {
|
if (property.type$.value === 'title') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (view instanceof KanbanSingleView) {
|
||||||
|
return canGroupable(view.manager.dataSource, property.id);
|
||||||
|
}
|
||||||
const dataType = property.dataType$.value;
|
const dataType = property.dataType$.value;
|
||||||
if (!dataType) {
|
if (!dataType) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type GetJsonValueFromConfig<T> =
|
|||||||
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
||||||
name: string;
|
name: string;
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: ZodType<Data>;
|
schema: ZodType<Data>;
|
||||||
default: () => Data;
|
default: () => Data;
|
||||||
|
|||||||
@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
|
|||||||
> = (data: Data) => Partial<Data>;
|
> = (data: Data) => Partial<Data>;
|
||||||
|
|
||||||
export interface DatabaseFlags {
|
export interface DatabaseFlags {
|
||||||
enable_number_formatting: boolean;
|
|
||||||
enable_table_virtual_scroll: boolean;
|
enable_table_virtual_scroll: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const autoScrollOnBoundary = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancelBoxListen = effect(() => {
|
const cancelBoxListen = effect(() => {
|
||||||
box.value;
|
void box.value;
|
||||||
startUpdate();
|
startUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const FALSE_VALUES = new Set([
|
|||||||
|
|
||||||
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
||||||
name: 'Checkbox',
|
name: 'Checkbox',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const multiSelectPropertyType = propertyType('multi-select');
|
|||||||
export const multiSelectPropertyModelConfig =
|
export const multiSelectPropertyModelConfig =
|
||||||
multiSelectPropertyType.modelConfig({
|
multiSelectPropertyType.modelConfig({
|
||||||
name: 'Multi-select',
|
name: 'Multi-select',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: SelectPropertySchema,
|
schema: SelectPropertySchema,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer<
|
|||||||
private accessor _inputEle!: HTMLInputElement;
|
private accessor _inputEle!: HTMLInputElement;
|
||||||
|
|
||||||
private _getFormattedString(value: number | undefined = this.value) {
|
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 decimals = this.property.data$.value.decimal ?? 0;
|
||||||
const formatMode = (this.property.data$.value.format ??
|
const formatMode = (this.property.data$.value.format ??
|
||||||
'number') as NumberFormat;
|
'number') as NumberFormat;
|
||||||
|
|
||||||
return value != undefined
|
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
|
||||||
? enableNewFormatting
|
|
||||||
? formatNumber(value, formatMode, decimals)
|
|
||||||
: value.toString()
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly _keydown = (e: KeyboardEvent) => {
|
private readonly _keydown = (e: KeyboardEvent) => {
|
||||||
@@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enableNewFormatting =
|
const value = parseNumber(str);
|
||||||
this.view.featureFlags$.value.enable_number_formatting;
|
|
||||||
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
|
|
||||||
if (isNaN(value)) {
|
if (isNaN(value)) {
|
||||||
if (this._inputEle) {
|
if (this._inputEle) {
|
||||||
this._inputEle.value = this.value
|
this._inputEle.value = this.value
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import zod from 'zod';
|
|||||||
import { t } from '../../core/logical/type-presets.js';
|
import { t } from '../../core/logical/type-presets.js';
|
||||||
import { propertyType } from '../../core/property/property-config.js';
|
import { propertyType } from '../../core/property/property-config.js';
|
||||||
import { NumberPropertySchema } from './types.js';
|
import { NumberPropertySchema } from './types.js';
|
||||||
|
import { parseNumber } from './utils/formatter.js';
|
||||||
export const numberPropertyType = propertyType('number');
|
export const numberPropertyType = propertyType('number');
|
||||||
|
|
||||||
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
||||||
@@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
|||||||
default: () => null,
|
default: () => null,
|
||||||
toString: ({ value }) => value?.toString() ?? '',
|
toString: ({ value }) => value?.toString() ?? '',
|
||||||
fromString: ({ value }) => {
|
fromString: ({ value }) => {
|
||||||
const num = value ? Number(value) : NaN;
|
const num = value ? parseNumber(value) : NaN;
|
||||||
return { value: isNaN(num) ? null : num };
|
return { value: isNaN(num) ? null : num };
|
||||||
},
|
},
|
||||||
toJson: ({ value }) => value ?? null,
|
toJson: ({ value }) => value ?? null,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const SelectPropertySchema = zod.object({
|
|||||||
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
||||||
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
||||||
name: 'Select',
|
name: 'Select',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: SelectPropertySchema,
|
schema: SelectPropertySchema,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -3,17 +3,9 @@ import { kanbanViewModel } from './kanban/index.js';
|
|||||||
import { tableViewModel } from './table/index.js';
|
import { tableViewModel } from './table/index.js';
|
||||||
|
|
||||||
export const viewConverts = [
|
export const viewConverts = [
|
||||||
createViewConvert(tableViewModel, kanbanViewModel, data => {
|
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||||
if (data.groupBy) {
|
filter: data.filter,
|
||||||
return {
|
})),
|
||||||
filter: data.filter,
|
|
||||||
groupBy: data.groupBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
filter: data.filter,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||||
filter: data.filter,
|
filter: data.filter,
|
||||||
groupBy: data.groupBy,
|
groupBy: data.groupBy,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
|||||||
|
|
||||||
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
||||||
import type { FilterGroup } from '../../core/filter/types.js';
|
import type { FilterGroup } from '../../core/filter/types.js';
|
||||||
import { defaultGroupBy, getGroupByService, t } from '../../core/index.js';
|
|
||||||
import type { Sort } from '../../core/sort/types.js';
|
import type { Sort } from '../../core/sort/types.js';
|
||||||
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
|
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
|
||||||
|
import { resolveKanbanGroupBy } from './group-by-utils.js';
|
||||||
import { KanbanSingleView } from './kanban-view-manager.js';
|
import { KanbanSingleView } from './kanban-view-manager.js';
|
||||||
|
|
||||||
export const kanbanViewType = viewType('kanban');
|
export const kanbanViewType = viewType('kanban');
|
||||||
@@ -34,41 +34,16 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
|||||||
defaultName: 'Kanban View',
|
defaultName: 'Kanban View',
|
||||||
dataViewManager: KanbanSingleView,
|
dataViewManager: KanbanSingleView,
|
||||||
defaultData: viewManager => {
|
defaultData: viewManager => {
|
||||||
const groupByService = getGroupByService(viewManager.dataSource);
|
const groupBy = resolveKanbanGroupBy(viewManager.dataSource);
|
||||||
const columns = viewManager.dataSource.properties$.value;
|
if (!groupBy) {
|
||||||
const allowList = columns.filter(columnId => {
|
|
||||||
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
|
|
||||||
return dataType && !!groupByService?.matcher.match(dataType);
|
|
||||||
});
|
|
||||||
const getWeight = (columnId: string) => {
|
|
||||||
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
|
|
||||||
if (!dataType || t.string.is(dataType) || t.richText.is(dataType)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (t.tag.is(dataType)) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
if (t.array.is(dataType)) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0];
|
|
||||||
if (!columnId) {
|
|
||||||
throw new BlockSuiteError(
|
throw new BlockSuiteError(
|
||||||
ErrorCode.DatabaseBlockError,
|
ErrorCode.DatabaseBlockError,
|
||||||
'no groupable column found'
|
'no groupable column found'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const type = viewManager.dataSource.propertyTypeGet(columnId);
|
|
||||||
const meta = type && viewManager.dataSource.propertyMetaGet(type);
|
const columns = viewManager.dataSource.properties$.value;
|
||||||
const data = viewManager.dataSource.propertyDataGet(columnId);
|
|
||||||
if (!columnId || !meta || !data) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.DatabaseBlockError,
|
|
||||||
'not implement yet'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
columns: columns.map(id => ({
|
columns: columns.map(id => ({
|
||||||
id: id,
|
id: id,
|
||||||
@@ -78,7 +53,7 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
|||||||
op: 'and',
|
op: 'and',
|
||||||
conditions: [],
|
conditions: [],
|
||||||
},
|
},
|
||||||
groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data),
|
groupBy,
|
||||||
header: {
|
header: {
|
||||||
titleColumn: viewManager.dataSource.properties$.value.find(
|
titleColumn: viewManager.dataSource.properties$.value.find(
|
||||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { nanoid } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import type { GroupBy } from '../../core/common/types.js';
|
||||||
|
import { getTagColor } from '../../core/component/tags/colors.js';
|
||||||
|
import type { DataSource } from '../../core/data-source/base.js';
|
||||||
|
import { defaultGroupBy } from '../../core/group-by/default.js';
|
||||||
|
import { getGroupByService } from '../../core/group-by/matcher.js';
|
||||||
|
|
||||||
|
type KanbanGroupCapability = 'mutable' | 'immutable' | 'none';
|
||||||
|
|
||||||
|
const KANBAN_DEFAULT_STATUS_OPTIONS = ['Todo', 'In Progress', 'Done'];
|
||||||
|
const SHOW_EMPTY_GROUPS_BY_DEFAULT = new Set(['select', 'multi-select']);
|
||||||
|
|
||||||
|
export const getKanbanDefaultHideEmpty = (groupName?: string): boolean => {
|
||||||
|
return !groupName || !SHOW_EMPTY_GROUPS_BY_DEFAULT.has(groupName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKanbanGroupCapability = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
propertyId: string
|
||||||
|
): KanbanGroupCapability => {
|
||||||
|
const type = dataSource.propertyTypeGet(propertyId);
|
||||||
|
if (!type) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = dataSource.propertyMetaGet(type);
|
||||||
|
const kanbanGroup = meta?.config.kanbanGroup;
|
||||||
|
if (!kanbanGroup?.enabled) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
return kanbanGroup.mutable ? 'mutable' : 'immutable';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMatchingGroupBy = (dataSource: DataSource, propertyId: string) => {
|
||||||
|
const dataType = dataSource.propertyDataTypeGet(propertyId);
|
||||||
|
if (!dataType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const groupByService = getGroupByService(dataSource);
|
||||||
|
return !!groupByService?.matcher.match(dataType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGroupByFromColumn = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
columnId: string
|
||||||
|
): GroupBy | undefined => {
|
||||||
|
const type = dataSource.propertyTypeGet(columnId);
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = dataSource.propertyMetaGet(type);
|
||||||
|
if (!meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return defaultGroupBy(
|
||||||
|
dataSource,
|
||||||
|
meta,
|
||||||
|
columnId,
|
||||||
|
dataSource.propertyDataGet(columnId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canGroupable = (dataSource: DataSource, propertyId: string) => {
|
||||||
|
return (
|
||||||
|
getKanbanGroupCapability(dataSource, propertyId) !== 'none' &&
|
||||||
|
hasMatchingGroupBy(dataSource, propertyId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickKanbanGroupColumn = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
propertyIds: string[] = dataSource.properties$.value
|
||||||
|
): string | undefined => {
|
||||||
|
let immutableFallback: string | undefined;
|
||||||
|
|
||||||
|
for (const propertyId of propertyIds) {
|
||||||
|
const capability = getKanbanGroupCapability(dataSource, propertyId);
|
||||||
|
if (capability === 'none' || !hasMatchingGroupBy(dataSource, propertyId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (capability === 'mutable') {
|
||||||
|
return propertyId;
|
||||||
|
}
|
||||||
|
immutableFallback ??= propertyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return immutableFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureKanbanGroupColumn = (
|
||||||
|
dataSource: DataSource
|
||||||
|
): string | undefined => {
|
||||||
|
const columnId = pickKanbanGroupColumn(dataSource);
|
||||||
|
if (columnId) {
|
||||||
|
return columnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusId = dataSource.propertyAdd('end', {
|
||||||
|
type: 'select',
|
||||||
|
name: 'Status',
|
||||||
|
});
|
||||||
|
if (!statusId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSource.propertyDataSet(statusId, {
|
||||||
|
options: KANBAN_DEFAULT_STATUS_OPTIONS.map(value => ({
|
||||||
|
id: nanoid(),
|
||||||
|
value,
|
||||||
|
color: getTagColor(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return statusId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveKanbanGroupBy = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
current?: GroupBy
|
||||||
|
): GroupBy | undefined => {
|
||||||
|
const keepColumnId =
|
||||||
|
current?.columnId && canGroupable(dataSource, current.columnId)
|
||||||
|
? current.columnId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const columnId = keepColumnId ?? ensureKanbanGroupColumn(dataSource);
|
||||||
|
if (!columnId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = createGroupByFromColumn(dataSource, columnId);
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
sort: current?.sort,
|
||||||
|
hideEmpty: current?.hideEmpty ?? getKanbanDefaultHideEmpty(next.name),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -17,7 +17,52 @@ import {
|
|||||||
import { fromJson } from '../../core/property/utils';
|
import { fromJson } from '../../core/property/utils';
|
||||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||||
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
||||||
import type { KanbanViewData } from './define.js';
|
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||||
|
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
||||||
|
import {
|
||||||
|
getKanbanDefaultHideEmpty,
|
||||||
|
resolveKanbanGroupBy,
|
||||||
|
} from './group-by-utils.js';
|
||||||
|
|
||||||
|
const materializeColumnsByPropertyIds = (
|
||||||
|
columns: KanbanViewColumn[],
|
||||||
|
propertyIds: string[]
|
||||||
|
) => {
|
||||||
|
const needShow = new Set(propertyIds);
|
||||||
|
const orderedColumns: KanbanViewColumn[] = [];
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
if (needShow.has(column.id)) {
|
||||||
|
orderedColumns.push(column);
|
||||||
|
needShow.delete(column.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of needShow) {
|
||||||
|
orderedColumns.push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedColumns;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const materializeKanbanColumns = (
|
||||||
|
columns: KanbanViewColumn[],
|
||||||
|
propertyIds: string[]
|
||||||
|
) => {
|
||||||
|
const nextColumns = materializeColumnsByPropertyIds(columns, propertyIds);
|
||||||
|
const unchanged =
|
||||||
|
columns.length === nextColumns.length &&
|
||||||
|
columns.every((column, index) => {
|
||||||
|
const nextColumn = nextColumns[index];
|
||||||
|
return (
|
||||||
|
nextColumn != null &&
|
||||||
|
column.id === nextColumn.id &&
|
||||||
|
column.hide === nextColumn.hide
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unchanged ? columns : nextColumns;
|
||||||
|
};
|
||||||
|
|
||||||
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||||
propertiesRaw$ = computed(() => {
|
propertiesRaw$ = computed(() => {
|
||||||
@@ -61,16 +106,27 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
groupBy$ = computed(() => {
|
groupBy$ = computed(() => {
|
||||||
return this.data$.value?.groupBy;
|
const groupBy = this.data$.value?.groupBy;
|
||||||
|
if (!groupBy || groupBy.hideEmpty != null) {
|
||||||
|
return groupBy;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...groupBy,
|
||||||
|
hideEmpty: getKanbanDefaultHideEmpty(groupBy.name),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
groupTrait = this.traitSet(
|
groupTrait = this.traitSet(
|
||||||
groupTraitKey,
|
groupTraitKey,
|
||||||
new GroupTrait(this.groupBy$, this, {
|
new GroupTrait(this.groupBy$, this, {
|
||||||
groupBySet: groupBy => {
|
groupBySet: groupBy => {
|
||||||
|
const nextGroupBy = resolveKanbanGroupBy(
|
||||||
|
this.manager.dataSource,
|
||||||
|
groupBy
|
||||||
|
);
|
||||||
this.dataUpdate(() => {
|
this.dataUpdate(() => {
|
||||||
return {
|
return {
|
||||||
groupBy: groupBy,
|
groupBy: nextGroupBy,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -200,6 +256,23 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
return this.view?.mode ?? 'kanban';
|
return this.view?.mode ?? 'kanban';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private materializeColumns() {
|
||||||
|
const view = this.view;
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextColumns = materializeKanbanColumns(
|
||||||
|
view.columns,
|
||||||
|
this.dataSource.properties$.value
|
||||||
|
);
|
||||||
|
if (nextColumns === view.columns) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataUpdate(() => ({ columns: nextColumns }));
|
||||||
|
}
|
||||||
|
|
||||||
get view() {
|
get view() {
|
||||||
return this.data$.value;
|
return this.data$.value;
|
||||||
}
|
}
|
||||||
@@ -289,6 +362,13 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||||
return new KanbanColumn(this, columnId);
|
return new KanbanColumn(this, columnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(viewManager: ViewManager, viewId: string) {
|
||||||
|
super(viewManager, viewId);
|
||||||
|
// Materialize view columns on view activation so newly added properties
|
||||||
|
// can participate in hide/order operations in kanban.
|
||||||
|
this.materializeColumns();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanColumnData = KanbanViewData['columns'][number];
|
type KanbanColumnData = KanbanViewData['columns'][number];
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
|||||||
div.className = 'with-data-view-css-variable';
|
div.className = 'with-data-view-css-variable';
|
||||||
div.style.width = `${card.getBoundingClientRect().width}px`;
|
div.style.width = `${card.getBoundingClientRect().width}px`;
|
||||||
div.style.position = 'fixed';
|
div.style.position = 'fixed';
|
||||||
// div.style.pointerEvents = 'none';
|
div.style.pointerEvents = 'none';
|
||||||
div.style.transform = 'rotate(-3deg)';
|
div.style.transform = 'rotate(-3deg)';
|
||||||
div.style.left = `${x}px`;
|
div.style.left = `${x}px`;
|
||||||
div.style.top = `${y}px`;
|
div.style.top = `${y}px`;
|
||||||
@@ -209,8 +209,12 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
|||||||
};
|
};
|
||||||
const createDropPreview = () => {
|
const createDropPreview = () => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.style.height = '2px';
|
div.dataset.isDropPreview = 'true';
|
||||||
div.style.borderRadius = '1px';
|
div.style.pointerEvents = 'none';
|
||||||
|
div.style.position = 'fixed';
|
||||||
|
div.style.zIndex = '9999';
|
||||||
|
div.style.height = '3px';
|
||||||
|
div.style.borderRadius = '2px';
|
||||||
div.style.backgroundColor = 'var(--affine-primary-color)';
|
div.style.backgroundColor = 'var(--affine-primary-color)';
|
||||||
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
||||||
return {
|
return {
|
||||||
@@ -219,19 +223,50 @@ const createDropPreview = () => {
|
|||||||
self: KanbanCard | undefined,
|
self: KanbanCard | undefined,
|
||||||
card?: KanbanCard
|
card?: KanbanCard
|
||||||
) {
|
) {
|
||||||
const target = card ?? group.querySelector('.add-card');
|
if (card === self) {
|
||||||
if (!target) {
|
|
||||||
console.error('`target` is not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (target.previousElementSibling === self || target === self) {
|
|
||||||
div.remove();
|
div.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.previousElementSibling === div) {
|
|
||||||
|
if (!card) {
|
||||||
|
const cards = Array.from(
|
||||||
|
group.querySelectorAll('affine-data-view-kanban-card')
|
||||||
|
);
|
||||||
|
const lastCard = cards[cards.length - 1];
|
||||||
|
if (lastCard === self) {
|
||||||
|
div.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect: DOMRect | undefined;
|
||||||
|
let y = 0;
|
||||||
|
if (card) {
|
||||||
|
rect = card.getBoundingClientRect();
|
||||||
|
y = rect.top;
|
||||||
|
} else {
|
||||||
|
const addCard = group.querySelector('.add-card');
|
||||||
|
if (addCard instanceof HTMLElement) {
|
||||||
|
rect = addCard.getBoundingClientRect();
|
||||||
|
y = rect.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rect) {
|
||||||
|
const body = group.querySelector('.group-body');
|
||||||
|
if (body instanceof HTMLElement) {
|
||||||
|
rect = body.getBoundingClientRect();
|
||||||
|
y = rect.bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rect) {
|
||||||
|
div.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.insertAdjacentElement('beforebegin', div);
|
|
||||||
|
document.body.append(div);
|
||||||
|
div.style.left = `${Math.round(rect.left)}px`;
|
||||||
|
div.style.top = `${Math.round(y - 2)}px`;
|
||||||
|
div.style.width = `${Math.round(rect.width)}px`;
|
||||||
},
|
},
|
||||||
remove() {
|
remove() {
|
||||||
div.remove();
|
div.remove();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
|
|
||||||
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
||||||
import type { SingleView } from '../../../core/index.js';
|
import type { SingleView } from '../../../core/index.js';
|
||||||
|
import { canGroupable } from '../group-by-utils.js';
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
affine-data-view-kanban-header {
|
affine-data-view-kanban-header {
|
||||||
@@ -43,7 +44,12 @@ export class KanbanHeader extends SignalWatcher(
|
|||||||
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
||||||
options: {
|
options: {
|
||||||
items: this.view.properties$.value
|
items: this.view.properties$.value
|
||||||
.filter(column => column.id !== groupTrait.property$.value?.id)
|
.filter(column => {
|
||||||
|
if (column.id === groupTrait.property$.value?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canGroupable(this.view.manager.dataSource, column.id);
|
||||||
|
})
|
||||||
.map(column => {
|
.map(column => {
|
||||||
return menu.action({
|
return menu.action({
|
||||||
name: column.name$.value,
|
name: column.name$.value,
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: {
|
||||||
@@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
|||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
title: {
|
||||||
this.column.type$.value !== 'number',
|
text: 'Number Format',
|
||||||
options: {
|
},
|
||||||
title: {
|
items: [
|
||||||
text: 'Number Format',
|
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,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
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
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
items: [
|
items: [
|
||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
items: [
|
||||||
this.column.type$.value !== 'number',
|
numberFormatConfig(this.column),
|
||||||
options: {
|
...numberFormats.map(format => {
|
||||||
items: [
|
const data = this.column.data$.value;
|
||||||
numberFormatConfig(this.column),
|
return menu.action({
|
||||||
...numberFormats.map(format => {
|
isSelected: data.format === format.type,
|
||||||
const data = this.column.data$.value;
|
prefix: html`<span
|
||||||
return menu.action({
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
isSelected: data.format === format.type,
|
>${format.symbol}</span
|
||||||
prefix: html`<span
|
>`,
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
name: format.label,
|
||||||
>${format.symbol}</span
|
select: () => {
|
||||||
>`,
|
if (data.format === format.type) return;
|
||||||
name: format.label,
|
this.column.dataUpdate(() => ({
|
||||||
select: () => {
|
format: format.type,
|
||||||
if (data.format === format.type) return;
|
}));
|
||||||
this.column.dataUpdate(() => ({
|
},
|
||||||
format: format.type,
|
});
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ import {
|
|||||||
DataViewUIBase,
|
DataViewUIBase,
|
||||||
DataViewUILogicBase,
|
DataViewUILogicBase,
|
||||||
} from '../../../core/view/data-view-base.js';
|
} from '../../../core/view/data-view-base.js';
|
||||||
|
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
|
||||||
import {
|
import {
|
||||||
type TableSingleView,
|
|
||||||
TableViewRowSelection,
|
TableViewRowSelection,
|
||||||
type TableViewSelectionWithType,
|
type TableViewSelectionWithType,
|
||||||
} from '../../index.js';
|
} from '../selection.js';
|
||||||
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
|
import type { TableSingleView } from '../table-view-manager.js';
|
||||||
import { TableClipboardController } from './controller/clipboard.js';
|
import { TableClipboardController } from './controller/clipboard.js';
|
||||||
import { TableDragController } from './controller/drag.js';
|
import { TableDragController } from './controller/drag.js';
|
||||||
import { TableHotkeysController } from './controller/hotkeys.js';
|
import { TableHotkeysController } from './controller/hotkeys.js';
|
||||||
|
|||||||
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
items: [
|
items: [
|
||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
items: [
|
||||||
this.column.type$.value !== 'number',
|
numberFormatConfig(this.column),
|
||||||
options: {
|
...numberFormats.map(format => {
|
||||||
items: [
|
const data = this.column.data$.value;
|
||||||
numberFormatConfig(this.column),
|
return menu.action({
|
||||||
...numberFormats.map(format => {
|
isSelected: data.format === format.type,
|
||||||
const data = this.column.data$.value;
|
prefix: html`<span
|
||||||
return menu.action({
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
isSelected: data.format === format.type,
|
>${format.symbol}</span
|
||||||
prefix: html`<span
|
>`,
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
name: format.label,
|
||||||
>${format.symbol}</span
|
select: () => {
|
||||||
>`,
|
if (data.format === format.type) return;
|
||||||
name: format.label,
|
this.column.dataUpdate(() => ({
|
||||||
select: () => {
|
format: format.type,
|
||||||
if (data.format === format.type) return;
|
}));
|
||||||
this.column.dataUpdate(() => ({
|
},
|
||||||
format: format.type,
|
});
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export const popViewOptions = (
|
|||||||
const reopen = () => {
|
const reopen = () => {
|
||||||
popViewOptions(target, dataViewLogic);
|
popViewOptions(target, dataViewLogic);
|
||||||
};
|
};
|
||||||
|
let handler: ReturnType<typeof popMenu>;
|
||||||
const items: MenuConfig[] = [];
|
const items: MenuConfig[] = [];
|
||||||
items.push(
|
items.push(
|
||||||
menu.input({
|
menu.input({
|
||||||
@@ -350,16 +351,9 @@ export const popViewOptions = (
|
|||||||
items.push(
|
items.push(
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu.action({
|
menu => {
|
||||||
name: 'Layout',
|
const viewTypeItems = menu.renderItems(
|
||||||
postfix: html` <div
|
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||||
style="font-size: 14px;text-transform: capitalize;"
|
|
||||||
>
|
|
||||||
${view.type}
|
|
||||||
</div>
|
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
|
|
||||||
return menu => {
|
return menu => {
|
||||||
if (!menu.search(meta.model.defaultName)) {
|
if (!menu.search(meta.model.defaultName)) {
|
||||||
return;
|
return;
|
||||||
@@ -379,10 +373,10 @@ export const popViewOptions = (
|
|||||||
? 'var(--affine-text-emphasis-color)'
|
? 'var(--affine-text-emphasis-color)'
|
||||||
: 'var(--affine-text-secondary-color)',
|
: 'var(--affine-text-secondary-color)',
|
||||||
});
|
});
|
||||||
const data: MenuButtonData = {
|
const buttonData: MenuButtonData = {
|
||||||
content: () => html`
|
content: () => html`
|
||||||
<div
|
<div
|
||||||
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"
|
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
||||||
>
|
>
|
||||||
<div style="${iconStyle}">
|
<div style="${iconStyle}">
|
||||||
${renderUniLit(meta.renderer.icon)}
|
${renderUniLit(meta.renderer.icon)}
|
||||||
@@ -392,7 +386,7 @@ export const popViewOptions = (
|
|||||||
`,
|
`,
|
||||||
select: () => {
|
select: () => {
|
||||||
const id = view.manager.currentViewId$.value;
|
const id = view.manager.currentViewId$.value;
|
||||||
if (!id) {
|
if (!id || meta.type === view.type) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
view.manager.viewChangeType(id, meta.type);
|
view.manager.viewChangeType(id, meta.type);
|
||||||
@@ -403,55 +397,35 @@ export const popViewOptions = (
|
|||||||
const containerStyle = styleMap({
|
const containerStyle = styleMap({
|
||||||
flex: '1',
|
flex: '1',
|
||||||
});
|
});
|
||||||
return html` <affine-menu-button
|
return html`<affine-menu-button
|
||||||
style="${containerStyle}"
|
style="${containerStyle}"
|
||||||
.data="${data}"
|
.data="${buttonData}"
|
||||||
.menu="${menu}"
|
.menu="${menu}"
|
||||||
></affine-menu-button>`;
|
></affine-menu-button>`;
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
const subHandler = popMenu(target, {
|
);
|
||||||
options: {
|
if (!viewTypeItems.length) {
|
||||||
title: {
|
return html``;
|
||||||
onBack: reopen,
|
}
|
||||||
text: 'Layout',
|
return html`
|
||||||
},
|
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||||
items: [
|
<div
|
||||||
menu => {
|
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||||
const result = menu.renderItems(viewTypes);
|
>
|
||||||
if (result.length) {
|
${LayoutIcon()}
|
||||||
return html` <div style="display: flex">${result}</div>`;
|
</div>
|
||||||
}
|
<div
|
||||||
return html``;
|
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||||
},
|
>
|
||||||
// menu.toggleSwitch({
|
Layout
|
||||||
// name: 'Show block icon',
|
</div>
|
||||||
// on: true,
|
</div>
|
||||||
// onChange: value => {
|
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||||
// console.log(value);
|
${viewTypeItems}
|
||||||
// },
|
</div>
|
||||||
// }),
|
`;
|
||||||
// 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(),
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -486,7 +460,6 @@ export const popViewOptions = (
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
let handler: ReturnType<typeof popMenu>;
|
|
||||||
handler = popMenu(target, {
|
handler = popMenu(target, {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -60,10 +60,9 @@ export class BaseExtensionProvider<
|
|||||||
* @param context - The context object containing scope and registration function
|
* @param context - The context object containing scope and registration function
|
||||||
* @param option - Optional configuration options for the provider
|
* @param option - Optional configuration options for the provider
|
||||||
*/
|
*/
|
||||||
setup(context: Context<Scope>, option?: Options) {
|
setup(_context: Context<Scope>, option?: Options) {
|
||||||
if (option) {
|
if (option) {
|
||||||
this.schema.parse(option);
|
this.schema.parse(option);
|
||||||
}
|
}
|
||||||
context;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -884,7 +884,7 @@ export class ConnectionOverlay extends Overlay {
|
|||||||
private _setupThemeListener(): void {
|
private _setupThemeListener(): void {
|
||||||
const themeService = this.gfx.std.get(ThemeProvider);
|
const themeService = this.gfx.std.get(ThemeProvider);
|
||||||
this._themeDisposer = effect(() => {
|
this._themeDisposer = effect(() => {
|
||||||
themeService.theme$;
|
void themeService.theme$.value;
|
||||||
this._emphasisColor = this._getEmphasisColor();
|
this._emphasisColor = this._getEmphasisColor();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
pendingFlag = false;
|
||||||
|
pendingList.clear();
|
||||||
disposables.forEach(d => d.unsubscribe());
|
disposables.forEach(d => d.unsubscribe());
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@toeverything/theme": "^1.1.23",
|
"@toeverything/theme": "^1.1.23",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"fractional-indexing": "^3.2.0",
|
||||||
"lit": "^3.2.0",
|
"lit": "^3.2.0",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.17.23",
|
||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.1",
|
||||||
@@ -33,6 +34,9 @@
|
|||||||
"yjs": "^13.6.27",
|
"yjs": "^13.6.27",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./view": "./src/view.ts",
|
"./view": "./src/view.ts",
|
||||||
|
|||||||
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('fractional-indexing', () => ({
|
||||||
|
generateKeyBetween: vi.fn(),
|
||||||
|
generateNKeysBetween: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||||
|
|
||||||
|
import { ungroupCommand } from '../command/group-api.js';
|
||||||
|
|
||||||
|
type TestElement = {
|
||||||
|
id: string;
|
||||||
|
index: string;
|
||||||
|
group: TestElement | null;
|
||||||
|
childElements: TestElement[];
|
||||||
|
removeChildren?: (elements: TestElement[]) => void;
|
||||||
|
addChildren?: (elements: TestElement[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
|
||||||
|
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
|
||||||
|
|
||||||
|
const createElement = (
|
||||||
|
id: string,
|
||||||
|
index: string,
|
||||||
|
group: TestElement | null
|
||||||
|
): TestElement => ({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
group,
|
||||||
|
childElements: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUngroupFixture = () => {
|
||||||
|
const parent = createElement('parent', 'p0', null);
|
||||||
|
const left = createElement('left', 'a0', parent);
|
||||||
|
const right = createElement('right', 'a0', parent);
|
||||||
|
const group = createElement('group', 'm0', parent);
|
||||||
|
const childA = createElement('child-a', 'c0', group);
|
||||||
|
const childB = createElement('child-b', 'c1', group);
|
||||||
|
|
||||||
|
group.childElements = [childB, childA];
|
||||||
|
parent.childElements = [left, group, right];
|
||||||
|
|
||||||
|
parent.removeChildren = vi.fn();
|
||||||
|
parent.addChildren = vi.fn();
|
||||||
|
group.removeChildren = vi.fn();
|
||||||
|
|
||||||
|
const elementOrder = new Map<TestElement, number>([
|
||||||
|
[left, 0],
|
||||||
|
[group, 1],
|
||||||
|
[right, 2],
|
||||||
|
[childA, 3],
|
||||||
|
[childB, 4],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectionSet = vi.fn();
|
||||||
|
const gfx = {
|
||||||
|
layer: {
|
||||||
|
compare: (a: TestElement, b: TestElement) =>
|
||||||
|
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
set: selectionSet,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const std = {
|
||||||
|
get: vi.fn(() => gfx),
|
||||||
|
store: {
|
||||||
|
transact: (callback: () => void) => callback(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
childA,
|
||||||
|
childB,
|
||||||
|
group,
|
||||||
|
parent,
|
||||||
|
selectionSet,
|
||||||
|
std,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ungroupCommand', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGenerateNKeysBetween.mockReset();
|
||||||
|
mockedGenerateKeyBetween.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to open-ended key generation when sibling interval is invalid', () => {
|
||||||
|
const fixture = createUngroupFixture();
|
||||||
|
mockedGenerateNKeysBetween
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw new Error('interval reversed');
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce(['n0', 'n1']);
|
||||||
|
|
||||||
|
const next = vi.fn();
|
||||||
|
ungroupCommand(
|
||||||
|
{
|
||||||
|
std: fixture.std,
|
||||||
|
group: fixture.group as any,
|
||||||
|
} as any,
|
||||||
|
next
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'a0',
|
||||||
|
'a0',
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'a0',
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(fixture.childA.index).toBe('n0');
|
||||||
|
expect(fixture.childB.index).toBe('n1');
|
||||||
|
expect(fixture.selectionSet).toHaveBeenCalledWith({
|
||||||
|
editing: false,
|
||||||
|
elements: ['child-a', 'child-b'],
|
||||||
|
});
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to key-by-key generation when all batched strategies fail', () => {
|
||||||
|
const fixture = createUngroupFixture();
|
||||||
|
mockedGenerateNKeysBetween.mockImplementation(() => {
|
||||||
|
throw new Error('invalid range');
|
||||||
|
});
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
|
||||||
|
|
||||||
|
ungroupCommand(
|
||||||
|
{
|
||||||
|
std: fixture.std,
|
||||||
|
group: fixture.group as any,
|
||||||
|
} as any,
|
||||||
|
vi.fn()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
|
||||||
|
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.childA.index).toBe('k0');
|
||||||
|
expect(fixture.childB.index).toBe('k1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,80 @@ import {
|
|||||||
MindmapElementModel,
|
MindmapElementModel,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import type { Command } from '@blocksuite/std';
|
import type { Command } from '@blocksuite/std';
|
||||||
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
|
import {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
|
type GfxController,
|
||||||
|
GfxControllerIdentifier,
|
||||||
|
type GfxModel,
|
||||||
|
measureOperation,
|
||||||
|
} from '@blocksuite/std/gfx';
|
||||||
|
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||||
|
|
||||||
|
const getTopLevelOrderedElements = (gfx: GfxController) => {
|
||||||
|
const topLevelElements = gfx.layer.layers.reduce<GfxModel[]>(
|
||||||
|
(elements, layer) => {
|
||||||
|
layer.elements.forEach(element => {
|
||||||
|
if (element.group === null) {
|
||||||
|
elements.push(element as GfxModel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
topLevelElements.sort((a, b) => gfx.layer.compare(a, b));
|
||||||
|
return topLevelElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUngroupIndexes = (
|
||||||
|
orderedElements: GfxModel[],
|
||||||
|
afterIndex: string | null,
|
||||||
|
beforeIndex: string | null,
|
||||||
|
fallbackAnchorIndex: string
|
||||||
|
) => {
|
||||||
|
if (orderedElements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = orderedElements.length;
|
||||||
|
const tryGenerateN = (left: string | null, right: string | null) => {
|
||||||
|
try {
|
||||||
|
const generated = generateNKeysBetween(left, right, count);
|
||||||
|
return generated.length === count ? generated : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryGenerateOneByOne = (left: string | null, right: string | null) => {
|
||||||
|
try {
|
||||||
|
let cursor = left;
|
||||||
|
return orderedElements.map(() => {
|
||||||
|
cursor = generateKeyBetween(cursor, right);
|
||||||
|
return cursor;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preferred: keep ungrouped children in the original group slot.
|
||||||
|
return (
|
||||||
|
tryGenerateN(afterIndex, beforeIndex) ??
|
||||||
|
// Fallback: ignore the upper bound when legacy/broken data has reversed interval.
|
||||||
|
tryGenerateN(afterIndex, null) ??
|
||||||
|
// Fallback: use group index as anchor when sibling interval is unavailable.
|
||||||
|
tryGenerateN(fallbackAnchorIndex, null) ??
|
||||||
|
// Last resort: always valid.
|
||||||
|
tryGenerateN(null, null) ??
|
||||||
|
// Defensive fallback for unexpected library behavior.
|
||||||
|
tryGenerateOneByOne(null, null) ??
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createGroupCommand: Command<
|
export const createGroupCommand: Command<
|
||||||
{ elements: GfxModel[] | string[] },
|
{ elements: GfxModel[] | string[] },
|
||||||
@@ -39,96 +112,118 @@ export const createGroupFromSelectedCommand: Command<
|
|||||||
{},
|
{},
|
||||||
{ groupId: string }
|
{ groupId: string }
|
||||||
> = (ctx, next) => {
|
> = (ctx, next) => {
|
||||||
const { std } = ctx;
|
measureOperation('edgeless:create-group-from-selected', () => {
|
||||||
const gfx = std.get(GfxControllerIdentifier);
|
const { std } = ctx;
|
||||||
const { selection, surface } = gfx;
|
const gfx = std.get(GfxControllerIdentifier);
|
||||||
|
const { selection, surface } = gfx;
|
||||||
|
|
||||||
if (!surface) {
|
if (!surface) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
selection.selectedElements.length === 0 ||
|
selection.selectedElements.length === 0 ||
|
||||||
!selection.selectedElements.every(
|
!selection.selectedElements.every(
|
||||||
element =>
|
element =>
|
||||||
element.group === selection.firstElement.group &&
|
element.group === selection.firstElement.group &&
|
||||||
!(element.group instanceof MindmapElementModel)
|
!(element.group instanceof MindmapElementModel)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parent = selection.firstElement.group as GroupElementModel;
|
const parent = selection.firstElement.group;
|
||||||
|
let groupId: string | undefined;
|
||||||
|
std.store.transact(() => {
|
||||||
|
const [_, result] = std.command.exec(createGroupCommand, {
|
||||||
|
elements: selection.selectedElements,
|
||||||
|
});
|
||||||
|
|
||||||
if (parent !== null) {
|
if (!result.groupId) {
|
||||||
selection.selectedElements.forEach(element => {
|
return;
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
}
|
||||||
parent.removeChild(element);
|
|
||||||
|
groupId = result.groupId;
|
||||||
|
const group = surface.getElementById(groupId);
|
||||||
|
|
||||||
|
if (parent !== null && group) {
|
||||||
|
batchRemoveChildren(parent, selection.selectedElements);
|
||||||
|
batchAddChildren(parent, [group]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const [_, result] = std.command.exec(createGroupCommand, {
|
if (!groupId) {
|
||||||
elements: selection.selectedElements,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.set({
|
||||||
|
editing: false,
|
||||||
|
elements: [groupId],
|
||||||
|
});
|
||||||
|
|
||||||
|
next({ groupId });
|
||||||
});
|
});
|
||||||
if (!result.groupId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const group = surface.getElementById(result.groupId);
|
|
||||||
|
|
||||||
if (parent !== null && group) {
|
|
||||||
parent.addChild(group);
|
|
||||||
}
|
|
||||||
|
|
||||||
selection.set({
|
|
||||||
editing: false,
|
|
||||||
elements: [result.groupId],
|
|
||||||
});
|
|
||||||
|
|
||||||
next({ groupId: result.groupId });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
|
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
|
||||||
ctx,
|
ctx,
|
||||||
next
|
next
|
||||||
) => {
|
) => {
|
||||||
const { std, group } = ctx;
|
measureOperation('edgeless:ungroup', () => {
|
||||||
const gfx = std.get(GfxControllerIdentifier);
|
const { std, group } = ctx;
|
||||||
const { selection } = gfx;
|
const gfx = std.get(GfxControllerIdentifier);
|
||||||
const parent = group.group as GroupElementModel;
|
const { selection } = gfx;
|
||||||
const elements = group.childElements;
|
const parent = group.group;
|
||||||
|
const elements = [...group.childElements];
|
||||||
|
|
||||||
if (group instanceof MindmapElementModel) {
|
if (group instanceof MindmapElementModel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent !== null) {
|
const orderedElements = [...elements].sort((a, b) =>
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
gfx.layer.compare(a, b)
|
||||||
parent.removeChild(group);
|
);
|
||||||
}
|
const siblings = parent
|
||||||
|
? [...parent.childElements].sort((a, b) => gfx.layer.compare(a, b))
|
||||||
|
: getTopLevelOrderedElements(gfx);
|
||||||
|
const groupPosition = siblings.indexOf(group);
|
||||||
|
const beforeSiblingIndex =
|
||||||
|
groupPosition > 0 ? (siblings[groupPosition - 1]?.index ?? null) : null;
|
||||||
|
const afterSiblingIndex =
|
||||||
|
groupPosition === -1
|
||||||
|
? null
|
||||||
|
: (siblings[groupPosition + 1]?.index ?? null);
|
||||||
|
const nextIndexes = buildUngroupIndexes(
|
||||||
|
orderedElements,
|
||||||
|
beforeSiblingIndex,
|
||||||
|
afterSiblingIndex,
|
||||||
|
group.index
|
||||||
|
);
|
||||||
|
|
||||||
elements.forEach(element => {
|
std.store.transact(() => {
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
if (parent !== null) {
|
||||||
group.removeChild(element);
|
batchRemoveChildren(parent, [group]);
|
||||||
});
|
}
|
||||||
|
|
||||||
// keep relative index order of group children after ungroup
|
batchRemoveChildren(group, elements);
|
||||||
elements
|
|
||||||
.sort((a, b) => gfx.layer.compare(a, b))
|
// keep relative index order of group children after ungroup
|
||||||
.forEach(element => {
|
orderedElements.forEach((element, idx) => {
|
||||||
std.store.transact(() => {
|
const index = nextIndexes[idx];
|
||||||
element.index = gfx.layer.generateIndex();
|
if (element.index !== index) {
|
||||||
|
element.index = index;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (parent !== null) {
|
||||||
|
batchAddChildren(parent, orderedElements);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parent !== null) {
|
selection.set({
|
||||||
elements.forEach(element => {
|
editing: false,
|
||||||
parent.addChild(element);
|
elements: orderedElements.map(ele => ele.id),
|
||||||
});
|
});
|
||||||
}
|
next();
|
||||||
|
|
||||||
selection.set({
|
|
||||||
editing: false,
|
|
||||||
elements: elements.map(ele => ele.id),
|
|
||||||
});
|
});
|
||||||
next();
|
|
||||||
};
|
};
|
||||||
|
|||||||
25
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
25
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2018',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globalSetup: '../../../scripts/vitest-global.js',
|
||||||
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
|
testTimeout: 1000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'istanbul',
|
||||||
|
reporter: ['lcov'],
|
||||||
|
reportsDirectory: '../../../.coverage/affine-gfx-group',
|
||||||
|
},
|
||||||
|
onConsoleLog(log, type) {
|
||||||
|
if (log.includes('lit.dev/msg/dev-mode')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.warn(`Unexpected ${type} log`, log);
|
||||||
|
throw new Error(log);
|
||||||
|
},
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
"yjs": "^13.6.27",
|
"yjs": "^13.6.27",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./view": "./src/view.ts"
|
"./view": "./src/view.ts"
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdaptiveCooldownController,
|
||||||
|
AdaptiveStrideController,
|
||||||
|
} from '../snap/adaptive-load-controller.js';
|
||||||
|
|
||||||
|
describe('AdaptiveStrideController', () => {
|
||||||
|
test('increases stride under heavy cost and respects maxStride', () => {
|
||||||
|
const controller = new AdaptiveStrideController({
|
||||||
|
heavyCostMs: 6,
|
||||||
|
maxStride: 3,
|
||||||
|
recoveryCostMs: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(10);
|
||||||
|
controller.reportCost(12);
|
||||||
|
controller.reportCost(15);
|
||||||
|
|
||||||
|
// stride should be capped at 3, so only every 3rd tick runs.
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
expect(controller.shouldSkip()).toBe(true);
|
||||||
|
expect(controller.shouldSkip()).toBe(true);
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decreases stride when cost recovers and reset clears state', () => {
|
||||||
|
const controller = new AdaptiveStrideController({
|
||||||
|
heavyCostMs: 8,
|
||||||
|
maxStride: 4,
|
||||||
|
recoveryCostMs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(12);
|
||||||
|
controller.reportCost(12);
|
||||||
|
controller.reportCost(1);
|
||||||
|
|
||||||
|
// From stride 3 recovered to stride 2: run every other tick.
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
expect(controller.shouldSkip()).toBe(true);
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdaptiveCooldownController', () => {
|
||||||
|
test('enters cooldown when cost exceeds threshold', () => {
|
||||||
|
const controller = new AdaptiveCooldownController({
|
||||||
|
cooldownFrames: 2,
|
||||||
|
maxCostMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(9);
|
||||||
|
expect(controller.shouldRun()).toBe(false);
|
||||||
|
expect(controller.shouldRun()).toBe(false);
|
||||||
|
expect(controller.shouldRun()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset exits cooldown immediately', () => {
|
||||||
|
const controller = new AdaptiveCooldownController({
|
||||||
|
cooldownFrames: 3,
|
||||||
|
maxCostMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(6);
|
||||||
|
expect(controller.shouldRun()).toBe(false);
|
||||||
|
controller.reset();
|
||||||
|
expect(controller.shouldRun()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||||
|
import { MouseButton } from '@blocksuite/std/gfx';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PanTool } from '../tools/pan-tool.js';
|
||||||
|
|
||||||
|
type PointerDownHandler = (event: {
|
||||||
|
raw: {
|
||||||
|
button: number;
|
||||||
|
preventDefault: () => void;
|
||||||
|
};
|
||||||
|
}) => unknown;
|
||||||
|
|
||||||
|
const mockRaf = () => {
|
||||||
|
let callback: FrameRequestCallback | undefined;
|
||||||
|
const requestAnimationFrameMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cb: FrameRequestCallback) => {
|
||||||
|
callback = cb;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const cancelAnimationFrameMock = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCallback: () => callback,
|
||||||
|
requestAnimationFrameMock,
|
||||||
|
cancelAnimationFrameMock,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createToolFixture = (options?: {
|
||||||
|
currentToolName?: string;
|
||||||
|
currentToolOptions?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const applyDeltaCenter = vi.fn();
|
||||||
|
const selectionSet = vi.fn();
|
||||||
|
const setTool = vi.fn();
|
||||||
|
const navigatorSettingUpdated = {
|
||||||
|
next: vi.fn(),
|
||||||
|
};
|
||||||
|
const currentToolName = options?.currentToolName;
|
||||||
|
const currentToolOption = {
|
||||||
|
toolType: currentToolName
|
||||||
|
? ({
|
||||||
|
toolName: currentToolName,
|
||||||
|
} as any)
|
||||||
|
: undefined,
|
||||||
|
options: options?.currentToolOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const gfx = {
|
||||||
|
viewport: {
|
||||||
|
zoom: 2,
|
||||||
|
applyDeltaCenter,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
surfaceSelections: [{ elements: ['shape-1'] }],
|
||||||
|
set: selectionSet,
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
currentTool$: {
|
||||||
|
peek: () => null,
|
||||||
|
},
|
||||||
|
currentToolOption$: {
|
||||||
|
peek: () => currentToolOption,
|
||||||
|
},
|
||||||
|
setTool,
|
||||||
|
},
|
||||||
|
std: {
|
||||||
|
get: (identifier: unknown) => {
|
||||||
|
if (identifier === EdgelessLegacySlotIdentifier) {
|
||||||
|
return { navigatorSettingUpdated };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
doc: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool = new PanTool(gfx as any);
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyDeltaCenter,
|
||||||
|
navigatorSettingUpdated,
|
||||||
|
selectionSet,
|
||||||
|
setTool,
|
||||||
|
tool,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PanTool', () => {
|
||||||
|
test('flushes accumulated delta on dragEnd', () => {
|
||||||
|
mockRaf();
|
||||||
|
const { tool, applyDeltaCenter } = createToolFixture();
|
||||||
|
|
||||||
|
tool.dragStart({ x: 100, y: 100 } as any);
|
||||||
|
tool.dragMove({ x: 80, y: 60 } as any);
|
||||||
|
tool.dragMove({ x: 70, y: 40 } as any);
|
||||||
|
|
||||||
|
expect(applyDeltaCenter).not.toHaveBeenCalled();
|
||||||
|
tool.dragEnd({} as any);
|
||||||
|
|
||||||
|
expect(applyDeltaCenter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(applyDeltaCenter).toHaveBeenCalledWith(15, 30);
|
||||||
|
expect(tool.panning$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel in unmounted drops pending deltas', () => {
|
||||||
|
mockRaf();
|
||||||
|
const { tool, applyDeltaCenter } = createToolFixture();
|
||||||
|
|
||||||
|
tool.dragStart({ x: 100, y: 100 } as any);
|
||||||
|
tool.dragMove({ x: 80, y: 60 } as any);
|
||||||
|
tool.unmounted();
|
||||||
|
tool.dragEnd({} as any);
|
||||||
|
|
||||||
|
expect(applyDeltaCenter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('middle click temporary pan restores frameNavigator with restoredAfterPan', () => {
|
||||||
|
const { tool, navigatorSettingUpdated, selectionSet, setTool } =
|
||||||
|
createToolFixture({
|
||||||
|
currentToolName: 'frameNavigator',
|
||||||
|
currentToolOptions: { mode: 'fit' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hooks: Partial<Record<'pointerDown', PointerDownHandler>> = {};
|
||||||
|
(tool as any).eventTarget = {
|
||||||
|
addHook: (eventName: 'pointerDown', handler: PointerDownHandler) => {
|
||||||
|
hooks[eventName] = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tool.mounted();
|
||||||
|
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
const pointerDown = hooks.pointerDown!;
|
||||||
|
const ret = pointerDown({
|
||||||
|
raw: {
|
||||||
|
button: MouseButton.MIDDLE,
|
||||||
|
preventDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ret).toBe(false);
|
||||||
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigatorSettingUpdated.next).toHaveBeenCalledWith({
|
||||||
|
blackBackground: false,
|
||||||
|
});
|
||||||
|
expect(setTool).toHaveBeenNthCalledWith(1, PanTool, {
|
||||||
|
panning: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new PointerEvent('pointerup', { button: MouseButton.MIDDLE })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectionSet).toHaveBeenCalledWith([{ elements: ['shape-1'] }]);
|
||||||
|
expect(setTool).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
toolName: 'frameNavigator',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
mode: 'fit',
|
||||||
|
restoredAfterPan: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export class AdaptiveStrideController {
|
||||||
|
private _stride = 1;
|
||||||
|
|
||||||
|
private _ticks = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _options: {
|
||||||
|
heavyCostMs: number;
|
||||||
|
maxStride: number;
|
||||||
|
recoveryCostMs: number;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
reportCost(costMs: number) {
|
||||||
|
if (costMs > this._options.heavyCostMs) {
|
||||||
|
this._stride = Math.min(this._options.maxStride, this._stride + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (costMs < this._options.recoveryCostMs && this._stride > 1) {
|
||||||
|
this._stride -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._stride = 1;
|
||||||
|
this._ticks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSkip() {
|
||||||
|
const shouldSkip = this._stride > 1 && this._ticks % this._stride !== 0;
|
||||||
|
this._ticks += 1;
|
||||||
|
return shouldSkip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdaptiveCooldownController {
|
||||||
|
private _remainingFrames = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _options: {
|
||||||
|
cooldownFrames: number;
|
||||||
|
maxCostMs: number;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
reportCost(costMs: number) {
|
||||||
|
if (costMs > this._options.maxCostMs) {
|
||||||
|
this._remainingFrames = this._options.cooldownFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._remainingFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldRun() {
|
||||||
|
if (this._remainingFrames <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._remainingFrames -= 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,18 @@ import {
|
|||||||
InteractivityExtension,
|
InteractivityExtension,
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
|
|
||||||
|
import { AdaptiveStrideController } from './adaptive-load-controller';
|
||||||
import type { SnapOverlay } from './snap-overlay';
|
import type { SnapOverlay } from './snap-overlay';
|
||||||
|
|
||||||
export class SnapExtension extends InteractivityExtension {
|
export class SnapExtension extends InteractivityExtension {
|
||||||
static override key = 'snap-manager';
|
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() {
|
get snapOverlay() {
|
||||||
return this.std.getOptional(
|
return this.std.getOptional(
|
||||||
OverlayIdentifier('snap-manager')
|
OverlayIdentifier('snap-manager')
|
||||||
@@ -29,6 +36,11 @@ export class SnapExtension extends InteractivityExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let alignBound: Bound | null = null;
|
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 {
|
return {
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
@@ -42,6 +54,7 @@ export class SnapExtension extends InteractivityExtension {
|
|||||||
return pre;
|
return pre;
|
||||||
}, [] as GfxModel[])
|
}, [] as GfxModel[])
|
||||||
);
|
);
|
||||||
|
alignStride.reset();
|
||||||
},
|
},
|
||||||
onDragMove(context: ExtensionDragMoveContext) {
|
onDragMove(context: ExtensionDragMoveContext) {
|
||||||
if (
|
if (
|
||||||
@@ -53,14 +66,22 @@ export class SnapExtension extends InteractivityExtension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alignStride.shouldSkip()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentBound = alignBound.moveDelta(context.dx, context.dy);
|
const currentBound = alignBound.moveDelta(context.dx, context.dy);
|
||||||
|
const alignStart = performance.now();
|
||||||
const alignRst = snapOverlay.align(currentBound);
|
const alignRst = snapOverlay.align(currentBound);
|
||||||
|
const alignCost = performance.now() - alignStart;
|
||||||
|
alignStride.reportCost(alignCost);
|
||||||
|
|
||||||
context.dx = alignRst.dx + context.dx;
|
context.dx = alignRst.dx + context.dx;
|
||||||
context.dy = alignRst.dy + context.dy;
|
context.dy = alignRst.dy + context.dy;
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
alignBound = null;
|
alignBound = null;
|
||||||
|
alignStride.reset();
|
||||||
snapOverlay.clear();
|
snapOverlay.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
|
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
|
||||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||||
|
|
||||||
|
import { AdaptiveCooldownController } from './adaptive-load-controller';
|
||||||
|
|
||||||
interface Distance {
|
interface Distance {
|
||||||
horiz?: {
|
horiz?: {
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +37,9 @@ interface Distance {
|
|||||||
const ALIGN_THRESHOLD = 8;
|
const ALIGN_THRESHOLD = 8;
|
||||||
const DISTRIBUTION_LINE_OFFSET = 1;
|
const DISTRIBUTION_LINE_OFFSET = 1;
|
||||||
const STROKE_WIDTH = 2;
|
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 {
|
export class SnapOverlay extends Overlay {
|
||||||
static override overlayName: string = 'snap-manager';
|
static override overlayName: string = 'snap-manager';
|
||||||
@@ -75,6 +80,11 @@ export class SnapOverlay extends Overlay {
|
|||||||
vertical: [],
|
vertical: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly _distributeCooldown = new AdaptiveCooldownController({
|
||||||
|
cooldownFrames: DISTRIBUTE_ALIGN_COOLDOWN_FRAMES,
|
||||||
|
maxCostMs: DISTRIBUTE_ALIGN_MAX_COST_MS,
|
||||||
|
});
|
||||||
|
|
||||||
override clear() {
|
override clear() {
|
||||||
this._referenceBounds = {
|
this._referenceBounds = {
|
||||||
vertical: [],
|
vertical: [],
|
||||||
@@ -87,6 +97,7 @@ export class SnapOverlay extends Overlay {
|
|||||||
};
|
};
|
||||||
this._distributedAlignLines = [];
|
this._distributedAlignLines = [];
|
||||||
this._skippedElements.clear();
|
this._skippedElements.clear();
|
||||||
|
this._distributeCooldown.reset();
|
||||||
|
|
||||||
super.clear();
|
super.clear();
|
||||||
}
|
}
|
||||||
@@ -673,13 +684,24 @@ export class SnapOverlay extends Overlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// point align priority is higher than distribute align
|
const shouldTryDistribute =
|
||||||
if (rst.dx === 0) {
|
this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
|
||||||
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
|
this._distributeCooldown.shouldRun();
|
||||||
}
|
|
||||||
|
|
||||||
if (rst.dy === 0) {
|
if (shouldTryDistribute) {
|
||||||
this._alignDistributeVertically(rst, bound, threshold, viewport);
|
const distributeStart = performance.now();
|
||||||
|
|
||||||
|
// point align priority is higher than distribute align
|
||||||
|
if (rst.dx === 0) {
|
||||||
|
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rst.dy === 0) {
|
||||||
|
this._alignDistributeVertically(rst, bound, threshold, viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributeCost = performance.now() - distributeStart;
|
||||||
|
this._distributeCooldown.reportCost(distributeCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._renderer?.refresh();
|
this._renderer?.refresh();
|
||||||
@@ -776,24 +798,26 @@ export class SnapOverlay extends Overlay {
|
|||||||
});
|
});
|
||||||
const verticalBounds: Bound[] = [];
|
const verticalBounds: Bound[] = [];
|
||||||
const horizBounds: Bound[] = [];
|
const horizBounds: Bound[] = [];
|
||||||
const allBounds: Bound[] = [];
|
const allCandidateElements = new Set<GfxModel>();
|
||||||
|
|
||||||
vertCandidates.forEach(candidate => {
|
vertCandidates.forEach(candidate => {
|
||||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||||
verticalBounds.push(candidate.elementBound);
|
const bound = candidate.elementBound;
|
||||||
allBounds.push(candidate.elementBound);
|
verticalBounds.push(bound);
|
||||||
|
allCandidateElements.add(candidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
horizCandidates.forEach(candidate => {
|
horizCandidates.forEach(candidate => {
|
||||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||||
horizBounds.push(candidate.elementBound);
|
const bound = candidate.elementBound;
|
||||||
allBounds.push(candidate.elementBound);
|
horizBounds.push(bound);
|
||||||
|
allCandidateElements.add(candidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._referenceBounds = {
|
this._referenceBounds = {
|
||||||
horizontal: horizBounds,
|
horizontal: horizBounds,
|
||||||
vertical: verticalBounds,
|
vertical: verticalBounds,
|
||||||
all: allBounds,
|
all: [...allCandidateElements].map(element => element.elementBound),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
} from '@blocksuite/affine-block-surface';
|
} from '@blocksuite/affine-block-surface';
|
||||||
import { on } from '@blocksuite/affine-shared/utils';
|
import { on } from '@blocksuite/affine-shared/utils';
|
||||||
import type { PointerEventState } from '@blocksuite/std';
|
import type { PointerEventState } from '@blocksuite/std';
|
||||||
import { BaseTool, MouseButton, type ToolOptions } from '@blocksuite/std/gfx';
|
import {
|
||||||
|
BaseTool,
|
||||||
|
createRafCoalescer,
|
||||||
|
MouseButton,
|
||||||
|
type ToolOptions,
|
||||||
|
} from '@blocksuite/std/gfx';
|
||||||
import { Signal } from '@preact/signals-core';
|
import { Signal } from '@preact/signals-core';
|
||||||
|
|
||||||
interface RestorablePresentToolOptions {
|
interface RestorablePresentToolOptions {
|
||||||
@@ -21,13 +26,30 @@ export class PanTool extends BaseTool<PanToolOption> {
|
|||||||
|
|
||||||
private _lastPoint: [number, number] | null = null;
|
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);
|
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 {
|
override get allowDragWithRightButton(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
override dragEnd(_: PointerEventState): void {
|
override dragEnd(_: PointerEventState): void {
|
||||||
|
this._deltaFlushCoalescer.flush();
|
||||||
this._lastPoint = null;
|
this._lastPoint = null;
|
||||||
this.panning$.value = false;
|
this.panning$.value = false;
|
||||||
}
|
}
|
||||||
@@ -43,12 +65,14 @@ export class PanTool extends BaseTool<PanToolOption> {
|
|||||||
const deltaY = lastY - e.y;
|
const deltaY = lastY - e.y;
|
||||||
|
|
||||||
this._lastPoint = [e.x, e.y];
|
this._lastPoint = [e.x, e.y];
|
||||||
|
this._pendingDelta[0] += deltaX / zoom;
|
||||||
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
|
this._pendingDelta[1] += deltaY / zoom;
|
||||||
|
this._deltaFlushCoalescer.schedule(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
override dragStart(e: PointerEventState): void {
|
override dragStart(e: PointerEventState): void {
|
||||||
this._lastPoint = [e.x, e.y];
|
this._lastPoint = [e.x, e.y];
|
||||||
|
this._pendingDelta = [0, 0];
|
||||||
this.panning$.value = true;
|
this.panning$.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,4 +144,8 @@ export class PanTool extends BaseTool<PanToolOption> {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override unmounted(): void {
|
||||||
|
this._deltaFlushCoalescer.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
25
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2018',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globalSetup: '../../../scripts/vitest-global.js',
|
||||||
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
|
testTimeout: 1000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'istanbul',
|
||||||
|
reporter: ['lcov'],
|
||||||
|
reportsDirectory: '../../../.coverage/affine-gfx-pointer',
|
||||||
|
},
|
||||||
|
onConsoleLog(log, type) {
|
||||||
|
if (log.includes('lit.dev/msg/dev-mode')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.warn(`Unexpected ${type} log`, log);
|
||||||
|
throw new Error(log);
|
||||||
|
},
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/await-thenable */
|
||||||
import type {
|
import type {
|
||||||
Template,
|
Template,
|
||||||
TemplateCategory,
|
TemplateCategory,
|
||||||
|
|||||||
@@ -155,9 +155,22 @@ export class FrameBlockModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeChild(element: GfxModel): void {
|
removeChild(element: GfxModel): void {
|
||||||
|
this.removeChildren([element]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChildren(elements: GfxModel[]): void {
|
||||||
|
const childIds = [...new Set(elements.map(element => element.id))];
|
||||||
|
if (!this.props.childElementIds || childIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.transact(() => {
|
this.store.transact(() => {
|
||||||
this.props.childElementIds &&
|
const childElementIds = this.props.childElementIds;
|
||||||
delete this.props.childElementIds[element.id];
|
if (!childElementIds) return;
|
||||||
|
|
||||||
|
childIds.forEach(childId => {
|
||||||
|
delete childElementIds[childId];
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,21 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
override addChild(element: GfxModel) {
|
override addChild(element: GfxModel) {
|
||||||
if (!canSafeAddToContainer(this, element)) {
|
this.addChildren([element]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addChildren(elements: GfxModel[]) {
|
||||||
|
elements = [...new Set(elements)].filter(element =>
|
||||||
|
canSafeAddToContainer(this, element)
|
||||||
|
);
|
||||||
|
if (elements.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.surface.store.transact(() => {
|
this.surface.store.transact(() => {
|
||||||
this.children.set(element.id, true);
|
elements.forEach(element => {
|
||||||
|
this.children.set(element.id, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +85,22 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeChild(element: GfxModel) {
|
removeChild(element: GfxModel) {
|
||||||
|
this.removeChildren([element]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChildren(elements: GfxModel[]) {
|
||||||
if (!this.children) {
|
if (!this.children) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const childIds = [...new Set(elements.map(element => element.id))];
|
||||||
|
if (childIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.surface.store.transact(() => {
|
this.surface.store.transact(() => {
|
||||||
this.children.delete(element.id);
|
childIds.forEach(childId => {
|
||||||
|
this.children.delete(childId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { isValidUrl } from '../../utils/url.js';
|
import { isValidUrl, splitTextByUrl } from '../../utils/url.js';
|
||||||
|
|
||||||
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
|
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
|
||||||
test('basic case', () => {
|
test('basic case', () => {
|
||||||
@@ -85,3 +85,55 @@ 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);
|
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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import rehypeParse from 'rehype-parse';
|
|||||||
import { unified } from 'unified';
|
import { unified } from 'unified';
|
||||||
|
|
||||||
import type { AffineTextAttributes } from '../../types/index.js';
|
import type { AffineTextAttributes } from '../../types/index.js';
|
||||||
import { HtmlDeltaConverter } from '../html/delta-converter.js';
|
import type { HtmlDeltaConverter } from '../html/delta-converter.js';
|
||||||
import {
|
import {
|
||||||
rehypeInlineToBlock,
|
rehypeInlineToBlock,
|
||||||
rehypeWrapInlineElements,
|
rehypeWrapInlineElements,
|
||||||
|
|||||||
@@ -873,7 +873,7 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
|
|||||||
return {
|
return {
|
||||||
table: {
|
table: {
|
||||||
headerRows: 0,
|
headerRows: 0,
|
||||||
widths: Array(sortedColumns.length).fill('*'),
|
widths: Array.from({ length: sortedColumns.length }, () => '*'),
|
||||||
body: tableBody,
|
body: tableBody,
|
||||||
},
|
},
|
||||||
margin: [0, 5, 0, 5],
|
margin: [0, 5, 0, 5],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store';
|
|||||||
import { type Signal, signal } from '@preact/signals-core';
|
import { type Signal, signal } from '@preact/signals-core';
|
||||||
|
|
||||||
export interface BlockSuiteFlags {
|
export interface BlockSuiteFlags {
|
||||||
enable_database_number_formatting: boolean;
|
|
||||||
enable_database_attachment_note: boolean;
|
enable_database_attachment_note: boolean;
|
||||||
enable_database_full_width: boolean;
|
enable_database_full_width: boolean;
|
||||||
enable_block_query: boolean;
|
enable_block_query: boolean;
|
||||||
@@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
static override key = 'feature-flag-server';
|
static override key = 'feature-flag-server';
|
||||||
|
|
||||||
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
||||||
enable_database_number_formatting: false,
|
|
||||||
enable_database_attachment_note: false,
|
enable_database_attachment_note: false,
|
||||||
enable_database_full_width: false,
|
enable_database_full_width: false,
|
||||||
enable_block_query: false,
|
enable_block_query: false,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
|
||||||
import { createIdentifier } from '@blocksuite/global/di';
|
import { createIdentifier } from '@blocksuite/global/di';
|
||||||
import { IS_FIREFOX } from '@blocksuite/global/env';
|
import { IS_FIREFOX } from '@blocksuite/global/env';
|
||||||
import { LifeCycleWatcher } from '@blocksuite/std';
|
import { LifeCycleWatcher } from '@blocksuite/std';
|
||||||
@@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX
|
|||||||
export class FontLoaderService extends LifeCycleWatcher {
|
export class FontLoaderService extends LifeCycleWatcher {
|
||||||
static override readonly key = 'font-loader';
|
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[] = [];
|
readonly fontFaces: FontFace[] = [];
|
||||||
|
|
||||||
get ready() {
|
get ready() {
|
||||||
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
|
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[]) {
|
load(fonts: FontConfig[]) {
|
||||||
this.fontFaces.push(
|
for (const font of fonts) {
|
||||||
...fonts.map(font => {
|
const key = this._fontKey(font);
|
||||||
const fontFace = initFontFace(font);
|
if (this._loadedFontKeys.has(key)) {
|
||||||
document.fonts.add(fontFace);
|
continue;
|
||||||
fontFace.load().catch(console.error);
|
}
|
||||||
return fontFace;
|
this._loadedFontKeys.add(key);
|
||||||
})
|
const fontFace = initFontFace(font);
|
||||||
);
|
document.fonts.add(fontFace);
|
||||||
|
fontFace.load().catch(console.error);
|
||||||
|
this.fontFaces.push(fontFace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override mounted() {
|
override mounted() {
|
||||||
const config = this.std.getOptional(FontConfigIdentifier);
|
const config = this.std.getOptional(FontConfigIdentifier);
|
||||||
if (config) {
|
if (!config || config.length === 0) {
|
||||||
this.load(config);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const criticalFonts = config.filter(this._isCriticalCanvasFont);
|
||||||
|
const eagerFonts =
|
||||||
|
criticalFonts.length > 0 ? criticalFonts : config.slice(0, 3);
|
||||||
|
const eagerFontKeySet = new Set(eagerFonts.map(this._fontKey));
|
||||||
|
const deferredFonts = config.filter(
|
||||||
|
font => !eagerFontKeySet.has(this._fontKey(font))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.load(eagerFonts);
|
||||||
|
this._scheduleDeferredLoad(deferredFonts);
|
||||||
}
|
}
|
||||||
|
|
||||||
override unmounted() {
|
override unmounted() {
|
||||||
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
|
this._cancelDeferredLoad();
|
||||||
|
for (const fontFace of this.fontFaces) {
|
||||||
|
document.fonts.delete(fontFace);
|
||||||
|
}
|
||||||
this.fontFaces.splice(0, this.fontFaces.length);
|
this.fontFaces.splice(0, this.fontFaces.length);
|
||||||
|
this._loadedFontKeys.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,12 +115,9 @@ export async function printToPdf(
|
|||||||
) as HTMLDivElement;
|
) as HTMLDivElement;
|
||||||
|
|
||||||
// force light theme in print iframe
|
// force light theme in print iframe
|
||||||
iframe.contentWindow.document.documentElement.setAttribute(
|
iframe.contentWindow.document.documentElement.dataset.theme = 'light';
|
||||||
'data-theme',
|
iframe.contentWindow.document.body.dataset.theme = 'light';
|
||||||
'light'
|
importedRoot.dataset.theme = 'light';
|
||||||
);
|
|
||||||
iframe.contentWindow.document.body.setAttribute('data-theme', 'light');
|
|
||||||
importedRoot.setAttribute('data-theme', 'light');
|
|
||||||
|
|
||||||
// draw saved canvas image to canvas
|
// draw saved canvas image to canvas
|
||||||
const allImportedCanvas = importedRoot.getElementsByTagName('canvas');
|
const allImportedCanvas = importedRoot.getElementsByTagName('canvas');
|
||||||
|
|||||||
@@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
|
|||||||
return result?.allowed ?? false;
|
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
|
// https://en.wikipedia.org/wiki/Top-level_domain
|
||||||
const COMMON_TLDS = new Set([
|
const COMMON_TLDS = new Set([
|
||||||
'com',
|
|
||||||
'org',
|
|
||||||
'net',
|
|
||||||
'edu',
|
|
||||||
'gov',
|
|
||||||
'co',
|
|
||||||
'io',
|
|
||||||
'me',
|
|
||||||
'moe',
|
|
||||||
'mil',
|
|
||||||
'top',
|
|
||||||
'dev',
|
|
||||||
'xyz',
|
|
||||||
'info',
|
|
||||||
'cat',
|
'cat',
|
||||||
'ru',
|
'co',
|
||||||
|
'com',
|
||||||
'de',
|
'de',
|
||||||
|
'dev',
|
||||||
|
'edu',
|
||||||
|
'eu',
|
||||||
|
'gov',
|
||||||
|
'info',
|
||||||
|
'io',
|
||||||
'jp',
|
'jp',
|
||||||
'uk',
|
'me',
|
||||||
|
'mil',
|
||||||
|
'moe',
|
||||||
|
'net',
|
||||||
|
'org',
|
||||||
'pro',
|
'pro',
|
||||||
|
'ru',
|
||||||
|
'top',
|
||||||
|
'uk',
|
||||||
|
'xyz',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isCommonTLD(url: URL) {
|
function isCommonTLD(url: URL) {
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ import {
|
|||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
import type { AffineDragHandleWidget } from '../drag-handle.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
|
* Used to control the drag handle visibility in edgeless mode
|
||||||
*
|
*
|
||||||
@@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
|
|||||||
* 2. Multiple selection is not supported
|
* 2. Multiple selection is not supported
|
||||||
*/
|
*/
|
||||||
export class EdgelessWatcher {
|
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 = (
|
private readonly _handleEdgelessToolUpdated = (
|
||||||
newTool: ToolOptionWithType
|
newTool: ToolOptionWithType
|
||||||
) => {
|
) => {
|
||||||
@@ -43,46 +100,123 @@ export class EdgelessWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.widget.center[0] !== center[0] &&
|
this.widget.center[0] !== center[0] ||
|
||||||
this.widget.center[1] !== center[1]
|
this.widget.center[1] !== center[1]
|
||||||
) {
|
) {
|
||||||
this.widget.center = [...center];
|
this.widget.center = [...center];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.widget.isGfxDragHandleVisible) {
|
if (this.widget.isGfxDragHandleVisible) {
|
||||||
this._showDragHandle();
|
const area = this.hoveredElemArea;
|
||||||
this._updateDragHoverRectTopLevelBlock();
|
this._showDragHandle(area);
|
||||||
|
this._updateDragHoverRectTopLevelBlock(area);
|
||||||
} else if (this.widget.activeDragHandle) {
|
} else if (this.widget.activeDragHandle) {
|
||||||
this.widget.hide();
|
this.widget.hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _showDragHandle = () => {
|
private readonly _flushShowDragHandle = () => {
|
||||||
if (!this.widget.anchorBlockId) return;
|
this._showDragHandleRafId = null;
|
||||||
|
|
||||||
|
if (!this.widget.anchorBlockId.peek()) return;
|
||||||
|
|
||||||
const container = this.widget.dragHandleContainer;
|
const container = this.widget.dragHandleContainer;
|
||||||
const grabber = this.widget.dragHandleGrabber;
|
const grabber = this.widget.dragHandleGrabber;
|
||||||
if (!container || !grabber) return;
|
if (!container || !grabber) return;
|
||||||
|
|
||||||
const area = this.hoveredElemArea;
|
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
|
||||||
|
this._pendingHoveredElemArea = null;
|
||||||
if (!area) return;
|
if (!area) return;
|
||||||
|
|
||||||
container.style.transition = 'none';
|
if (
|
||||||
container.style.paddingTop = `0px`;
|
this.widget.isGfxDragHandleVisible &&
|
||||||
container.style.paddingBottom = `0px`;
|
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
|
||||||
container.style.left = `${area.left}px`;
|
) {
|
||||||
container.style.top = `${area.top}px`;
|
return;
|
||||||
container.style.display = 'flex';
|
}
|
||||||
|
|
||||||
|
if (container.style.transition !== 'none') {
|
||||||
|
container.style.transition = 'none';
|
||||||
|
}
|
||||||
|
const nextPaddingTop = '0px';
|
||||||
|
if (container.style.paddingTop !== nextPaddingTop) {
|
||||||
|
container.style.paddingTop = nextPaddingTop;
|
||||||
|
}
|
||||||
|
const nextPaddingBottom = '0px';
|
||||||
|
if (container.style.paddingBottom !== nextPaddingBottom) {
|
||||||
|
container.style.paddingBottom = nextPaddingBottom;
|
||||||
|
}
|
||||||
|
const nextLeft = `${area.left}px`;
|
||||||
|
if (container.style.left !== nextLeft) {
|
||||||
|
container.style.left = nextLeft;
|
||||||
|
}
|
||||||
|
const nextTop = `${area.top}px`;
|
||||||
|
if (container.style.top !== nextTop) {
|
||||||
|
container.style.top = nextTop;
|
||||||
|
}
|
||||||
|
if (container.style.display !== 'flex') {
|
||||||
|
container.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
this.widget.handleAnchorModelDisposables();
|
this.widget.handleAnchorModelDisposables();
|
||||||
|
|
||||||
this.widget.activeDragHandle = 'gfx';
|
this.widget.activeDragHandle = 'gfx';
|
||||||
|
this._lastAppliedHoveredElemArea = this._cloneArea(area);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _updateDragHoverRectTopLevelBlock = () => {
|
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
|
||||||
|
const nextArea = area ?? this.hoveredElemArea;
|
||||||
|
this._pendingHoveredElemArea = nextArea;
|
||||||
|
if (!this._pendingHoveredElemArea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.widget.isGfxDragHandleVisible &&
|
||||||
|
this._showDragHandleRafId === null &&
|
||||||
|
this._isAreaEqual(
|
||||||
|
this._lastAppliedHoveredElemArea,
|
||||||
|
this._pendingHoveredElemArea
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._showDragHandleRafId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._showDragHandleRafId = requestAnimationFrame(
|
||||||
|
this._flushShowDragHandle
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _updateDragHoverRectTopLevelBlock = (
|
||||||
|
area?: HoveredElemArea | null
|
||||||
|
) => {
|
||||||
if (!this.widget.dragHoverRect) return;
|
if (!this.widget.dragHoverRect) return;
|
||||||
|
|
||||||
this.widget.dragHoverRect = this.hoveredElemAreaRect;
|
const nextArea = area ?? this.hoveredElemArea;
|
||||||
|
if (!nextArea) {
|
||||||
|
this.widget.dragHoverRect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRect = new Rect(
|
||||||
|
nextArea.left,
|
||||||
|
nextArea.top,
|
||||||
|
nextArea.right,
|
||||||
|
nextArea.bottom
|
||||||
|
);
|
||||||
|
const prevRect = this.widget.dragHoverRect;
|
||||||
|
if (
|
||||||
|
prevRect &&
|
||||||
|
prevRect.left === nextRect.left &&
|
||||||
|
prevRect.top === nextRect.top &&
|
||||||
|
prevRect.width === nextRect.width &&
|
||||||
|
prevRect.height === nextRect.height
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.widget.dragHoverRect = nextRect;
|
||||||
};
|
};
|
||||||
|
|
||||||
get gfx() {
|
get gfx() {
|
||||||
@@ -123,7 +257,7 @@ export class EdgelessWatcher {
|
|||||||
return new Rect(area.left, area.top, area.right, area.bottom);
|
return new Rect(area.left, area.top, area.right, area.bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
get hoveredElemArea() {
|
get hoveredElemArea(): HoveredElemArea | null {
|
||||||
const edgelessElement = this.widget.anchorEdgelessElement.peek();
|
const edgelessElement = this.widget.anchorEdgelessElement.peek();
|
||||||
|
|
||||||
if (!edgelessElement) return null;
|
if (!edgelessElement) return null;
|
||||||
@@ -174,6 +308,19 @@ export class EdgelessWatcher {
|
|||||||
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
|
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(
|
disposables.add(
|
||||||
selection.slots.updated.subscribe(() => {
|
selection.slots.updated.subscribe(() => {
|
||||||
this.updateAnchorElement();
|
this.updateAnchorElement();
|
||||||
@@ -216,7 +363,7 @@ export class EdgelessWatcher {
|
|||||||
this.widget.hide();
|
this.widget.hide();
|
||||||
}
|
}
|
||||||
if (payload.type === 'update') {
|
if (payload.type === 'update') {
|
||||||
this._showDragHandle();
|
this._scheduleShowDragHandleFromSurfaceUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -224,9 +371,10 @@ export class EdgelessWatcher {
|
|||||||
|
|
||||||
if (surface) {
|
if (surface) {
|
||||||
disposables.add(
|
disposables.add(
|
||||||
surface.elementUpdated.subscribe(() => {
|
surface.elementUpdated.subscribe(({ id }) => {
|
||||||
if (this.widget.isGfxDragHandleVisible) {
|
if (this.widget.isGfxDragHandleVisible) {
|
||||||
this._showDragHandle();
|
if (id !== this.widget.anchorBlockId.peek()) return;
|
||||||
|
this._scheduleShowDragHandleFromSurfaceUpdate();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export class PointerEventWatcher {
|
|||||||
|
|
||||||
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
|
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
|
* When pointer move on block, should show drag handle
|
||||||
* And update hover block id and path
|
* And update hover block id and path
|
||||||
@@ -169,6 +173,7 @@ export class PointerEventWatcher {
|
|||||||
point
|
point
|
||||||
);
|
);
|
||||||
if (!closestBlock) {
|
if (!closestBlock) {
|
||||||
|
this._lastPointerHitBlockId = null;
|
||||||
this.widget.anchorBlockId.value = null;
|
this.widget.anchorBlockId.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -237,19 +242,38 @@ export class PointerEventWatcher {
|
|||||||
|
|
||||||
const state = ctx.get('pointerState');
|
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 { target } = state.raw;
|
||||||
const element = captureEventTarget(target);
|
const element = captureEventTarget(target);
|
||||||
// When pointer not on block or on dragging, should do nothing
|
// When pointer not on block or on dragging, should do nothing
|
||||||
if (!element) return;
|
if (!element) {
|
||||||
|
this._lastPointerHitBlockId = null;
|
||||||
|
this._lastPointerHitBlockElement = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// When pointer on drag handle, should do nothing
|
// When pointer on drag handle, should do nothing
|
||||||
if (element.closest('.affine-drag-handle-container')) return;
|
if (element.closest('.affine-drag-handle-container')) return;
|
||||||
|
|
||||||
if (!this.widget.rootComponent) 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
|
// 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);
|
const point = new Point(state.raw.x, state.raw.y);
|
||||||
|
|
||||||
@@ -354,6 +378,8 @@ export class PointerEventWatcher {
|
|||||||
reset() {
|
reset() {
|
||||||
this._lastHoveredBlockId = null;
|
this._lastHoveredBlockId = null;
|
||||||
this._lastShowedBlock = null;
|
this._lastShowedBlock = null;
|
||||||
|
this._lastPointerHitBlockId = null;
|
||||||
|
this._lastPointerHitBlockElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch() {
|
watch() {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
|
|||||||
|
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.gfx.tool.currentToolName$.value;
|
void this.gfx.tool.currentToolName$.value;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
|||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const std = this.rootComponent.std;
|
const std = this.rootComponent.std;
|
||||||
std.selection.value;
|
void std.selection.value;
|
||||||
// wait cursor updated
|
// wait cursor updated
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this._scrollCurrentBlockIntoView();
|
this._scrollCurrentBlockIntoView();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
|
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
|
||||||
// @ts-ignore
|
// @ts-expect-error -- mammoth.browser has no compatible type declaration for this subpath.
|
||||||
import { convertToHtml } from 'mammoth/mammoth.browser';
|
import { convertToHtml } from 'mammoth/mammoth.browser';
|
||||||
|
|
||||||
import { HtmlTransformer } from './html';
|
import { HtmlTransformer } from './html';
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { Container } from '@blocksuite/global/di';
|
|||||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||||
import { sha } from '@blocksuite/global/utils';
|
import { sha } from '@blocksuite/global/utils';
|
||||||
import type {
|
import type {
|
||||||
|
DocMeta,
|
||||||
ExtensionType,
|
ExtensionType,
|
||||||
Schema,
|
Schema,
|
||||||
Store,
|
Store,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from '@blocksuite/store';
|
} from '@blocksuite/store';
|
||||||
import type { DocMeta } from '@blocksuite/store';
|
|
||||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||||
|
|
||||||
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
||||||
|
|||||||
@@ -171,9 +171,11 @@ export class Unzip {
|
|||||||
const fileExt =
|
const fileExt =
|
||||||
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
|
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
|
||||||
const mime = extMimeMap.get(fileExt ?? '');
|
const mime = extMimeMap.get(fileExt ?? '');
|
||||||
const content = new File([this.unzipped![path]], fileName, {
|
const content = new File(
|
||||||
type: mime ?? '',
|
[new Uint8Array(this.unzipped![path]).buffer],
|
||||||
}) as Blob;
|
fileName,
|
||||||
|
mime ? { type: mime } : undefined
|
||||||
|
) as Blob;
|
||||||
|
|
||||||
const fixedPath = this.fixFileNameEncoding(path);
|
const fixedPath = this.fixFileNameEncoding(path);
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ async function exportDocs(
|
|||||||
titleMiddleware(collection.meta.docMetas),
|
titleMiddleware(collection.meta.docMetas),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const snapshots = await Promise.all(docs.map(job.docToSnapshot));
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
snapshots
|
docs
|
||||||
|
.map(job.docToSnapshot)
|
||||||
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
|
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
|
||||||
.map(async snapshot => {
|
.map(async snapshot => {
|
||||||
// Use the title and id as the snapshot file name
|
// Use the title and id as the snapshot file name
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
- [canSafeAddToContainer](functions/canSafeAddToContainer.md)
|
- [canSafeAddToContainer](functions/canSafeAddToContainer.md)
|
||||||
- [compareLayer](functions/compareLayer.md)
|
- [compareLayer](functions/compareLayer.md)
|
||||||
- [convert](functions/convert.md)
|
- [convert](functions/convert.md)
|
||||||
|
- [createRafCoalescer](functions/createRafCoalescer.md)
|
||||||
- [derive](functions/derive.md)
|
- [derive](functions/derive.md)
|
||||||
- [generateKeyBetween](functions/generateKeyBetween.md)
|
- [generateKeyBetween](functions/generateKeyBetween.md)
|
||||||
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
||||||
@@ -42,5 +43,6 @@
|
|||||||
- [GfxCompatible](functions/GfxCompatible.md)
|
- [GfxCompatible](functions/GfxCompatible.md)
|
||||||
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
||||||
- [local](functions/local.md)
|
- [local](functions/local.md)
|
||||||
|
- [measureOperation](functions/measureOperation.md)
|
||||||
- [observe](functions/observe.md)
|
- [observe](functions/observe.md)
|
||||||
- [watch](functions/watch.md)
|
- [watch](functions/watch.md)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[**BlockSuite API Documentation**](../../../../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / createRafCoalescer
|
||||||
|
|
||||||
|
# Function: createRafCoalescer()
|
||||||
|
|
||||||
|
> **createRafCoalescer**\<`T`\>(`apply`): `RafCoalescer`\<`T`\>
|
||||||
|
|
||||||
|
Coalesce high-frequency updates and only process the latest payload in one frame.
|
||||||
|
|
||||||
|
## Type Parameters
|
||||||
|
|
||||||
|
### T
|
||||||
|
|
||||||
|
`T`
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### apply
|
||||||
|
|
||||||
|
(`payload`) => `void`
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
`RafCoalescer`\<`T`\>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[**BlockSuite API Documentation**](../../../../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / measureOperation
|
||||||
|
|
||||||
|
# Function: measureOperation()
|
||||||
|
|
||||||
|
> **measureOperation**\<`T`\>(`name`, `fn`): `T`
|
||||||
|
|
||||||
|
Measure operation cost via Performance API when available.
|
||||||
|
|
||||||
|
Marks are always cleared, while measure entries are intentionally retained
|
||||||
|
so callers can inspect them from Performance tools.
|
||||||
|
|
||||||
|
## Type Parameters
|
||||||
|
|
||||||
|
### T
|
||||||
|
|
||||||
|
`T`
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### name
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
### fn
|
||||||
|
|
||||||
|
() => `T`
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
`T`
|
||||||
@@ -356,3 +356,63 @@ describe('convert decorator', () => {
|
|||||||
expect(elementModel.shapeType).toBe('rect');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type GfxGroupCompatibleInterface,
|
||||||
|
gfxGroupCompatibleSymbol,
|
||||||
|
} from '../../gfx/model/base.js';
|
||||||
|
import type { GfxModel } from '../../gfx/model/model.js';
|
||||||
|
import {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
|
canSafeAddToContainer,
|
||||||
|
descendantElementsImpl,
|
||||||
|
getTopElements,
|
||||||
|
} from '../../utils/tree.js';
|
||||||
|
|
||||||
|
type TestElement = {
|
||||||
|
id: string;
|
||||||
|
group: TestGroup | null;
|
||||||
|
groups: TestGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestGroup = TestElement & {
|
||||||
|
[gfxGroupCompatibleSymbol]: true;
|
||||||
|
childIds: string[];
|
||||||
|
childElements: GfxModel[];
|
||||||
|
addChild: (element: GfxModel) => void;
|
||||||
|
removeChild: (element: GfxModel) => void;
|
||||||
|
hasChild: (element: GfxModel) => boolean;
|
||||||
|
hasDescendant: (element: GfxModel) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createElement = (id: string): TestElement => ({
|
||||||
|
id,
|
||||||
|
group: null,
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroup = (id: string): TestGroup => {
|
||||||
|
const group: TestGroup = {
|
||||||
|
id,
|
||||||
|
[gfxGroupCompatibleSymbol]: true,
|
||||||
|
group: null,
|
||||||
|
groups: [],
|
||||||
|
childIds: [],
|
||||||
|
childElements: [],
|
||||||
|
addChild(element: GfxModel) {
|
||||||
|
const child = element as unknown as TestElement;
|
||||||
|
if (this.childElements.includes(element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.childElements.push(element);
|
||||||
|
this.childIds.push(child.id);
|
||||||
|
child.group = this;
|
||||||
|
child.groups = [...this.groups, this];
|
||||||
|
},
|
||||||
|
removeChild(element: GfxModel) {
|
||||||
|
const child = element as unknown as TestElement;
|
||||||
|
this.childElements = this.childElements.filter(item => item !== element);
|
||||||
|
this.childIds = this.childIds.filter(id => id !== child.id);
|
||||||
|
if (child.group === this) {
|
||||||
|
child.group = null;
|
||||||
|
child.groups = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hasChild(element: GfxModel) {
|
||||||
|
return this.childElements.includes(element);
|
||||||
|
},
|
||||||
|
hasDescendant(element: GfxModel) {
|
||||||
|
return descendantElementsImpl(
|
||||||
|
this as unknown as GfxGroupCompatibleInterface
|
||||||
|
).includes(element);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('tree utils', () => {
|
||||||
|
test('batchAddChildren prefers container.addChildren and deduplicates', () => {
|
||||||
|
const a = createElement('a') as unknown as GfxModel;
|
||||||
|
const b = createElement('b') as unknown as GfxModel;
|
||||||
|
const container = {
|
||||||
|
addChildren: vi.fn(),
|
||||||
|
addChild: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
batchAddChildren(container as any, [a, a, b]);
|
||||||
|
|
||||||
|
expect(container.addChildren).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.addChildren).toHaveBeenCalledWith([a, b]);
|
||||||
|
expect(container.addChild).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batchRemoveChildren falls back to container.removeChild and deduplicates', () => {
|
||||||
|
const a = createElement('a') as unknown as GfxModel;
|
||||||
|
const b = createElement('b') as unknown as GfxModel;
|
||||||
|
const container = {
|
||||||
|
removeChild: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
batchRemoveChildren(container as any, [a, a, b]);
|
||||||
|
|
||||||
|
expect(container.removeChild).toHaveBeenCalledTimes(2);
|
||||||
|
expect(container.removeChild).toHaveBeenNthCalledWith(1, a);
|
||||||
|
expect(container.removeChild).toHaveBeenNthCalledWith(2, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTopElements removes descendants when ancestors are selected', () => {
|
||||||
|
const root = createGroup('root');
|
||||||
|
const nested = createGroup('nested');
|
||||||
|
const leafA = createElement('leaf-a');
|
||||||
|
const leafB = createElement('leaf-b');
|
||||||
|
const leafC = createElement('leaf-c');
|
||||||
|
|
||||||
|
root.addChild(leafA as unknown as GfxModel);
|
||||||
|
root.addChild(nested as unknown as GfxModel);
|
||||||
|
nested.addChild(leafB as unknown as GfxModel);
|
||||||
|
|
||||||
|
const result = getTopElements([
|
||||||
|
root as unknown as GfxModel,
|
||||||
|
nested as unknown as GfxModel,
|
||||||
|
leafA as unknown as GfxModel,
|
||||||
|
leafB as unknown as GfxModel,
|
||||||
|
leafC as unknown as GfxModel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
root as unknown as GfxModel,
|
||||||
|
leafC as unknown as GfxModel,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('descendantElementsImpl stops on cyclic graph', () => {
|
||||||
|
const groupA = createGroup('group-a');
|
||||||
|
const groupB = createGroup('group-b');
|
||||||
|
groupA.addChild(groupB as unknown as GfxModel);
|
||||||
|
groupB.addChild(groupA as unknown as GfxModel);
|
||||||
|
|
||||||
|
const descendants = descendantElementsImpl(groupA as unknown as any);
|
||||||
|
|
||||||
|
expect(descendants).toHaveLength(2);
|
||||||
|
expect(new Set(descendants).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canSafeAddToContainer blocks self and circular descendants', () => {
|
||||||
|
const parent = createGroup('parent');
|
||||||
|
const child = createGroup('child');
|
||||||
|
const unrelated = createElement('plain');
|
||||||
|
|
||||||
|
parent.addChild(child as unknown as GfxModel);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canSafeAddToContainer(parent as unknown as any, parent as unknown as any)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canSafeAddToContainer(child as unknown as any, parent as unknown as any)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canSafeAddToContainer(
|
||||||
|
parent as unknown as any,
|
||||||
|
unrelated as unknown as any
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -190,7 +190,7 @@ export class Clipboard extends LifeCycleWatcher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return slice;
|
return slice;
|
||||||
} catch (error) {
|
} catch {
|
||||||
const getDataByType = this._getDataByType(data);
|
const getDataByType = this._getDataByType(data);
|
||||||
const slice = await this._getSnapshotByPriority(
|
const slice = await this._getSnapshotByPriority(
|
||||||
type => getDataByType(type),
|
type => getDataByType(type),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LifeCycleWatcher } from '../extension/index.js';
|
|
||||||
import { BlockServiceIdentifier } from '../identifier.js';
|
import { BlockServiceIdentifier } from '../identifier.js';
|
||||||
|
import { LifeCycleWatcher } from './lifecycle-watcher.js';
|
||||||
|
|
||||||
export class ServiceManager extends LifeCycleWatcher {
|
export class ServiceManager extends LifeCycleWatcher {
|
||||||
static override readonly key = 'serviceManager';
|
static override readonly key = 'serviceManager';
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
} from '../utils/layer.js';
|
} from '../utils/layer.js';
|
||||||
export {
|
export {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
canSafeAddToContainer,
|
canSafeAddToContainer,
|
||||||
descendantElementsImpl,
|
descendantElementsImpl,
|
||||||
getTopElements,
|
getTopElements,
|
||||||
@@ -94,6 +96,8 @@ export {
|
|||||||
type SurfaceBlockProps,
|
type SurfaceBlockProps,
|
||||||
type SurfaceMiddleware,
|
type SurfaceMiddleware,
|
||||||
} from './model/surface/surface-model.js';
|
} 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 { GfxSelectionManager } from './selection.js';
|
||||||
export {
|
export {
|
||||||
SurfaceMiddlewareBuilder,
|
SurfaceMiddlewareBuilder,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
|||||||
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
||||||
import type { GfxModel } from '../model/model.js';
|
import type { GfxModel } from '../model/model.js';
|
||||||
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||||
|
import { createRafCoalescer } from '../raf-coalescer.js';
|
||||||
import type { GfxElementModelView } from '../view/view.js';
|
import type { GfxElementModelView } from '../view/view.js';
|
||||||
import { createInteractionContext, type SupportedEvents } from './event.js';
|
import { createInteractionContext, type SupportedEvents } from './event.js';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,20 @@ export const InteractivityIdentifier = GfxExtensionIdentifier(
|
|||||||
'interactivity-manager'
|
'interactivity-manager'
|
||||||
) as ServiceIdentifier<InteractivityManager>;
|
) 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 {
|
export class InteractivityManager extends GfxExtension {
|
||||||
static override key = 'interactivity-manager';
|
static override key = 'interactivity-manager';
|
||||||
|
|
||||||
@@ -381,11 +396,18 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
};
|
};
|
||||||
let dragLastPos = internal.dragStartPos;
|
let dragLastPos = internal.dragStartPos;
|
||||||
let lastEvent = event;
|
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(
|
dragLastPos = Point.from(
|
||||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||||
);
|
);
|
||||||
@@ -407,6 +429,16 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
moveContext[direction] = 0;
|
moveContext[direction] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastMoveDelta &&
|
||||||
|
lastMoveDelta[0] === moveContext.dx &&
|
||||||
|
lastMoveDelta[1] === moveContext.dy
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMoveDelta = [moveContext.dx, moveContext.dy];
|
||||||
|
|
||||||
this._safeExecute(() => {
|
this._safeExecute(() => {
|
||||||
activeExtensionHandlers.forEach(handler =>
|
activeExtensionHandlers.forEach(handler =>
|
||||||
handler?.onDragMove?.(moveContext)
|
handler?.onDragMove?.(moveContext)
|
||||||
@@ -423,13 +455,39 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
elements: internal.elements,
|
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) => {
|
const onDragEnd = (event: PointerEvent) => {
|
||||||
this.activeInteraction$.value = null;
|
this.activeInteraction$.value = null;
|
||||||
|
|
||||||
host.removeEventListener('pointermove', onDragMove, false);
|
host.removeEventListener('pointermove', onDragMove, false);
|
||||||
host.removeEventListener('pointerup', onDragEnd, false);
|
host.removeEventListener('pointerup', onDragEnd, false);
|
||||||
viewportWatcher.unsubscribe();
|
viewportWatcher.unsubscribe();
|
||||||
|
flushPendingDragMove();
|
||||||
|
|
||||||
dragLastPos = Point.from(
|
dragLastPos = Point.from(
|
||||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user