commit 702cc698a77b36c2525f55854da440cc3290ad32 Author: upstream-mirror Date: Wed Sep 16 04:01:20 2026 +0500 snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted) diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..a3b23341 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,26 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] +[target.'cfg(target_os = "linux")'] +rustflags = ["-C", "link-args=-Wl,--warn-unresolved-symbols"] +[target.'cfg(target_os = "macos")'] +rustflags = [ + "-C", + "link-args=-Wl,-undefined,dynamic_lookup,-no_fixup_chains", + "-C", + "link-args=-all_load", + "-C", + "link-args=-weak_framework ScreenCaptureKit", +] +# https://sourceware.org/bugzilla/show_bug.cgi?id=21032 +# https://sourceware.org/bugzilla/show_bug.cgi?id=21031 +# https://github.com/rust-lang/rust/issues/134820 +# pthread_key_create() destructors and segfault after a DSO unloading +[target.'cfg(all(target_env = "gnu", not(target_os = "windows")))'] +rustflags = ["-C", "link-args=-Wl,-z,nodelete"] + +# Temporary local llm_adapter override. +# Uncomment when verifying AFFiNE against the sibling llm_adapter workspace. +# [patch.crates-io] +# llm_adapter = { path = "../llm_adapter" } diff --git a/.codesandbox/task.json b/.codesandbox/task.json new file mode 100644 index 00000000..5ae39af7 --- /dev/null +++ b/.codesandbox/task.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://codesandbox.io/schemas/tasks.json", + "setupTasks": [ + { + "name": "Install Dependencies", + "command": "yarn install" + } + ], + + "tasks": { + "start-web": { + "name": "Start Web", + "command": "yarn dev-core", + "runAtStart": true, + "preview": { + "port": 8080 + } + } + } +} diff --git a/.devcontainer/build.sh b/.devcontainer/build.sh new file mode 100644 index 00000000..9642696b --- /dev/null +++ b/.devcontainer/build.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# This is a script used by the devcontainer to build the project + +# install dependencies +yarn install + +# Build Server Dependencies +yarn affine @affine/server-native build + +# Create database +yarn affine @affine/server prisma migrate reset -f diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..e59b7a4d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,25 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "AFFiNE Dev Container", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "containerEnv": { + "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0" + }, + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "lts", + "installYarnUsingApt": false + }, + "ghcr.io/devcontainers/features/rust:1": {} + }, + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": ["ms-playwright.playwright", "oxc.oxc-vscode"] + } + }, + "updateContentCommand": "bash ./.devcontainer/build.sh", + "postCreateCommand": "bash ./.devcontainer/setup-user.sh" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..d2ba5dec --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + app: + security_opt: + - no-new-privileges:true + image: mcr.microsoft.com/devcontainers/base:bookworm + volumes: + - ../..:/workspaces:cached + command: sleep infinity + network_mode: service:db + environment: + DATABASE_URL: postgresql://affine:affine@db:5432/affine + REDIS_SERVER_HOST: redis + + db: + image: pgvector/pgvector:pg16 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: affine + POSTGRES_USER: affine + POSTGRES_DB: affine + redis: + image: redis + +volumes: + postgres-data: diff --git a/.devcontainer/setup-user.sh b/.devcontainer/setup-user.sh new file mode 100755 index 00000000..5ee5dd1d --- /dev/null +++ b/.devcontainer/setup-user.sh @@ -0,0 +1,9 @@ +set -e + +npm install -g @withgraphite/graphite-cli@stable + +if [ -v GRAPHITE_TOKEN ];then + gt auth --token $GRAPHITE_TOKEN +fi + +gt init --trunk canary diff --git a/.docker/dev/.env.example b/.docker/dev/.env.example new file mode 100644 index 00000000..7adcc084 --- /dev/null +++ b/.docker/dev/.env.example @@ -0,0 +1,12 @@ +# postgres major version +DB_VERSION=16 +# database credentials +DB_PASSWORD=affine +DB_USERNAME=affine +DB_DATABASE_NAME=affine + +# elasticsearch env +# ELASTIC_VERSION=9.0.1 +# enable for arm64, e.g.: macOS M1+ +# ELASTIC_VERSION_ARM64=-arm64 +# ELASTIC_PLATFORM=linux/arm64 diff --git a/.docker/dev/.gitignore b/.docker/dev/.gitignore new file mode 100644 index 00000000..30ddd27b --- /dev/null +++ b/.docker/dev/.gitignore @@ -0,0 +1,6 @@ +postgres +.env +compose.yml +certs/* +!certs/.gitkeep +nginx/conf.d/* \ No newline at end of file diff --git a/.docker/dev/README.md b/.docker/dev/README.md new file mode 100644 index 00000000..3a9d4b42 --- /dev/null +++ b/.docker/dev/README.md @@ -0,0 +1,21 @@ +# Dev containers + +## Develop with domain + +> MacOs only, OrbStack only + +### 1. Generate and install Root CA + +```bash +# the root ca file will be located at `./.docker/dev/certs/ca` +yarn affine cert --install +``` + +### 2. Generate domain certs + +```bash +# certificates will be located at `./.docker/dev/certs/${domain}` +yarn affine cert --domain affine.localhost +``` + +### 3. Enable nginx service in compose.yml diff --git a/.docker/dev/certs/.gitkeep b/.docker/dev/certs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.docker/dev/compose.yml.example b/.docker/dev/compose.yml.example new file mode 100644 index 00000000..30202cd9 --- /dev/null +++ b/.docker/dev/compose.yml.example @@ -0,0 +1,82 @@ +name: affine_dev_services +services: + postgres: + env_file: + - .env + image: pgvector/pgvector:pg${DB_VERSION:-16} + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_DB: ${DB_DATABASE_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:latest + ports: + - 6379:6379 + + # https://mailpit.axllent.org/docs/install/docker/ + mailpit: + image: axllent/mailpit:latest + ports: + - 1025:1025 + - 8025:8025 + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + volumes: + - mailpit_data:/data + + # elasticsearch: + # image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-9.0.1}${ELASTIC_VERSION_ARM64} + # platform: ${ELASTIC_PLATFORM} + # labels: + # co.elastic.logs/module: elasticsearch + # volumes: + # - elasticsearch_data:/usr/share/elasticsearch/data + # ports: + # - ${ES_PORT:-9200}:9200 + # environment: + # - node.name=es01 + # - cluster.name=affine-dev + # - discovery.type=single-node + # - bootstrap.memory_lock=true + # - xpack.security.enabled=false + # - xpack.security.http.ssl.enabled=false + # - xpack.security.transport.ssl.enabled=false + # - xpack.license.self_generated.type=basic + # mem_limit: ${ES_MEM_LIMIT:-1073741824} + # ulimits: + # memlock: + # soft: -1 + # hard: -1 + # healthcheck: + # test: + # [ + # "CMD-SHELL", + # "curl -s http://localhost:9200 | grep -q 'affine-dev'", + # ] + # interval: 10s + # timeout: 10s + # retries: 120 + + # nginx: + # image: nginx:alpine + # volumes: + # - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + # - ./nginx/conf.d:/etc/nginx/conf.d:ro + # - ./certs:/etc/nginx/certs:ro + # network_mode: host + +networks: + dev: + +volumes: + postgres_data: + mailpit_data: + elasticsearch_data: diff --git a/.docker/dev/nginx/nginx.conf b/.docker/dev/nginx/nginx.conf new file mode 100644 index 00000000..2c3988aa --- /dev/null +++ b/.docker/dev/nginx/nginx.conf @@ -0,0 +1,28 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 512M; + server_names_hash_bucket_size 128; + ssi on; + gzip on; + include "/etc/nginx/conf.d/*"; +} diff --git a/.docker/dev/templates/nginx.conf b/.docker/dev/templates/nginx.conf new file mode 100644 index 00000000..d3c83324 --- /dev/null +++ b/.docker/dev/templates/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name DEV_DOMAIN; + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + ssl_certificate /etc/nginx/certs/$host/crt; + ssl_certificate_key /etc/nginx/certs/$host/key; + server_name DEV_DOMAIN; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + resolver 127.0.0.1; + } +} \ No newline at end of file diff --git a/.docker/dev/templates/openssl.conf b/.docker/dev/templates/openssl.conf new file mode 100644 index 00000000..45504401 --- /dev/null +++ b/.docker/dev/templates/openssl.conf @@ -0,0 +1,25 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req + +[req_distinguished_name] +countryName = Country Name (2 letter code) +countryName_default = US +stateOrProvinceName = State or Province Name (full name) +stateOrProvinceName_default = MN +localityName = Locality Name (eg, city) +localityName_default = Minneapolis +organizationalUnitName = Organizational Unit Name (eg, section) +organizationalUnitName_default = Domain Control Validated +commonName = Internet Widgits Ltd +commonName_max = 64 + +[ v3_req ] +# Extensions to add to a certificate request +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = DEV_DOMAIN +DNS.2 = *.DEV_DOMAIN diff --git a/.docker/selfhost/.gitignore b/.docker/selfhost/.gitignore new file mode 100644 index 00000000..4c49bd78 --- /dev/null +++ b/.docker/selfhost/.gitignore @@ -0,0 +1 @@ +.env diff --git a/.docker/selfhost/compose.yml b/.docker/selfhost/compose.yml new file mode 100644 index 00000000..d0748baf --- /dev/null +++ b/.docker/selfhost/compose.yml @@ -0,0 +1,66 @@ +name: affine +services: + affine: + image: ghcr.io/toeverything/affine:stable + container_name: affine_server + ports: + - '3010:3010' + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + affine_migration: + condition: service_completed_successfully + volumes: + - ./data/storage:/root/.affine/storage + - ./config:/root/.affine/config + environment: + - REDIS_SERVER_HOST=redis + - DATABASE_URL=postgresql://affine@postgres:5432/affine + - DEPLOYMENT_TYPE=selfhosted + restart: unless-stopped + + affine_migration: + image: ghcr.io/toeverything/affine:stable + container_name: affine_migration_job + volumes: + - ./data/storage:/root/.affine/storage + - ./config:/root/.affine/config + command: ['sh', '-c', 'node ./scripts/self-host-predeploy.js'] + environment: + - REDIS_SERVER_HOST=redis + - DATABASE_URL=postgresql://affine@postgres:5432/affine + - DEPLOYMENT_TYPE=selfhosted + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + redis: + image: redis + container_name: affine_redis + healthcheck: + test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + postgres: + image: pgvector/pgvector:pg16 + container_name: affine_postgres + volumes: + - ./data/postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: affine + POSTGRES_DB: affine + POSTGRES_INITDB_ARGS: '--data-checksums' + POSTGRES_HOST_AUTH_METHOD: trust + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'affine', '-d', 'affine'] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped diff --git a/.docker/selfhost/config.example.json b/.docker/selfhost/config.example.json new file mode 100644 index 00000000..38b447ba --- /dev/null +++ b/.docker/selfhost/config.example.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://github.com/toeverything/affine/releases/latest/download/config.schema.json", + "server": { + "name": "AFFiNE Self Hosted Server" + }, + "indexer": { + "enabled": true, + "provider.type": "embedded" + } +} diff --git a/.docker/selfhost/config.json.example b/.docker/selfhost/config.json.example new file mode 100644 index 00000000..eeec2804 --- /dev/null +++ b/.docker/selfhost/config.json.example @@ -0,0 +1,18 @@ +{ + "$schema": "https://github.com/toeverything/affine/releases/latest/download/config.schema.json", + "server": { + "name": "AFFiNE Self-hosted", + "externalUrl": "http://localhost:3010" + }, + "copilot": { + "enabled": true, + "byok": { + "enabled": true, + "allowCustomEndpoint": true + } + }, + "indexer": { + "enabled": true, + "provider.type": "embedded" + } +} diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json new file mode 100644 index 00000000..1350345d --- /dev/null +++ b/.docker/selfhost/schema.json @@ -0,0 +1,1534 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AFFiNE Application Configuration", + "type": "object", + "properties": { + "metrics": { + "type": "object", + "description": "Configuration for metrics module", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable metric and tracing collection\n@default false", + "default": false + } + } + }, + "crypto": { + "type": "object", + "description": "Configuration for crypto module", + "properties": { + "privateKey": { + "type": "string", + "description": "The private key for used by the crypto module to create signed tokens or encrypt data.\n@default \"\"\n@environment `AFFINE_PRIVATE_KEY`", + "default": "" + } + } + }, + "throttle": { + "type": "object", + "description": "Configuration for throttle module", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the throttler is enabled.\n@default true", + "default": true + }, + "throttlers.default": { + "type": "object", + "description": "The config for the default throttler.\n@default {\"ttl\":60000,\"limit\":120}", + "default": { + "ttl": 60000, + "limit": 120 + } + }, + "throttlers.strict": { + "type": "object", + "description": "The config for the strict throttler.\n@default {\"ttl\":60000,\"limit\":20}", + "default": { + "ttl": 60000, + "limit": 20 + } + } + } + }, + "auth": { + "type": "object", + "description": "Configuration for auth module", + "properties": { + "allowSignup": { + "type": "boolean", + "description": "Whether allow new registrations.\n@default true", + "default": true + }, + "allowSignupForOauth": { + "type": "boolean", + "description": "Whether allow new registrations via configured oauth.\n@default true", + "default": true + }, + "requireEmailDomainVerification": { + "type": "boolean", + "description": "Whether require email domain record verification before accessing restricted resources.\n@default false", + "default": false + }, + "requireEmailVerification": { + "type": "boolean", + "description": "Whether require email verification before accessing restricted resources(not implemented).\n@default true", + "default": true + }, + "newAccountActionDelay": { + "type": "number", + "description": "Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable.\n@default 86400", + "default": 86400 + }, + "trustedCloudflareHeaders": { + "type": "boolean", + "description": "Whether request abuse source facts should trust Cloudflare headers from the origin edge.\n@default false", + "default": false + }, + "signInRateLimit": { + "type": "object", + "description": "Limits for sign-in attempts shared through Redis by source IP and email. ttl is measured in milliseconds.\n@default {\"ttl\":60000,\"ipLimit\":20,\"emailLimit\":5}", + "default": { + "ttl": 60000, + "ipLimit": 20, + "emailLimit": 5 + } + }, + "passwordRequirements": { + "type": "object", + "description": "The password strength requirements when set new password.\n@default {\"min\":8,\"max\":32}", + "properties": { + "min": { + "type": "number" + }, + "max": { + "type": "number" + } + }, + "default": { + "min": 8, + "max": 32 + } + }, + "session.ttl": { + "type": "number", + "description": "Application auth expiration time in seconds.\n@default 1296000", + "default": 1296000 + }, + "session.ttr": { + "type": "number", + "description": "Application auth time to refresh in seconds.\n@default 604800", + "default": 604800 + }, + "token.accessTokenTtl": { + "type": "number", + "description": "Access JWT expiration time in seconds.\n@default 900", + "default": 900 + }, + "token.refreshIdleTtl": { + "type": "number", + "description": "Auth refresh session inactivity expiration in seconds.\n@default 2592000", + "default": 2592000 + }, + "token.refreshAbsoluteTtl": { + "type": "number", + "description": "Auth refresh session absolute expiration in seconds.\n@default 15552000", + "default": 15552000 + }, + "token.refreshGracePeriod": { + "type": "number", + "description": "One-use refresh rotation concurrency grace period in seconds.\n@default 30", + "default": 30 + }, + "token.refreshRetention": { + "type": "number", + "description": "Retention for expired auth refresh generations in seconds.\n@default 2592000", + "default": 2592000 + } + } + }, + "mailer": { + "type": "object", + "description": "Configuration for mailer module", + "properties": { + "SMTP.name": { + "type": "string", + "description": "Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"\n@environment `MAILER_SERVERNAME`", + "default": "" + }, + "SMTP.host": { + "type": "string", + "description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"\n@environment `MAILER_HOST`", + "default": "" + }, + "SMTP.port": { + "type": "number", + "description": "Port of the email server (they commonly are 25, 465 or 587)\n@default 465\n@environment `MAILER_PORT`", + "default": 465 + }, + "SMTP.username": { + "type": "string", + "description": "Username used to authenticate the email server\n@default \"\"\n@environment `MAILER_USER`", + "default": "" + }, + "SMTP.password": { + "type": "string", + "description": "Password used to authenticate the email server\n@default \"\"\n@environment `MAILER_PASSWORD`", + "default": "" + }, + "SMTP.sender": { + "type": "string", + "description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"AFFiNE Self Hosted \"\n@environment `MAILER_SENDER`", + "default": "AFFiNE Self Hosted " + }, + "SMTP.ignoreTLS": { + "type": "boolean", + "description": "Whether ignore email server's TLS certificate verification. Enable it for self-signed certificates.\n@default false\n@environment `MAILER_IGNORE_TLS`", + "default": false + }, + "fallbackDomains": { + "type": "array", + "description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []", + "default": [] + }, + "deliveryWorker.batchSize": { + "type": "number", + "description": "Number of mail delivery rows claimed by each worker tick.\n@default 50", + "default": 50 + }, + "deliveryWorker.leaseMs": { + "type": "number", + "description": "Mail delivery worker lease duration in milliseconds.\n@default 120000", + "default": 120000 + }, + "deliveryWorker.retentionDays": { + "type": "number", + "description": "Days to retain anonymized terminal mail delivery ledger rows.\n@default 30", + "default": 30 + }, + "fallbackSMTP.name": { + "type": "string", + "description": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"", + "default": "" + }, + "fallbackSMTP.host": { + "type": "string", + "description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"", + "default": "" + }, + "fallbackSMTP.port": { + "type": "number", + "description": "Port of the email server (they commonly are 25, 465 or 587)\n@default 465", + "default": 465 + }, + "fallbackSMTP.username": { + "type": "string", + "description": "Username used to authenticate the email server\n@default \"\"", + "default": "" + }, + "fallbackSMTP.password": { + "type": "string", + "description": "Password used to authenticate the email server\n@default \"\"", + "default": "" + }, + "fallbackSMTP.sender": { + "type": "string", + "description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"\"", + "default": "" + }, + "fallbackSMTP.ignoreTLS": { + "type": "boolean", + "description": "Whether ignore email server's TLS certificate verification. Enable it for self-signed certificates.\n@default false", + "default": false + } + } + }, + "doc": { + "type": "object", + "description": "Configuration for doc module", + "properties": { + "experimental.yocto": { + "type": "boolean", + "description": "Use `y-octo` to merge updates at the same time when merging using Yjs.\n@default false", + "default": false + }, + "history.interval": { + "type": "number", + "description": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.\n@default 600000", + "default": 600000 + } + } + }, + "storages": { + "type": "object", + "description": "Configuration for storages module", + "properties": { + "avatar.publicPath": { + "type": "string", + "description": "The public accessible path prefix for user avatars.\n@default \"/api/avatars/\"", + "default": "/api/avatars/" + }, + "avatar.storage": { + "type": "object", + "description": "The config of storage for user avatars.\n@default {\"provider\":\"fs\",\"bucket\":\"avatars\",\"config\":{\"path\":\"~/.affine/storage\"}}", + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "fs" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "aws-s3" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "description": "The config for the S3 compatible storage provider.", + "properties": { + "endpoint": { + "type": "string", + "description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region." + }, + "region": { + "type": "string", + "description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2." + }, + "forcePathStyle": { + "type": "boolean", + "description": "Whether to use path-style bucket addressing." + }, + "requestTimeoutMs": { + "type": "number", + "description": "Request timeout in milliseconds." + }, + "minPartSize": { + "type": "number", + "description": "Minimum multipart part size in bytes." + }, + "presign": { + "type": "object", + "description": "Presigned URL behavior configuration.", + "properties": { + "expiresInSeconds": { + "type": "number", + "description": "Expiration time in seconds for presigned URLs." + }, + "signContentTypeForPut": { + "type": "boolean", + "description": "Whether to sign Content-Type for presigned PUT." + } + } + }, + "credentials": { + "type": "object", + "description": "The credentials for the s3 compatible storage provider.", + "properties": { + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "sessionToken": { + "type": "string" + } + } + }, + "usePresignedURL": { + "type": "object", + "description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether browser upload URLs are enabled." + }, + "urlPrefix": { + "type": "string", + "description": "Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured." + }, + "signKey": { + "type": "string", + "description": "Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix." + } + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "cloudflare-r2" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "description": "The config for the S3 compatible storage provider.", + "properties": { + "region": { + "type": "string", + "description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2." + }, + "forcePathStyle": { + "type": "boolean", + "description": "Whether to use path-style bucket addressing." + }, + "requestTimeoutMs": { + "type": "number", + "description": "Request timeout in milliseconds." + }, + "minPartSize": { + "type": "number", + "description": "Minimum multipart part size in bytes." + }, + "presign": { + "type": "object", + "description": "Presigned URL behavior configuration.", + "properties": { + "expiresInSeconds": { + "type": "number", + "description": "Expiration time in seconds for presigned URLs." + }, + "signContentTypeForPut": { + "type": "boolean", + "description": "Whether to sign Content-Type for presigned PUT." + } + } + }, + "credentials": { + "type": "object", + "description": "The credentials for the s3 compatible storage provider.", + "properties": { + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "sessionToken": { + "type": "string" + } + } + }, + "usePresignedURL": { + "type": "object", + "description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether browser upload URLs are enabled." + }, + "urlPrefix": { + "type": "string", + "description": "Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured." + }, + "signKey": { + "type": "string", + "description": "Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix." + } + } + }, + "accountId": { + "type": "string", + "description": "The account id for the cloudflare r2 storage provider." + }, + "jurisdiction": { + "type": "string", + "enum": [ + "default", + "eu" + ], + "description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets." + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "assetpack" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "provider", + "bucket", + "config" + ] + } + ], + "default": { + "provider": "fs", + "bucket": "avatars", + "config": { + "path": "~/.affine/storage" + } + } + }, + "blob.storage": { + "type": "object", + "description": "The config of storage for all uploaded blobs(images, videos, etc.).\n@default {\"provider\":\"fs\",\"bucket\":\"blobs\",\"config\":{\"path\":\"~/.affine/storage\"}}", + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "fs" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "aws-s3" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "description": "The config for the S3 compatible storage provider.", + "properties": { + "endpoint": { + "type": "string", + "description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region." + }, + "region": { + "type": "string", + "description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2." + }, + "forcePathStyle": { + "type": "boolean", + "description": "Whether to use path-style bucket addressing." + }, + "requestTimeoutMs": { + "type": "number", + "description": "Request timeout in milliseconds." + }, + "minPartSize": { + "type": "number", + "description": "Minimum multipart part size in bytes." + }, + "presign": { + "type": "object", + "description": "Presigned URL behavior configuration.", + "properties": { + "expiresInSeconds": { + "type": "number", + "description": "Expiration time in seconds for presigned URLs." + }, + "signContentTypeForPut": { + "type": "boolean", + "description": "Whether to sign Content-Type for presigned PUT." + } + } + }, + "credentials": { + "type": "object", + "description": "The credentials for the s3 compatible storage provider.", + "properties": { + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "sessionToken": { + "type": "string" + } + } + }, + "usePresignedURL": { + "type": "object", + "description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether browser upload URLs are enabled." + }, + "urlPrefix": { + "type": "string", + "description": "Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured." + }, + "signKey": { + "type": "string", + "description": "Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix." + } + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "cloudflare-r2" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "description": "The config for the S3 compatible storage provider.", + "properties": { + "region": { + "type": "string", + "description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2." + }, + "forcePathStyle": { + "type": "boolean", + "description": "Whether to use path-style bucket addressing." + }, + "requestTimeoutMs": { + "type": "number", + "description": "Request timeout in milliseconds." + }, + "minPartSize": { + "type": "number", + "description": "Minimum multipart part size in bytes." + }, + "presign": { + "type": "object", + "description": "Presigned URL behavior configuration.", + "properties": { + "expiresInSeconds": { + "type": "number", + "description": "Expiration time in seconds for presigned URLs." + }, + "signContentTypeForPut": { + "type": "boolean", + "description": "Whether to sign Content-Type for presigned PUT." + } + } + }, + "credentials": { + "type": "object", + "description": "The credentials for the s3 compatible storage provider.", + "properties": { + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "sessionToken": { + "type": "string" + } + } + }, + "usePresignedURL": { + "type": "object", + "description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether browser upload URLs are enabled." + }, + "urlPrefix": { + "type": "string", + "description": "Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured." + }, + "signKey": { + "type": "string", + "description": "Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix." + } + } + }, + "accountId": { + "type": "string", + "description": "The account id for the cloudflare r2 storage provider." + }, + "jurisdiction": { + "type": "string", + "enum": [ + "default", + "eu" + ], + "description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets." + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "assetpack" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "provider", + "bucket", + "config" + ] + } + ], + "default": { + "provider": "fs", + "bucket": "blobs", + "config": { + "path": "~/.affine/storage" + } + } + } + } + }, + "websocket": { + "type": "object", + "description": "Configuration for websocket module", + "properties": { + "transports": { + "type": "array", + "description": "The enabled transports for accepting websocket traffics.\n@default [\"websocket\",\"polling\"]\n@link https://docs.nestjs.com/websockets/gateways#transports", + "items": { + "type": "string", + "enum": [ + "websocket", + "polling" + ] + }, + "default": [ + "websocket", + "polling" + ] + }, + "maxHttpBufferSize": { + "type": "number", + "description": "How many bytes or characters a message can be, before closing the session (to avoid DoS).\n@default 100000000", + "default": 100000000 + } + } + }, + "server": { + "type": "object", + "description": "Configuration for server module", + "properties": { + "name": { + "type": "string", + "description": "A recognizable name for the server. Will be shown when connected with AFFiNE Desktop.\n@default undefined" + }, + "externalUrl": { + "type": "string", + "description": "Base url of AFFiNE server, used for generating external urls.\nDefault to be `[server.protocol]://[server.host][:server.port]` if not specified.\n \n@default \"\"\n@environment `AFFINE_SERVER_EXTERNAL_URL`", + "default": "" + }, + "https": { + "type": "boolean", + "description": "Whether the server is hosted on a ssl enabled domain (https://).\n@default false\n@environment `AFFINE_SERVER_HTTPS`", + "default": false + }, + "host": { + "type": "string", + "description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`", + "default": "localhost" + }, + "hosts": { + "type": "array", + "description": "Multiple hosts the server will accept requests from.\n@default []", + "default": [] + }, + "listenAddr": { + "type": "string", + "description": "The address to listen on (e.g., 0.0.0.0 for IPv4, :: for IPv6).\n@default \"0.0.0.0\"\n@environment `LISTEN_ADDR`", + "default": "0.0.0.0" + }, + "port": { + "type": "number", + "description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`", + "default": 3010 + }, + "path": { + "type": "string", + "description": "Subpath where the server get deployed if there is one.(e.g. /affine)\n@default \"\"\n@environment `AFFINE_SERVER_SUB_PATH`", + "default": "" + } + } + }, + "flags": { + "type": "object", + "description": "Configuration for flags module", + "properties": { + "allowGuestDemoWorkspace": { + "type": "boolean", + "description": "Whether allow guest users to create demo workspaces.\n@default true", + "default": true + } + } + }, + "telemetry": { + "type": "object", + "description": "Configuration for telemetry module", + "properties": { + "allowedOrigin": { + "type": "array", + "description": "Allowed origins for telemetry collection.\n@default [\"localhost\",\"127.0.0.1\"]", + "default": [ + "localhost", + "127.0.0.1" + ] + }, + "ga4.measurementId": { + "type": "string", + "description": "GA4 Measurement ID for Measurement Protocol.\n@default \"\"\n@environment `GA4_MEASUREMENT_ID`", + "default": "" + }, + "ga4.apiSecret": { + "type": "string", + "description": "GA4 API secret for Measurement Protocol.\n@default \"\"\n@environment `GA4_API_SECRET`", + "default": "" + }, + "dedupe.ttlHours": { + "type": "number", + "description": "Telemetry dedupe TTL in hours.\n@default 24", + "default": 24 + }, + "dedupe.maxEntries": { + "type": "number", + "description": "Telemetry dedupe max entries.\n@default 100000", + "default": 100000 + }, + "batch.maxEvents": { + "type": "number", + "description": "Max events per telemetry batch.\n@default 25", + "default": 25 + } + } + }, + "client": { + "type": "object", + "description": "Configuration for client module", + "properties": { + "versionControl.enabled": { + "type": "boolean", + "description": "Whether check version of client before accessing the server.\n@default false", + "default": false + }, + "versionControl.requiredVersion": { + "type": "string", + "description": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.\n@default \">=0.25.0\"", + "default": ">=0.25.0" + } + } + }, + "calendar": { + "type": "object", + "description": "Configuration for calendar module", + "properties": { + "google": { + "type": "object", + "description": "Google Calendar integration config\n@default {\"enabled\":false,\"allowNewAccounts\":true,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push", + "properties": { + "enabled": { + "type": "boolean" + }, + "allowNewAccounts": { + "type": "boolean" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "externalWebhookUrl": { + "type": "string" + }, + "webhookVerificationToken": { + "type": "string" + }, + "requestTimeoutMs": { + "type": "number" + } + }, + "default": { + "enabled": false, + "allowNewAccounts": true, + "clientId": "", + "clientSecret": "", + "externalWebhookUrl": "", + "webhookVerificationToken": "", + "requestTimeoutMs": 10000 + } + }, + "caldav": { + "type": "object", + "description": "CalDAV integration config\n@default {\"enabled\":false,\"allowCustomProvider\":false,\"providers\":[],\"allowInsecureHttp\":false,\"allowedHosts\":[],\"blockPrivateNetwork\":true,\"requestTimeoutMs\":10000,\"maxRedirects\":5}", + "properties": { + "enabled": { + "type": "boolean" + }, + "allowCustomProvider": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "serverUrl": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "requiresAppPassword": { + "type": "boolean" + }, + "docsUrl": { + "type": "string" + } + } + } + }, + "allowInsecureHttp": { + "type": "boolean" + }, + "allowedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "blockPrivateNetwork": { + "type": "boolean" + }, + "requestTimeoutMs": { + "type": "number" + }, + "maxRedirects": { + "type": "number" + } + }, + "default": { + "enabled": false, + "allowCustomProvider": false, + "providers": [], + "allowInsecureHttp": false, + "allowedHosts": [], + "blockPrivateNetwork": true, + "requestTimeoutMs": 10000, + "maxRedirects": 5 + } + } + } + }, + "captcha": { + "type": "object", + "description": "Configuration for captcha module", + "properties": { + "enabled": { + "type": "boolean", + "description": "Check captcha challenge when user authenticating the app.\n@default false", + "default": false + }, + "config": { + "type": "object", + "description": "The config for the captcha plugin.\n@default {\"turnstile\":{\"secret\":\"\",\"siteKey\":\"\",\"action\":\"auth-sign-in\"},\"challenge\":{\"bits\":20}}", + "default": { + "turnstile": { + "secret": "", + "siteKey": "", + "action": "auth-sign-in" + }, + "challenge": { + "bits": 20 + } + } + } + } + }, + "copilot": { + "type": "object", + "description": "Configuration for copilot module", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable AI features. Workspace owners configure provider keys in Workspace Settings → Integrations → AI BYOK.\n@default false", + "default": false + }, + "unsplash": { + "type": "object", + "description": "The config for the unsplash key.\n@default {\"key\":\"\"}", + "default": { + "key": "" + } + }, + "exa": { + "type": "object", + "description": "The config for the exa web search key.\n@default {\"key\":\"\"}", + "default": { + "key": "" + } + }, + "storage": { + "type": "object", + "description": "The config for the storage provider.\n@default {\"provider\":\"fs\",\"bucket\":\"copilot\",\"config\":{\"path\":\"~/.affine/storage\"}}", + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "fs" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "aws-s3" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "description": "The config for the S3 compatible storage provider.", + "properties": { + "endpoint": { + "type": "string", + "description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region." + }, + "region": { + "type": "string", + "description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2." + }, + "forcePathStyle": { + "type": "boolean", + "description": "Whether to use path-style bucket addressing." + }, + "requestTimeoutMs": { + "type": "number", + "description": "Request timeout in milliseconds." + }, + "minPartSize": { + "type": "number", + "description": "Minimum multipart part size in bytes." + }, + "presign": { + "type": "object", + "description": "Presigned URL behavior configuration.", + "properties": { + "expiresInSeconds": { + "type": "number", + "description": "Expiration time in seconds for presigned URLs." + }, + "signContentTypeForPut": { + "type": "boolean", + "description": "Whether to sign Content-Type for presigned PUT." + } + } + }, + "credentials": { + "type": "object", + "description": "The credentials for the s3 compatible storage provider.", + "properties": { + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "sessionToken": { + "type": "string" + } + } + }, + "usePresignedURL": { + "type": "object", + "description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether browser upload URLs are enabled." + }, + "urlPrefix": { + "type": "string", + "description": "Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured." + }, + "signKey": { + "type": "string", + "description": "Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix." + } + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "cloudflare-r2" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "description": "The config for the S3 compatible storage provider.", + "properties": { + "region": { + "type": "string", + "description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2." + }, + "forcePathStyle": { + "type": "boolean", + "description": "Whether to use path-style bucket addressing." + }, + "requestTimeoutMs": { + "type": "number", + "description": "Request timeout in milliseconds." + }, + "minPartSize": { + "type": "number", + "description": "Minimum multipart part size in bytes." + }, + "presign": { + "type": "object", + "description": "Presigned URL behavior configuration.", + "properties": { + "expiresInSeconds": { + "type": "number", + "description": "Expiration time in seconds for presigned URLs." + }, + "signContentTypeForPut": { + "type": "boolean", + "description": "Whether to sign Content-Type for presigned PUT." + } + } + }, + "credentials": { + "type": "object", + "description": "The credentials for the s3 compatible storage provider.", + "properties": { + "accessKeyId": { + "type": "string" + }, + "secretAccessKey": { + "type": "string" + }, + "sessionToken": { + "type": "string" + } + } + }, + "usePresignedURL": { + "type": "object", + "description": "Controls browser upload URLs. Disabled or unavailable modes fall back to server uploads.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether browser upload URLs are enabled." + }, + "urlPrefix": { + "type": "string", + "description": "Optional base URL for proxied uploads and signed custom GET URLs. Provider presigned uploads require an origin-only value when signKey is not configured." + }, + "signKey": { + "type": "string", + "description": "Optional HMAC key for signed upload and custom GET URLs. Custom GET URLs require both signKey and urlPrefix." + } + } + }, + "accountId": { + "type": "string", + "description": "The account id for the cloudflare r2 storage provider." + }, + "jurisdiction": { + "type": "string", + "enum": [ + "default", + "eu" + ], + "description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets." + } + } + } + } + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "assetpack" + ] + }, + "bucket": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "provider", + "bucket", + "config" + ] + } + ], + "default": { + "provider": "fs", + "bucket": "copilot", + "config": { + "path": "~/.affine/storage" + } + } + }, + "byok.enabled": { + "type": "boolean", + "description": "Allow workspace owners and admins to configure AI provider keys through AI BYOK.\n@default true", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "boolean", + "default": true + }, + "byok.allowedProviders": { + "type": "array", + "description": "AI providers that workspace owners and admins may add through AI BYOK.\n@default [\"openai\",\"anthropic\",\"gemini\",\"fal\"]", + "$schema": "http://json-schema.org/draft-07/schema#", + "items": { + "enum": [ + "openai", + "anthropic", + "gemini", + "fal" + ], + "type": "string" + }, + "title": "Array_of_string", + "default": [ + "openai", + "anthropic", + "gemini", + "fal" + ] + }, + "byok.allowCustomEndpoint": { + "type": "boolean", + "description": "Allow AI BYOK keys to use a custom provider endpoint.\n@default false", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "boolean", + "default": false + }, + "byok.allowPrivateEndpoint": { + "type": "boolean", + "description": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network.\n@default false", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "boolean", + "default": false + } + } + }, + "indexer": { + "type": "object", + "description": "Configuration for indexer module", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable indexer plugin\n@default false", + "default": false + }, + "provider.type": { + "type": "string", + "description": "Indexer search provider. Self-hosted uses the embedded provider by default; remote providers require an endpoint.\n@default \"embedded\"", + "default": "embedded" + }, + "provider.endpoint": { + "type": "string", + "description": "Remote indexer endpoint. Not used by the embedded provider.\n@default \"\"", + "default": "" + }, + "provider.apiKey": { + "type": "string", + "description": "Indexer search service api key. Optional for remote providers\n@default \"\"\n@link https://www.elastic.co/guide/server/current/api-key.html", + "default": "" + }, + "provider.username": { + "type": "string", + "description": "Indexer search service auth username, if not set, basic auth will be disabled. Optional for remote providers\n@default \"\"\n@link https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html", + "default": "" + }, + "provider.password": { + "type": "string", + "description": "Indexer search service auth password, if not set, basic auth will be disabled. Optional for remote providers\n@default \"\"", + "default": "" + } + } + }, + "oauth": { + "type": "object", + "description": "Configuration for oauth module", + "properties": { + "providers.google": { + "type": "object", + "description": "Google OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\"}\n@link https://developers.google.com/identity/protocols/oauth2/web-server", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "args": { + "type": "object" + } + }, + "default": { + "clientId": "", + "clientSecret": "" + } + }, + "providers.github": { + "type": "object", + "description": "GitHub OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\"}\n@link https://docs.github.com/en/apps/oauth-apps", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "args": { + "type": "object" + } + }, + "default": { + "clientId": "", + "clientSecret": "" + } + }, + "providers.oidc": { + "type": "object", + "description": "OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"allowPrivateNetwork\":false,\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "args": { + "type": "object" + }, + "issuer": { + "type": "string", + "description": "OIDC issuer URL" + }, + "allowPrivateNetwork": { + "type": "boolean", + "description": "Allow the OIDC issuer origin to resolve to private network addresses" + } + }, + "default": { + "clientId": "", + "clientSecret": "", + "issuer": "", + "allowPrivateNetwork": false, + "args": {} + } + }, + "providers.apple": { + "type": "object", + "description": "Apple OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\"}\n@link https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/implementing_sign_in_with_apple_in_your_app", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "args": { + "type": "object" + } + }, + "default": { + "clientId": "", + "clientSecret": "" + } + } + } + }, + "payment": { + "type": "object", + "description": "Configuration for payment module", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether enable payment plugin\n@default false", + "default": false + }, + "showLifetimePrice": { + "type": "boolean", + "description": "Whether enable lifetime price and allow user to pay for it.\n@default true", + "default": true + }, + "stripe": { + "type": "object", + "description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\",\"accountId\":\"\",\"environment\":\"test\"}\n@link https://docs.stripe.com/api", + "default": { + "apiKey": "", + "webhookKey": "", + "accountId": "", + "environment": "test" + } + }, + "revenuecat": { + "type": "object", + "description": "RevenueCat integration configs\n@default {\"enabled\":false,\"apiKey\":\"\",\"projectId\":\"\",\"webhookAuth\":\"\",\"environment\":\"production\",\"productMap\":{}}\n@link https://www.revenuecat.com/docs/", + "default": { + "enabled": false, + "apiKey": "", + "projectId": "", + "webhookAuth": "", + "environment": "production", + "productMap": {} + } + } + } + }, + "worker": { + "type": "object", + "description": "Configuration for worker module", + "properties": { + "allowedOrigin": { + "type": "array", + "description": "Allowed origin\n@default [\"localhost\",\"127.0.0.1\"]", + "default": [ + "localhost", + "127.0.0.1" + ] + } + } + } + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5cfbb2f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +.github/**/*.md +.gitignore + +# Local dependency/build artifacts +/node_modules +/target + +# Yarn v4 artifacts (not needed for image packaging) +/.yarn/cache +/.yarn/unplugged +/.yarn/install-state.gz +/.pnp.* + +# Test artifacts +/test-results +/playwright-report +/coverage +/.coverage + +# OS noise +.DS_Store + +# Sourcemaps (keep server sourcemap for backend stacktraces) +**/*.map +!packages/backend/server/dist/main.js.map diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..94d1d40e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*.rs] +max_line_length = 120 +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9c6024a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +* text=auto eol=lf + +# These files are binary and should be left untouched +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mov binary +*.mp4 binary +*.mp3 binary +*.ttf binary +*.otf binary +*.eot binary +*.woff binary +*.woff2 binary +*.pdf binary +*.tar.gz binary +*.zip binary +*.7z binary diff --git a/.github/CLA.md b/.github/CLA.md new file mode 100644 index 00000000..c319e6ce --- /dev/null +++ b/.github/CLA.md @@ -0,0 +1,40 @@ + + +# AFFiNE Contributor License Agreement + +To clarify the intellectual property license granted with contributions from any person or entity, AFFiNE must have on file a signed Contributor License Agreement ("CLA") from each contributor, indicating agreement with the license terms below. This agreement is for your protection as a contributor as well as the protection of the AFFiNE and its users; it does not change your rights to use your own contributions for any other purpose. + +You accept and agree to the following terms and conditions for your past, present and future contributions submitted to AFFiNE. You should sign this agreement before submitting your first contribution. Except for the license granted herein to AFFiNE and recipients of software distributed by AFFiNE, You reserve all right, title, and interest in and to Your Contributions. + +1. Parties. + + (a) "AFFiNE" refers to the project's operator, TOEVERYTHING PTE. LTD registered in Republic of Singapore. + + (b) "You" (or "Your") means the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with AFFiNE. + +2. Definitions. "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to AFFiNE for inclusion in, or documentation of, any of the products owned or managed by AFFiNE (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to AFFiNE or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, AFFiNE for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution". + +3. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to AFFiNE and to recipients of software distributed by AFFiNE a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to use, copy, reproduce, prepare derivative works of, distribute, sublicense, and publicly perform and display the Contribution and such derivative works on any licensing terms, including without limitation open source licenses and binary, proprietary, or commercial licenses. + +4. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to AFFiNE and to recipients of software distributed by AFFiNE a perpetual, irrevocable, non-exclusive, worldwide, no-charge, royalty-free patent license to make, have made, use, sell, offer to sell, import, and otherwise transfer your Contribution in whole or in part, alone or in combination with or included in any product, work or materials arising out of the project to which your contribution was submitted, and to sublicense these same rights to third parties through multiple levels of sublicensees or other licensing arrangements. + +5. Except as set out above, You keep all right, title, and interest in your contribution. The rights that you grant to AFFiNE under these terms are effective on the date you first submitted a contribution to AFFiNE, even if your submission took place before the date you sign these terms. + +6. You promise that: + +- Each of Your Contributions is Your original work and that you are legally entitled to grant the above license. +- Each of Your Contributions does not to the best of your knowledge violate any third party's copyrights, trademarks, patents, or other intellectual property rights; +- Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. +- If You are an individual and if your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to AFFiNE, or that your employer has executed a separate Corporate CLA with AFFiNE. + +7. You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +8. You agree to notify AFFiNE of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. + +9. This Agreement will be governed by the laws of Republic of Singapore without reference to conflict of laws principles. + +## How To Sign + +Visit ****, sign in with your GitHub account, and agree. + +Once signed, the `license/cla` check on your pull requests passes automatically. If you signed after opening a PR, click the **recheck** link in the CLA bot's comment on the PR (or push a new commit) to refresh the check. Note that every committer on a PR must sign, and each commit's author email must be linked to a GitHub account. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..742da552 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +/blocksuite/ @toeverything/blocksuite-core +/packages/frontend/core/src/blocksuite @toeverything/blocksuite-core diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..722bc989 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [toeverything] diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 00000000..7a1de6d9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,84 @@ +name: Bug Report +description: File a bug report +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + Check out this [link](https://github.com/toeverything/AFFiNE/blob/canary/docs/issue-triaging.md) + to learn how we manage issues and when your issue will be processed. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + validations: + required: true + - type: dropdown + id: distribution + attributes: + label: Distribution version + description: What distribution of AFFiNE are you using? + options: + - macOS x64 (Intel) + - macOS ARM 64 (Apple Silicon) + - Windows x64 + - Linux + - Web (https://app.affine.pro) + - Beta Web (https://insider.affine.pro) + - Canary Web (https://affine.fail) + validations: + required: true + - type: input + id: version + attributes: + label: App Version + description: What version of AFFiNE are you using? + placeholder: (You can find AFFiNE version in [About AFFiNE] setting panel) + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on if you're using web version? + multiple: true + options: + - Chrome + - Microsoft Edge + - Firefox + - Safari + - Other + - type: checkboxes + id: selfhost + attributes: + label: Are you self-hosting? + description: > + If you are self-hosting, please check the box and provide information about your setup. + options: + - label: 'Yes' + - type: input + id: selfhost-version + attributes: + label: Self-hosting Version + description: What version of AFFiNE are you selfhosting? + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + Tip: You can attach images here + - type: checkboxes + attributes: + label: Is your content generated by AI? + description: > + (Required) Please confirm that the content you submit was not generated by AI or only minimally edited by AI. + If an administrator believes the post contains a large amount of AI-generated content, they may directly close the question. + options: + - label: I confirm that the content I submitted was **not** generated by AI / **merely contained minimal** AI edits. diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 00000000..2a429762 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,44 @@ +name: Feature Request +description: Suggest a feature or improvement +title: '[Feature Request]: ' +labels: ['feat', 'story'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this feature suggestion! + - type: textarea + id: description + attributes: + label: Description + description: What would you like to see added to AFFiNE? + placeholder: Please explain in details the feature and improvements you'd like to see. + validations: + required: true + - type: textarea + attributes: + label: Use case + description: | + How might this feature be used and who might use it. + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the idea you have! + Tip: You can attach images here + - type: checkboxes + attributes: + label: Are you willing to submit a PR? + description: > + (Optional) We encourage you to submit a [Pull Request](https://github.com/toeverything/affine/pulls) (PR) to help improve AFFiNE for everyone, especially if you have a good understanding of how to implement a fix or feature. + See the AFFiNE [Contributing Guide](https://github.com/toeverything/affine/blob/canary/CONTRIBUTING.md) to get started. + options: + - label: Yes I'd like to help by submitting a PR! + - type: checkboxes + attributes: + label: Is your content generated by AI? + description: > + (Required) Please confirm that the content you submit was not generated by AI or only minimally edited by AI. + If an administrator believes the post contains a large amount of AI-generated content, they may directly close the question. + options: + - label: I confirm that the content I submitted was **not** generated by AI / **merely contained minimal** AI edits. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..bd547bf2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Something else? + url: https://github.com/toeverything/AFFiNE/discussions + about: Feel free to ask and answer questions over in GitHub Discussions + - name: AFFiNE Community Support (Discord) + url: https://affine.pro/redirect/discord + about: AFFiNE Community - a place to ask, learn and engage with others diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..0f2c4627 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ + + +## Description + + + +## Checklist + +- [ ] I have signed the [AFFiNE Contributor License Agreement](https://cla-assistant.io/toeverything/AFFiNE) — required before merge; the `license/cla` check must be green ([how it works](https://github.com/toeverything/AFFiNE/blob/canary/docs/BUILDING.md#sign-the-cla-first)) +- [ ] The PR targets the `canary` branch and its title follows [Conventional Commits](https://www.conventionalcommits.org/) +- [ ] Tests are added or updated where it makes sense +- [ ] `yarn lint` and `yarn typecheck` pass locally +- [ ] If the PR code includes AI-generated edits, I have carefully reviewed it to ensure that the scope of the PR is consistent with what is claimed in the title and description, and that there are no redundant or invalid implementations. +- [ ] I agree that maintainers may close the PR without discussion if they consider the code quality to be too low. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..f27c4173 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,20 @@ +self-hosted-runner: + # Labels of self-hosted runner in array of strings. + labels: + - win-signer + +# Configuration variables in array of strings defined in your repository or +# organization. `null` means disabling configuration variables check. +# Empty array means no configuration variable is allowed. +config-variables: null + +# Configuration for file paths. The keys are glob patterns to match to file +# paths relative to the repository root. The values are the configurations for +# the file paths. Note that the path separator is always '/'. +# The following configurations are available. +# +# "ignore" is an array of regular expression patterns. Matched error messages +# are ignored. This is similar to the "-ignore" command line option. +paths: +# .github/workflows/**/*.yml: +# ignore: [] diff --git a/.github/actions/build-rust/action.yml b/.github/actions/build-rust/action.yml new file mode 100644 index 00000000..305897d2 --- /dev/null +++ b/.github/actions/build-rust/action.yml @@ -0,0 +1,108 @@ +name: 'AFFiNE Rust build' +description: 'Rust build setup, including cache configuration' +inputs: + target: + description: 'Cargo target' + required: true + package: + description: 'Package to build' + required: true + no-build: + description: 'Whether to skip building' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: Print rustup toolchain version + shell: bash + id: rustup-version + working-directory: ${{ env.DEV_DRIVE_WORKSPACE || github.workspace }} + run: | + export RUST_TOOLCHAIN_VERSION="$(grep 'channel' rust-toolchain.toml | head -1 | awk -F '"' '{print $2}')" + echo "Rust toolchain version: $RUST_TOOLCHAIN_VERSION" + echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> "$GITHUB_OUTPUT" + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + if: ${{ runner.os != 'Windows' }} + with: + toolchain: '${{ steps.rustup-version.outputs.RUST_TOOLCHAIN_VERSION }}' + targets: ${{ inputs.target }} + env: + CARGO_INCREMENTAL: '1' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + if: ${{ runner.os == 'Windows' }} + with: + toolchain: '${{ steps.rustup-version.outputs.RUST_TOOLCHAIN_VERSION }}' + targets: ${{ inputs.target }} + env: + CARGO_INCREMENTAL: '1' + CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo + RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup + + - name: Set CC + if: ${{ contains(inputs.target, 'linux') && inputs.no-build != 'true' }} + working-directory: ${{ env.DEV_DRIVE_WORKSPACE || github.workspace }} + shell: bash + # https://github.com/tree-sitter/tree-sitter/issues/4186 + # pass -D_BSD_SOURCE to clang to fix the tree-sitter build issue + run: | + if [[ "${{ inputs.target }}" == "aarch64-unknown-linux-gnu" ]]; then + # napi cross-toolchain 1.0.3 headers miss AT_HWCAP2 in elf.h + echo "CC=clang -D_BSD_SOURCE -DAT_HWCAP2=26" >> "$GITHUB_ENV" + echo "TARGET_CC=clang -D_BSD_SOURCE -DAT_HWCAP2=26" >> "$GITHUB_ENV" + else + echo "CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV" + echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV" + fi + + - name: Prepare cache key + id: cache-key + shell: bash + run: | + shared_key="$(printf '%s' "${{ inputs.target }}-${{ inputs.package }}" | tr -c 'A-Za-z0-9_.-' '-')" + echo "shared-key=$shared_key" >> "$GITHUB_OUTPUT" + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + if: ${{ runner.os == 'Windows' }} + with: + workspaces: ${{ env.DEV_DRIVE_WORKSPACE }} + save-if: ${{ github.ref_name == 'canary' }} + shared-key: ${{ steps.cache-key.outputs.shared-key }} + env: + CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo + RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + if: ${{ runner.os != 'Windows' }} + with: + save-if: ${{ github.ref_name == 'canary' }} + shared-key: ${{ steps.cache-key.outputs.shared-key }} + + - name: Build + shell: bash + if: ${{ runner.os != 'Windows' && inputs.no-build != 'true' }} + run: | + if [[ "${{ inputs.target }}" == "x86_64-unknown-linux-gnu" ]]; then + yarn workspace ${{ inputs.package }} build --target ${{ inputs.target }} + else + yarn workspace ${{ inputs.package }} build --target ${{ inputs.target }} --use-napi-cross + fi + env: + DEBUG: 'napi:*' + + - name: Build + working-directory: ${{ env.DEV_DRIVE_WORKSPACE || github.workspace }} + shell: bash + if: ${{ runner.os == 'Windows' && inputs.no-build != 'true' }} + run: | + yarn workspace ${{ inputs.package }} build --target ${{ inputs.target }} + env: + DEBUG: 'napi:*' + CARGO_HOME: ${{ env.DEV_DRIVE }}/.cargo + RUSTUP_HOME: ${{ env.DEV_DRIVE }}/.rustup diff --git a/.github/actions/cluster-auth/action.yml b/.github/actions/cluster-auth/action.yml new file mode 100644 index 00000000..3fa3c618 --- /dev/null +++ b/.github/actions/cluster-auth/action.yml @@ -0,0 +1,36 @@ +name: 'Auth to Cluster' +description: 'Auth to the GCP Cluster' +inputs: + gcp-project-number: + description: 'GCP project number' + required: true + gcp-project-id: + description: 'GCP project id' + required: true + service-account: + description: 'Service account' + cluster-name: + description: 'Cluster name' + cluster-location: + description: 'Cluster location' + +runs: + using: 'composite' + steps: + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: 'projects/${{ inputs.gcp-project-number }}/locations/global/workloadIdentityPools/github-actions/providers/github-actions-helm-deploy' + service_account: '${{ inputs.service-account }}' + token_format: 'access_token' + project_id: '${{ inputs.gcp-project-id }}' + + - name: 'Setup gcloud cli' + uses: 'google-github-actions/setup-gcloud@v2' + with: + install_components: 'gke-gcloud-auth-plugin' + + - id: get-gke-credentials + shell: bash + run: | + gcloud container clusters get-credentials ${{ inputs.cluster-name }} --region ${{ inputs.cluster-location }} --project ${{ inputs.gcp-project-id }} diff --git a/.github/actions/copilot-test/action.yml b/.github/actions/copilot-test/action.yml new file mode 100644 index 00000000..4a0b081b --- /dev/null +++ b/.github/actions/copilot-test/action.yml @@ -0,0 +1,25 @@ +name: 'Run Copilot E2E Test' +description: 'Run Copilot E2E Test' +inputs: + script: + description: 'Script to run' + default: 'yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only' + required: false + +runs: + using: 'composite' + steps: + - name: Server Copilot E2E Test + shell: bash + run: ${{ inputs.script }} + env: + COPILOT: true + DEV_SERVER_URL: http://localhost:8080 + + - name: Upload test results + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-server-copilot + path: ./test-results + if-no-files-found: ignore diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 00000000..afa60601 --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,35 @@ +name: 'Deploy to Cluster' +description: 'Deploy AFFiNE Cloud to cluster' +inputs: + gcp-project-number: + description: 'GCP project number' + required: true + gcp-project-id: + description: 'GCP project id' + required: true + service-account: + description: 'Service account' + cluster-name: + description: 'Cluster name' + cluster-location: + description: 'Cluster location' + +runs: + using: 'composite' + steps: + - name: Setup Git short hash + shell: bash + run: | + echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" + - name: 'Auth to cluster' + uses: './.github/actions/cluster-auth' + with: + gcp-project-number: '${{ inputs.gcp-project-number }}' + gcp-project-id: '${{ inputs.gcp-project-id }}' + service-account: '${{ inputs.service-account }}' + cluster-name: '${{ inputs.cluster-name }}' + cluster-location: '${{ inputs.cluster-location }}' + + - name: Deploy + shell: bash + run: node ./.github/actions/deploy/deploy.mjs diff --git a/.github/actions/deploy/deploy.mjs b/.github/actions/deploy/deploy.mjs new file mode 100644 index 00000000..2487109f --- /dev/null +++ b/.github/actions/deploy/deploy.mjs @@ -0,0 +1,174 @@ +import { execSync } from 'node:child_process'; + +const { + APP_VERSION, + BUILD_TYPE, + DEPLOY_HOST, + CANARY_DEPLOY_HOST, + GIT_SHORT_HASH, + DATABASE_URL, + DATABASE_USERNAME, + DATABASE_PASSWORD, + DATABASE_NAME, + GCLOUD_CONNECTION_NAME, + CLOUD_SQL_IAM_ACCOUNT, + APP_IAM_ACCOUNT, + REDIS_SERVER_HOST, + REDIS_SERVER_PASSWORD, + STATIC_IP_NAME, + AFFINE_INDEXER_SEARCH_PROVIDER, + AFFINE_INDEXER_SEARCH_ENDPOINT, + AFFINE_INDEXER_SEARCH_API_KEY, +} = process.env; + +const buildType = BUILD_TYPE || 'canary'; + +const isProduction = buildType === 'stable'; +const isBeta = buildType === 'beta'; +const isCanary = buildType === 'canary'; +const isInternal = buildType === 'internal'; +const isSpotEnabled = isBeta || isCanary; + +const replicaConfig = { + stable: { + front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2, + graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2, + }, + beta: { + front: Number(process.env.BETA_FRONT_REPLICA) || 1, + graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1, + }, + canary: { front: 1, graphql: 1 }, +}; + +const cpuConfig = { + beta: { front: '1', graphql: '1' }, + canary: { front: '500m', graphql: '1' }, +}; + +const memoryConfig = { + beta: { front: '2Gi', graphql: '1Gi' }, + canary: { front: '512Mi', graphql: '512Mi' }, +}; + +const createHelmCommand = ({ isDryRun }) => { + const flag = isDryRun ? '--dry-run' : '--atomic'; + const imageTag = `${buildType}-${GIT_SHORT_HASH}`; + const redisAndPostgres = + isProduction || isBeta || isInternal + ? [ + `--set cloud-sql-proxy.enabled=true`, + `--set-string cloud-sql-proxy.database.connectionName="${GCLOUD_CONNECTION_NAME}"`, + `--set-string global.database.host=${DATABASE_URL}`, + `--set-string global.database.user=${DATABASE_USERNAME}`, + `--set-string global.database.password=${DATABASE_PASSWORD}`, + `--set-string global.database.name=${DATABASE_NAME}`, + `--set-string global.redis.host="${REDIS_SERVER_HOST}"`, + `--set-string global.redis.password="${REDIS_SERVER_PASSWORD}"`, + ] + : []; + const indexerOptions = [ + `--set-string global.indexer.provider="${AFFINE_INDEXER_SEARCH_PROVIDER}"`, + `--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`, + `--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`, + ]; + const cloudSqlNodeSelector = isBeta + ? `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\", \\"cloud.google.com/gke-spot\\": \\"true\\" }` + : `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }`; + const serviceAnnotations = [ + `--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`, + `--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`, + ].concat( + isProduction || isBeta || isInternal + ? [ + `--set-json front.services.web.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`, + `--set-json front.services.sync.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 cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`, + `--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 memory = memoryConfig[buildType]; + let resources = []; + if (cpu) { + resources = resources.concat([ + `--set front.resources.requests.cpu="${cpu.front}"`, + `--set graphql.resources.requests.cpu="${cpu.graphql}"`, + ]); + } + if (memory) { + resources = resources.concat([ + `--set front.resources.requests.memory="${memory.front}"`, + `--set graphql.resources.requests.memory="${memory.graphql}"`, + ]); + } + + const replica = replicaConfig[buildType] || replicaConfig.canary; + + const namespace = isProduction + ? 'production' + : isBeta + ? 'beta' + : isInternal + ? 'internal' + : 'dev'; + + const hosts = (DEPLOY_HOST || CANARY_DEPLOY_HOST) + .split(',') + .map(host => host.trim()) + .filter(host => host); + const primaryHost = hosts[0] || '0.0.0.0'; + const deployCommand = [ + `helm upgrade --install affine .github/helm/affine`, + `--namespace ${namespace}`, + `--set-string global.deployment.type="affine"`, + `--set-string global.deployment.platform="gcp"`, + `--set-string global.app.buildType="${buildType}"`, + `--set global.ingress.enabled=true`, + `--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`, + ...hosts.map( + (host, index) => `--set global.ingress.hosts[${index}]=${host}` + ), + `--set-string global.version="${APP_VERSION}"`, + ...redisAndPostgres, + ...indexerOptions, + `--set front.replicaCount=${replica.front}`, + `--set-string front.image.tag="${imageTag}"`, + `--set-string front.app.host="${primaryHost}"`, + `--set graphql.replicaCount=${replica.graphql}`, + `--set-string graphql.image.tag="${imageTag}"`, + `--set-string graphql.app.host="${primaryHost}"`, + ...serviceAnnotations, + ...spotScheduling, + ...resources, + `--timeout 10m`, + flag, + ].join(' '); + return deployCommand; +}; + +const output = execSync(createHelmCommand({ isDryRun: true }), { + encoding: 'utf-8', + stdio: ['inherit', 'pipe', 'inherit'], +}); +const templates = output + .split('---') + .filter(yml => !yml.split('\n').some(line => line.trim() === 'kind: Secret')) + .join('---'); +console.log(templates); + +execSync(createHelmCommand({ isDryRun: false }), { + encoding: 'utf-8', + stdio: 'inherit', +}); diff --git a/.github/actions/download-web/action.yml b/.github/actions/download-web/action.yml new file mode 100644 index 00000000..b5ea42e6 --- /dev/null +++ b/.github/actions/download-web/action.yml @@ -0,0 +1,22 @@ +name: 'Download core artifacts' +description: 'Download core artifacts and extract to dist' +inputs: + path: + description: 'Path to extract' + required: true + +runs: + using: 'composite' + steps: + - name: Download tar.gz + uses: actions/download-artifact@v4 + with: + name: web + path: . + + - name: Extract core artifacts + shell: bash + run: | + mkdir -p ${{ inputs.path }} + tar -xvf dist.tar.gz --directory ${{ inputs.path }} + rm dist.tar.gz diff --git a/.github/actions/prepare-release/action.yml b/.github/actions/prepare-release/action.yml new file mode 100644 index 00000000..3eac38c1 --- /dev/null +++ b/.github/actions/prepare-release/action.yml @@ -0,0 +1,41 @@ +name: Prepare Release +description: 'Prepare Release' +outputs: + APP_VERSION: + description: 'App Version' + value: ${{ steps.get-version.outputs.APP_VERSION }} + GIT_SHORT_HASH: + description: 'Git Short Hash' + value: ${{ steps.get-version.outputs.GIT_SHORT_HASH }} + BUILD_TYPE: + description: 'Build Type' + value: ${{ steps.get-version.outputs.BUILD_TYPE }} +runs: + using: 'composite' + steps: + - name: Get Version + id: get-version + shell: bash + run: | + GIT_SHORT_HASH=$(git rev-parse --short HEAD) + if [ "${{ github.ref_type }}" == "tag" ]; then + APP_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//') + else + APP_VERSION=$(date '+%Y.%-m.%-d-canary.%-H%M') + fi + if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + BUILD_TYPE=stable + elif [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then + BUILD_TYPE=beta + elif [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-canary\.[0-9a-f]+$ ]]; then + BUILD_TYPE=canary + else + echo "Error: unsupported version string: $APP_VERSION" >&2 + exit 1 + fi + echo $APP_VERSION + echo $GIT_SHORT_HASH + echo $BUILD_TYPE + echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT" + echo "GIT_SHORT_HASH=$GIT_SHORT_HASH" >> "$GITHUB_OUTPUT" + echo "BUILD_TYPE=$BUILD_TYPE" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/server-test-env/action.yml b/.github/actions/server-test-env/action.yml new file mode 100644 index 00000000..6b2a288e --- /dev/null +++ b/.github/actions/server-test-env/action.yml @@ -0,0 +1,57 @@ +name: 'Prepare Server Test Environment' +description: 'Prepare Server Test Environment' + +inputs: + indexer-provider: + description: 'Search provider used by the server test runtime' + required: false + default: embedded + +runs: + using: 'composite' + steps: + - name: Initialize database + shell: bash + run: | + psql -h localhost -U postgres -c "CREATE DATABASE affine;" + psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';" + psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;" + env: + PGPASSWORD: affine + + - name: Run init-db script + shell: bash + env: + NODE_ENV: test + run: | + yarn affine @affine/server prisma generate + yarn affine @affine/server data-migration admit-legacy-context-blobs + yarn affine @affine/server prisma migrate deploy + yarn affine @affine/server data-migration run + + - name: Import config + shell: bash + run: | + case '${{ inputs.indexer-provider }}' in + disabled) + default_indexer='{"enabled":false,"provider":{"type":"embedded","endpoint":""}}' + ;; + embedded) + default_indexer='{"enabled":true,"provider":{"type":"embedded","endpoint":""}}' + ;; + elasticsearch) + default_indexer='{"enabled":true,"provider":{"type":"elasticsearch","endpoint":"http://localhost:9200"}}' + ;; + manticoresearch) + default_indexer='{"enabled":true,"provider":{"type":"manticoresearch","endpoint":"http://localhost:9308"}}' + ;; + *) + echo "Unsupported test indexer provider: ${{ inputs.indexer-provider }}" >&2 + exit 1 + ;; + esac + if [[ -n "${SERVER_CONFIG:-}" ]]; then + jq --argjson indexer "$default_indexer" '.indexer = $indexer' <<<"$SERVER_CONFIG" > ./packages/backend/server/config.json + else + jq -n --argjson indexer "$default_indexer" '{indexer: $indexer}' > ./packages/backend/server/config.json + fi diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml new file mode 100644 index 00000000..87696b7a --- /dev/null +++ b/.github/actions/setup-node/action.yml @@ -0,0 +1,212 @@ +name: 'AFFiNE Node.js Setup' +description: 'Node.js setup for CI, including cache configuration' +inputs: + extra-flags: + description: 'Extra flags to pass to the yarn install.' + required: false + default: '--immutable --inline-builds' + package-install: + description: 'Run the install step.' + required: false + default: 'true' + playwright-install: + description: 'Run the install step for Playwright.' + required: false + default: 'false' + playwright-platform: + description: 'The platform to install Playwright for.' + required: false + default: 'chromium,webkit' + electron-install: + description: 'Download the Electron binary' + required: false + default: 'true' + corepack-install: + description: 'Install CorePack' + required: false + default: 'false' + hard-link-nm: + description: 'set nmMode to hardlinks-local in .yarnrc.yml' + required: false + default: 'true' + nmHoistingLimits: + description: 'Set nmHoistingLimits in .yarnrc.yml' + required: false + enableScripts: + description: 'Set enableScripts in .yarnrc.yml' + required: false + default: 'true' + full-cache: + description: 'Full installation cache' + required: false +runs: + using: 'composite' + steps: + - name: Output workspace path + id: workspace-path + shell: bash + run: | + if [ -n "${{ env.DEV_DRIVE_WORKSPACE }}" ]; then + echo "workspace_path=${{ env.DEV_DRIVE_WORKSPACE }}" >> $GITHUB_OUTPUT + else + echo "workspace_path=${{ github.workspace }}" >> $GITHUB_OUTPUT + fi + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + registry-url: https://npm.pkg.github.com + scope: '@toeverything' + + - uses: kenchan0130/actions-system-info@master + id: system-info + + - name: Init CorePack + if: ${{ inputs.corepack-install == 'true' }} + shell: bash + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: corepack enable + + - name: Set nmMode + if: ${{ inputs.hard-link-nm == 'false' }} + shell: bash + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: yarn config set nmMode classic + + - name: Set nmHoistingLimits + if: ${{ inputs.nmHoistingLimits }} + shell: bash + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: yarn config set nmHoistingLimits ${{ inputs.nmHoistingLimits }} + + - name: Set enableScripts + if: ${{ inputs.enableScripts == 'false' }} + shell: bash + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: yarn config set enableScripts false + + - name: Set yarn global cache path + shell: bash + id: yarn-cache + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: node -e "const p = $(yarn config cacheFolder --json).effective; console.log('yarn_global_cache=' + p)" >> $GITHUB_OUTPUT + + - name: Cache non-full yarn cache on Linux + uses: actions/cache@v6 + if: ${{ inputs.full-cache != 'true' && runner.os == 'Linux' }} + with: + path: | + ${{ steps.workspace-path.outputs.workspace_path }}/node_modules + ${{ steps.yarn-cache.outputs.yarn_global_cache }} + key: node_modules-cache-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }} + + # The network performance on macOS is very poor + # and the decompression performance on Windows is very terrible + # so we reduce the number of cached files on non-Linux systems by remove node_modules from cache path. + - name: Cache non-full yarn cache on non-Linux + uses: actions/cache@v6 + if: ${{ inputs.full-cache != 'true' && runner.os != 'Linux' }} + with: + path: | + ${{ steps.yarn-cache.outputs.yarn_global_cache }} + key: node_modules-cache-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }} + + - name: Cache full yarn cache on Linux + uses: actions/cache@v6 + if: ${{ inputs.full-cache == 'true' && runner.os == 'Linux' }} + with: + path: | + node_modules + ${{ steps.yarn-cache.outputs.yarn_global_cache }} + key: node_modules-cache-full-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }} + + - name: Cache full yarn cache on non-Linux + uses: actions/cache@v6 + if: ${{ inputs.full-cache == 'true' && runner.os != 'Linux' }} + with: + path: | + ${{ steps.yarn-cache.outputs.yarn_global_cache }} + key: node_modules-cache-full-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }} + + - name: yarn install + if: ${{ inputs.package-install == 'true' }} + shell: bash + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: yarn ${{ inputs.extra-flags }} + env: + HUSKY: '0' + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + SENTRYCLI_SKIP_DOWNLOAD: '1' + DEBUG: '*' + + - name: Get installed Playwright version + id: playwright-version + if: ${{ inputs.playwright-install == 'true' }} + shell: bash + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + run: echo "version=$(yarn why --json @playwright/test | grep -h 'workspace:.' | jq --raw-output '.children[].locator' | sed -e 's/@playwright\/test@.*://' | head -n 1)" >> $GITHUB_OUTPUT + + # Attempt to restore the correct Playwright browser binaries based on the + # currently installed version of Playwright (The browser binary versions + # may change with Playwright versions). + # Note: Playwright's cache directory is hard coded because that's what it + # says to do in the docs. There doesn't appear to be a command that prints + # it out for us. + - uses: actions/cache@v6 + id: playwright-cache + if: ${{ inputs.playwright-install == 'true' }} + with: + path: ${{ steps.workspace-path.outputs.workspace_path }}/node_modules/.cache/ms-playwright + key: '${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}-playwright-${{ steps.playwright-version.outputs.version }}' + # As a fallback, if the Playwright version has changed, try use the + # most recently cached version. There's a good chance that at least one + # of the browser binary versions haven't been updated, so Playwright can + # skip installing that in the next step. + # Note: When falling back to an old cache, `cache-hit` (used below) + # will be `false`. This allows us to restore the potentially out of + # date cache, but still let Playwright decide if it needs to download + # new binaries or not. + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}-playwright- + + # If the Playwright browser binaries weren't able to be restored, we tell + # playwright to install everything for us. + - name: Install Playwright's dependencies + shell: bash + if: inputs.playwright-install == 'true' + run: yarn playwright install --with-deps $(echo "${{ inputs.playwright-platform }}" | tr ',' ' ') + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + env: + PLAYWRIGHT_BROWSERS_PATH: ${{ steps.workspace-path.outputs.workspace_path }}/node_modules/.cache/ms-playwright + + - name: Get installed Electron version + id: electron-version + if: ${{ inputs.electron-install == 'true' }} + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + shell: bash + run: | + echo "version=$(yarn why --json electron | grep -h 'workspace:.' | jq --raw-output '.children[].locator' | sed -e 's/@playwright\/test@.*://' | head -n 1)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v6 + id: electron-cache + if: ${{ inputs.electron-install == 'true' }} + with: + path: ${{ steps.workspace-path.outputs.workspace_path }}/node_modules/.cache/electron + key: '${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}-electron-${{ steps.electron-version.outputs.version }}' + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}-electron- + + - name: Install Electron binary + shell: bash + if: inputs.electron-install == 'true' + run: node ./node_modules/electron/install.js + working-directory: ${{ steps.workspace-path.outputs.workspace_path }} + env: + electron_config_cache: ${{ steps.workspace-path.outputs.workspace_path }}/node_modules/.cache/electron + + - name: Write PLAYWRIGHT_BROWSERS_PATH env + shell: bash + run: | + echo "PLAYWRIGHT_BROWSERS_PATH=${{ steps.workspace-path.outputs.workspace_path }}/node_modules/.cache/ms-playwright" >> $GITHUB_ENV diff --git a/.github/actions/setup-sentry/action.yml b/.github/actions/setup-sentry/action.yml new file mode 100644 index 00000000..d38a61b3 --- /dev/null +++ b/.github/actions/setup-sentry/action.yml @@ -0,0 +1,13 @@ +name: Setup @sentry/cli +description: 'Setup @sentry/cli' +runs: + using: 'composite' + steps: + - name: 'Install @sentry/cli from brew' + if: runner.os == 'macos' + shell: bash + run: brew install getsentry/tools/sentry-cli + - name: 'Install @sentry/cli from npm' + if: runner.os != 'macos' + shell: bash + run: sudo npm install -g @sentry/cli --unsafe-perm diff --git a/.github/actions/setup-version/action.yml b/.github/actions/setup-version/action.yml new file mode 100644 index 00000000..e0542f96 --- /dev/null +++ b/.github/actions/setup-version/action.yml @@ -0,0 +1,17 @@ +name: Setup Version +description: 'Setup Version' +inputs: + app-version: + description: 'App Version' + required: true + ios-app-version: + description: 'iOS App Store Version (Optional, use App version if empty)' + required: false +runs: + using: 'composite' + steps: + - name: 'Write Version' + shell: bash + env: + IOS_APP_VERSION: ${{ inputs.ios-app-version }} + run: ./scripts/set-version.sh ${{ inputs.app-version }} diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 00000000..c2c4c4ac --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1,10 @@ +# This is used for tracking in GitHub project. +# See https://github.com/marketplace/actions/auto-assign-action + +# Set to true to add reviewers to pull requests +addReviewers: false + +# Set to true to add assignees to pull requests +addAssignees: author + +runOnDraft: true diff --git a/.github/deployment/node/Dockerfile b/.github/deployment/node/Dockerfile new file mode 100644 index 00000000..7f637cbc --- /dev/null +++ b/.github/deployment/node/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-bookworm-slim AS assets +WORKDIR /app + +COPY ./packages/backend/server /app +COPY ./packages/frontend/apps/web/dist /app/static +COPY ./packages/frontend/admin/dist /app/static/admin +COPY ./packages/frontend/apps/mobile/dist /app/static/mobile + +# Keep server sourcemap for stacktraces, but don't ship frontend/node_modules sourcemaps. +ARG TARGETARCH +ARG TARGETVARIANT +# Needed for Prisma engine resolution (and potential engine download during cleanup). +RUN apt-get update && \ + apt-get install -y --no-install-recommends openssl ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +RUN AFFINE_DOCKER_CLEAN=1 TARGETARCH="${TARGETARCH}" TARGETVARIANT="${TARGETVARIANT}" node ./scripts/docker-clean.mjs + +FROM node:22-bookworm-slim +WORKDIR /app + +COPY --from=assets /app /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends openssl libjemalloc2 && \ + rm -rf /var/lib/apt/lists/* + +# Enable jemalloc by preloading the library +ENV LD_PRELOAD=libjemalloc.so.2 + +EXPOSE 3010 + +CMD ["node", "./dist/main.js"] diff --git a/.github/helm/affine/.helmignore b/.github/helm/affine/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/.github/helm/affine/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/.github/helm/affine/Chart.yaml b/.github/helm/affine/Chart.yaml new file mode 100644 index 00000000..2ae11c9a --- /dev/null +++ b/.github/helm/affine/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: affine +description: AFFiNE cloud chart +type: application +version: 0.0.0 +appVersion: "0.27.5" diff --git a/.github/helm/affine/charts/front/Chart.yaml b/.github/helm/affine/charts/front/Chart.yaml new file mode 100644 index 00000000..9fec0dda --- /dev/null +++ b/.github/helm/affine/charts/front/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: front +description: AFFiNE front server +type: application +version: 0.0.0 +appVersion: "0.27.5" +dependencies: + - name: gcloud-sql-proxy + version: 0.0.0 + repository: "file://../gcloud-sql-proxy" + condition: .global.database.gcloud.enabled diff --git a/.github/helm/affine/charts/front/templates/NOTES.txt b/.github/helm/affine/charts/front/templates/NOTES.txt new file mode 100644 index 00000000..796f60e9 --- /dev/null +++ b/.github/helm/affine/charts/front/templates/NOTES.txt @@ -0,0 +1,16 @@ +1. Get the application URL by running these commands: +{{- if contains "NodePort" .Values.services.sync.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ .Values.services.sync.name }}) + 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.services.sync.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 {{ .Values.services.sync.name }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ .Values.services.sync.name }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.services.sync.port }} +{{- else if contains "ClusterIP" .Values.services.sync.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "front.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 }} diff --git a/.github/helm/affine/charts/front/templates/_helpers.tpl b/.github/helm/affine/charts/front/templates/_helpers.tpl new file mode 100644 index 00000000..dcfa4a6c --- /dev/null +++ b/.github/helm/affine/charts/front/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "front.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 "front.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 "front.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "front.labels" -}} +helm.sh/chart: {{ include "front.chart" . }} +{{ include "front.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 "front.selectorLabels" -}} +app.kubernetes.io/name: {{ include "front.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "front.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "front.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/front/templates/deployment.yaml b/.github/helm/affine/charts/front/templates/deployment.yaml new file mode 100644 index 00000000..df076413 --- /dev/null +++ b/.github/helm/affine/charts/front/templates/deployment.yaml @@ -0,0 +1,150 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "front.fullname" . }} + labels: + {{- include "front.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "front.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/indexer-config: {{ include "affine.indexerConfig" . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "front.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "front.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.global.database.gcloud }} + {{- if .enabled }} + initContainers: + - name: wait-for-cloud-sql-proxy + image: busybox:1.36.1 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + until wget -q -T 2 -O /dev/null "http://{{ $.Values.global.database.host }}:9801/startup"; do + echo "waiting for cloud sql proxy to become ready" + sleep 2 + done + {{- end }} + {{- end }} + 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 + {{- if .Values.global.secret.licenseSecretName }} + - name: AFFINE_PRO_LICENSE_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: "{{ .Values.global.secret.licenseSecretName }}" + key: "{{ .Values.global.secret.licensePrivateKeyKey }}" + {{- end }} + - name: NODE_ENV + value: "{{ .Values.env }}" + - name: NODE_OPTIONS + value: "{{ .Values.nodeOptions }}" + - 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: "front" + - 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_SERVER_PORT + value: "{{ .Values.app.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.app.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /info + port: http + initialDelaySeconds: {{ default .Values.probe.initialDelaySeconds .Values.probe.liveness.initialDelaySeconds }} + timeoutSeconds: {{ default .Values.probe.timeoutSeconds .Values.probe.liveness.timeoutSeconds }} + periodSeconds: {{ default .Values.probe.periodSeconds .Values.probe.liveness.periodSeconds }} + failureThreshold: {{ default .Values.probe.failureThreshold .Values.probe.liveness.failureThreshold }} + successThreshold: {{ default .Values.probe.successThreshold .Values.probe.liveness.successThreshold }} + readinessProbe: + httpGet: + path: /info + port: http + initialDelaySeconds: {{ default .Values.probe.initialDelaySeconds .Values.probe.readiness.initialDelaySeconds }} + timeoutSeconds: {{ default .Values.probe.timeoutSeconds .Values.probe.readiness.timeoutSeconds }} + periodSeconds: {{ default .Values.probe.periodSeconds .Values.probe.readiness.periodSeconds }} + failureThreshold: {{ default .Values.probe.failureThreshold .Values.probe.readiness.failureThreshold }} + successThreshold: {{ default .Values.probe.successThreshold .Values.probe.readiness.successThreshold }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: indexer-config + mountPath: /app/config.json + subPath: config.json + readOnly: true + volumes: + - name: indexer-config + secret: + secretName: indexer + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/.github/helm/affine/charts/front/templates/service-renderer.yaml b/.github/helm/affine/charts/front/templates/service-renderer.yaml new file mode 100644 index 00000000..070f2750 --- /dev/null +++ b/.github/helm/affine/charts/front/templates/service-renderer.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.services.renderer.name }} + labels: + {{- include "front.labels" . | nindent 4 }} + {{- with .Values.services.renderer.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.services.renderer.type }} + ports: + - port: {{ .Values.services.renderer.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "front.selectorLabels" . | nindent 4 }} diff --git a/.github/helm/affine/charts/front/templates/service-sync.yaml b/.github/helm/affine/charts/front/templates/service-sync.yaml new file mode 100644 index 00000000..6db650a4 --- /dev/null +++ b/.github/helm/affine/charts/front/templates/service-sync.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.services.sync.name }} + labels: + {{- include "front.labels" . | nindent 4 }} + {{- $annotations := omit .Values.services.sync.annotations "cloud.google.com/backend-config" }} + annotations: + cloud.google.com/backend-config: {{ dict "default" .Values.services.sync.backendConfig.name | toJson | quote }} + {{- with $annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.services.sync.type }} + ports: + - port: {{ .Values.services.sync.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "front.selectorLabels" . | nindent 4 }} diff --git a/.github/helm/affine/charts/front/templates/service-web.yaml b/.github/helm/affine/charts/front/templates/service-web.yaml new file mode 100644 index 00000000..c98035d2 --- /dev/null +++ b/.github/helm/affine/charts/front/templates/service-web.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.services.web.name }} + labels: + {{- include "front.labels" . | nindent 4 }} + {{- with .Values.services.web.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.services.web.type }} + ports: + - port: {{ .Values.services.web.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "front.selectorLabels" . | nindent 4 }} diff --git a/.github/helm/affine/charts/front/templates/serviceaccount.yaml b/.github/helm/affine/charts/front/templates/serviceaccount.yaml new file mode 100644 index 00000000..322b290e --- /dev/null +++ b/.github/helm/affine/charts/front/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "front.serviceAccountName" . }} + labels: + {{- include "front.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/front/templates/tests/test-connection.yaml b/.github/helm/affine/charts/front/templates/tests/test-connection.yaml new file mode 100644 index 00000000..d917f442 --- /dev/null +++ b/.github/helm/affine/charts/front/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "front.fullname" . }}-test-connection" + labels: + {{- include "front.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ .Values.services.sync.name }}:{{ .Values.services.sync.port }}'] + restartPolicy: Never diff --git a/.github/helm/affine/charts/front/values.yaml b/.github/helm/affine/charts/front/values.yaml new file mode 100644 index 00000000..c53d4d22 --- /dev/null +++ b/.github/helm/affine/charts/front/values.yaml @@ -0,0 +1,72 @@ +replicaCount: 1 +image: + repository: ghcr.io/toeverything/affine + pullPolicy: IfNotPresent + tag: '' + +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' +# map to NODE_ENV environment variable +env: 'production' +nodeOptions: '--max-old-space-size=3072' +app: + # AFFINE_SERVER_PORT + port: 3010 + # AFFINE_SERVER_SUB_PATH + path: '' + # AFFINE_SERVER_HOST + host: '0.0.0.0' + https: true +serviceAccount: + create: true + annotations: {} + name: 'affine-front' + +podAnnotations: {} + +podSecurityContext: + fsGroup: 2000 + +resources: + limits: + cpu: '1' + memory: 6Gi + requests: + cpu: '1' + memory: 4Gi + +probe: + initialDelaySeconds: 20 + timeoutSeconds: 5 + periodSeconds: 10 + failureThreshold: 6 + successThreshold: 1 + liveness: + initialDelaySeconds: 60 + failureThreshold: 12 + readiness: {} + +services: + sync: + name: affine-sync + type: ClusterIP + port: 3010 + backendConfig: + name: affine-sync-backendconfig + timeoutSec: 10800 + annotations: {} + renderer: + name: affine-renderer + type: ClusterIP + port: 3000 + annotations: + cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}' + web: + name: affine-web + type: ClusterIP + port: 8080 + annotations: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/.helmignore b/.github/helm/affine/charts/gcloud-sql-proxy/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml new file mode 100644 index 00000000..f1038c70 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: cloud-sql-proxy +description: Google Cloud SQL Proxy +type: application +version: 0.0.0 +appVersion: "2.8.1" diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt b/.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt new file mode 100644 index 00000000..775c2927 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/NOTES.txt @@ -0,0 +1,18 @@ +{{- if .Values.enabled -}} +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 "gcloud-sql-proxy.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 "gcloud-sql-proxy.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "gcloud-sql-proxy.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 "gcloud-sql-proxy.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 }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl b/.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl new file mode 100644 index 00000000..8d447acb --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gcloud-sql-proxy.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 "gcloud-sql-proxy.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 "gcloud-sql-proxy.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gcloud-sql-proxy.labels" -}} +helm.sh/chart: {{ include "gcloud-sql-proxy.chart" . }} +{{ include "gcloud-sql-proxy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gcloud-sql-proxy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gcloud-sql-proxy.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "gcloud-sql-proxy.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "gcloud-sql-proxy.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml new file mode 100644 index 00000000..a94f5cc7 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/deployment.yaml @@ -0,0 +1,132 @@ +{{- if .Values.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gcloud-sql-proxy.fullname" . }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "gcloud-sql-proxy.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "gcloud-sql-proxy.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - "--address" + - "0.0.0.0" + - "--structured-logs" + - "--auto-iam-authn" + - "{{ .Values.database.connectionName }}" + env: + # Enable HTTP healthchecks on port 9801. This enables /liveness, + # /readiness and /startup health check endpoints. Allow connections + # listen for connections on any interface (0.0.0.0) so that the + # k8s management components can reach these endpoints. + - name: CSQL_PROXY_HEALTH_CHECK + value: "true" + - name: CSQL_PROXY_HTTP_PORT + value: "9801" + - name: CSQL_PROXY_HTTP_ADDRESS + value: 0.0.0.0 + ports: + - name: cloud-sql-proxy + containerPort: {{ .Values.service.port }} + protocol: TCP + - containerPort: 9801 + protocol: TCP + # The /startup probe returns OK when the proxy is ready to receive + # connections from the application. In this example, k8s will check + # once a second for 60 seconds. + startupProbe: + failureThreshold: 60 + httpGet: + path: /startup + port: 9801 + scheme: HTTP + periodSeconds: 1 + successThreshold: 1 + timeoutSeconds: 10 + # The /liveness probe returns OK as soon as the proxy application has + # begun its startup process and continues to return OK until the + # process stops. + livenessProbe: + failureThreshold: 3 + httpGet: + path: /liveness + port: 9801 + scheme: HTTP + # The probe will be checked every 10 seconds. + periodSeconds: 10 + # Number of times the probe is allowed to fail before the transition + # from healthy to failure state. + # + # If periodSeconds = 60, 5 tries will result in five minutes of + # checks. The proxy starts to refresh a certificate five minutes + # before its expiration. If those five minutes lapse without a + # successful refresh, the liveness probe will fail and the pod will be + # restarted. + successThreshold: 1 + # The probe will fail if it does not respond in 10 seconds + timeoutSeconds: 10 + readinessProbe: + # The /readiness probe returns OK when the proxy can establish + # a new connections to its databases. + httpGet: + path: /readiness + port: 9801 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 10 + # Number of times the probe must report success to transition from failure to healthy state. + # Defaults to 1 for readiness probe. + successThreshold: 1 + failureThreshold: 6 + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml new file mode 100644 index 00000000..412d2ecd --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/service.yaml @@ -0,0 +1,21 @@ +{{- if .Values.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gcloud-sql-proxy.fullname" . }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.global.database.port }} + targetPort: cloud-sql-proxy + protocol: TCP + name: cloud-sql-proxy + - port: 9801 + targetPort: 9801 + protocol: TCP + name: health + selector: + {{- include "gcloud-sql-proxy.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml new file mode 100644 index 00000000..a5bddfaa --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/serviceaccount.yaml @@ -0,0 +1,15 @@ +{{- if .Values.enabled -}} +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "gcloud-sql-proxy.serviceAccountName" . }} + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml new file mode 100644 index 00000000..49b67166 --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/templates/tests/test-connection.yaml @@ -0,0 +1,17 @@ +{{- if .Values.enabled -}} +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "gcloud-sql-proxy.fullname" . }}-test-connection" + labels: + {{- include "gcloud-sql-proxy.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "gcloud-sql-proxy.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never +{{- end }} diff --git a/.github/helm/affine/charts/gcloud-sql-proxy/values.yaml b/.github/helm/affine/charts/gcloud-sql-proxy/values.yaml new file mode 100644 index 00000000..e1e2ddff --- /dev/null +++ b/.github/helm/affine/charts/gcloud-sql-proxy/values.yaml @@ -0,0 +1,46 @@ +replicaCount: 2 +enabled: false +database: + connectionName: "" + +image: + # the tag is defined as chart appVersion. + repository: gcr.io/cloud-sql-connectors/cloud-sql-proxy + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 2000 + +securityContext: + runAsNonRoot: true + +service: + type: ClusterIP + port: 5432 + +resources: + limits: + memory: "1Gi" + cpu: "1" + requests: + memory: "512Mi" + cpu: "100m" + +volumes: [] +volumeMounts: [] +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/.github/helm/affine/charts/graphql/Chart.yaml b/.github/helm/affine/charts/graphql/Chart.yaml new file mode 100644 index 00000000..36293f15 --- /dev/null +++ b/.github/helm/affine/charts/graphql/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: graphql +description: AFFiNE GraphQL server +type: application +version: 0.0.0 +appVersion: "0.27.5" +dependencies: + - name: gcloud-sql-proxy + version: 0.0.0 + repository: "file://../gcloud-sql-proxy" + condition: .global.database.gcloud.enabled diff --git a/.github/helm/affine/charts/graphql/templates/NOTES.txt b/.github/helm/affine/charts/graphql/templates/NOTES.txt new file mode 100644 index 00000000..2cec931c --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/NOTES.txt @@ -0,0 +1,16 @@ +1. Get the application URL by running these commands: +{{- if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "graphql.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 "graphql.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "graphql.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 "graphql.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 }} diff --git a/.github/helm/affine/charts/graphql/templates/_helpers.tpl b/.github/helm/affine/charts/graphql/templates/_helpers.tpl new file mode 100644 index 00000000..ee3254da --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "graphql.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 "graphql.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 "graphql.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "graphql.labels" -}} +helm.sh/chart: {{ include "graphql.chart" . }} +{{ include "graphql.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 "graphql.selectorLabels" -}} +app.kubernetes.io/name: {{ include "graphql.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "graphql.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "graphql.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/graphql/templates/deployment.yaml b/.github/helm/affine/charts/graphql/templates/deployment.yaml new file mode 100644 index 00000000..f903b27c --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/deployment.yaml @@ -0,0 +1,146 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "graphql.fullname" . }} + labels: + {{- include "graphql.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "graphql.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/indexer-config: {{ include "affine.indexerConfig" . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "graphql.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "graphql.serviceAccountName" . }} + {{- with .Values.global.database.gcloud }} + {{- if .enabled }} + initContainers: + - name: wait-for-cloud-sql-proxy + image: busybox:1.36.1 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + until wget -q -T 2 -O /dev/null "http://{{ $.Values.global.database.host }}:9801/startup"; do + echo "waiting for cloud sql proxy to become ready" + sleep 2 + done + {{- end }} + {{- end }} + 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 + {{- if .Values.global.secret.licenseSecretName }} + - name: AFFINE_PRO_LICENSE_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: "{{ .Values.global.secret.licenseSecretName }}" + key: "{{ .Values.global.secret.licensePrivateKeyKey }}" + {{- end }} + - name: NODE_ENV + value: "{{ .Values.env }}" + - name: NODE_OPTIONS + value: "--max-old-space-size=2048" + - 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: "graphql" + - 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_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_SERVER_PORT + value: "{{ .Values.service.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.service.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /info + port: http + initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }} + timeoutSeconds: {{ default .Values.probe.timeoutSeconds .Values.probe.liveness.timeoutSeconds }} + periodSeconds: {{ default .Values.probe.periodSeconds .Values.probe.liveness.periodSeconds }} + failureThreshold: {{ default .Values.probe.failureThreshold .Values.probe.liveness.failureThreshold }} + successThreshold: {{ default .Values.probe.successThreshold .Values.probe.liveness.successThreshold }} + readinessProbe: + httpGet: + path: /info + port: http + initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }} + timeoutSeconds: {{ default .Values.probe.timeoutSeconds .Values.probe.readiness.timeoutSeconds }} + periodSeconds: {{ default .Values.probe.periodSeconds .Values.probe.readiness.periodSeconds }} + failureThreshold: {{ default .Values.probe.failureThreshold .Values.probe.readiness.failureThreshold }} + successThreshold: {{ default .Values.probe.successThreshold .Values.probe.readiness.successThreshold }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: indexer-config + mountPath: /app/config.json + subPath: config.json + readOnly: true + volumes: + - name: indexer-config + secret: + secretName: indexer + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/.github/helm/affine/charts/graphql/templates/migration.yaml b/.github/helm/affine/charts/graphql/templates/migration.yaml new file mode 100644 index 00000000..04463055 --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/migration.yaml @@ -0,0 +1,77 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "graphql.fullname" . }}-database-migration + labels: + {{- include "graphql.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": post-install,pre-upgrade + "helm.sh/hook-weight": "-1" + "helm.sh/hook-delete-policy": before-hook-creation + +spec: + template: + spec: + serviceAccountName: {{ include "graphql.serviceAccountName" . }} + {{- with .Values.global.database.gcloud }} + {{- if .enabled }} + initContainers: + - name: wait-for-cloud-sql-proxy + image: busybox:1.36.1 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + until wget -q -T 2 -O /dev/null "http://{{ $.Values.global.database.host }}:9801/startup"; do + echo "waiting for cloud sql proxy to become ready" + sleep 2 + done + {{- end }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + command: ["yarn", "predeploy"] + env: + - name: NODE_ENV + value: "{{ .Values.env }}" + - name: AFFINE_ENV + value: "{{ .Release.Namespace }}" + - name: DEPLOYMENT_TYPE + value: "{{ .Values.global.deployment.type }}" + - name: DEPLOYMENT_PLATFORM + value: "{{ .Values.global.deployment.platform }}" + - 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_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 + volumeMounts: + - name: indexer-config + mountPath: /app/config.json + subPath: config.json + readOnly: true + resources: + requests: + cpu: '100m' + memory: '200Mi' + volumes: + - name: indexer-config + secret: + secretName: indexer + restartPolicy: Never + backoffLimit: 1 diff --git a/.github/helm/affine/charts/graphql/templates/secret.yaml b/.github/helm/affine/charts/graphql/templates/secret.yaml new file mode 100644 index 00000000..338f7f1e --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/secret.yaml @@ -0,0 +1,18 @@ +{{- $privateKey := default (genPrivateKey "ecdsa") .Values.global.secret.privateKey | b64enc | quote }} + +{{- if not .Values.global.secret.privateKey }} +{{- $existingKey := (lookup "v1" "Secret" .Release.Namespace .Values.global.secret.secretName) }} +{{- if $existingKey }} +{{- $privateKey = index $existingKey.data "key" }} +{{- end -}} +{{- end -}} + +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.global.secret.secretName }} + annotations: + "helm.sh/resource-policy": "keep" +type: Opaque +data: + key: {{ $privateKey }} diff --git a/.github/helm/affine/charts/graphql/templates/service.yaml b/.github/helm/affine/charts/graphql/templates/service.yaml new file mode 100644 index 00000000..4c34622c --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "graphql.fullname" . }} + labels: + {{- include "graphql.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "graphql.selectorLabels" . | nindent 4 }} diff --git a/.github/helm/affine/charts/graphql/templates/serviceaccount.yaml b/.github/helm/affine/charts/graphql/templates/serviceaccount.yaml new file mode 100644 index 00000000..14dac586 --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "graphql.serviceAccountName" . }} + labels: + {{- include "graphql.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/.github/helm/affine/charts/graphql/templates/tests/test-connection.yaml b/.github/helm/affine/charts/graphql/templates/tests/test-connection.yaml new file mode 100644 index 00000000..6e46e00d --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "graphql.fullname" . }}-test-connection" + labels: + {{- include "graphql.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "graphql.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/.github/helm/affine/charts/graphql/values.yaml b/.github/helm/affine/charts/graphql/values.yaml new file mode 100644 index 00000000..084489ea --- /dev/null +++ b/.github/helm/affine/charts/graphql/values.yaml @@ -0,0 +1,49 @@ +replicaCount: 1 +image: + repository: ghcr.io/toeverything/affine + pullPolicy: IfNotPresent + tag: '' + +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' +# map to NODE_ENV environment variable +env: 'production' +app: + # AFFINE_SERVER_SUB_PATH + path: '' + # AFFINE_SERVER_HOST + host: '0.0.0.0' + https: true + +serviceAccount: + create: true + annotations: {} + name: 'affine-graphql' + +podAnnotations: {} + +podSecurityContext: + fsGroup: 2000 + +resources: + limits: + cpu: '1' + memory: 4Gi + requests: + cpu: '1' + memory: 2Gi + +probe: + initialDelaySeconds: 20 + timeoutSeconds: 5 + periodSeconds: 10 + failureThreshold: 6 + successThreshold: 1 + liveness: + failureThreshold: 12 + readiness: {} + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/.github/helm/affine/templates/_helpers.tpl b/.github/helm/affine/templates/_helpers.tpl new file mode 100644 index 00000000..c6caa58c --- /dev/null +++ b/.github/helm/affine/templates/_helpers.tpl @@ -0,0 +1,69 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "affine.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 "affine.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 "affine.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "affine.labels" -}} +helm.sh/chart: {{ include "affine.chart" . }} +{{ include "affine.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "affine.selectorLabels" -}} +app.kubernetes.io/name: {{ include "affine.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "affine.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "affine.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Render the indexer runtime configuration shared by the Secret and Pod checksums. +*/}} +{{- define "affine.indexerConfig" -}} +{{- dict "indexer" (dict "enabled" .Values.global.indexer.enabled "provider" (dict "type" .Values.global.indexer.provider "endpoint" .Values.global.indexer.endpoint "apiKey" .Values.global.indexer.apiKey "username" .Values.global.indexer.username "password" .Values.global.indexer.password)) | toJson -}} +{{- end }} diff --git a/.github/helm/affine/templates/backend-config.yaml b/.github/helm/affine/templates/backend-config.yaml new file mode 100644 index 00000000..652b9feb --- /dev/null +++ b/.github/helm/affine/templates/backend-config.yaml @@ -0,0 +1,32 @@ +{{- if eq .Values.global.deployment.platform "gcp" }} +apiVersion: cloud.google.com/v1 +kind: BackendConfig +metadata: + name: affine-api-backendconfig + annotations: + meta.helm.sh/release-name: {{ .Release.Name }} + meta.helm.sh/release-namespace: {{ .Release.Namespace }} + labels: + {{- include "affine.labels" . | nindent 4 }} +spec: + healthCheck: + timeoutSec: 1 + type: HTTP + requestPath: /info +--- +apiVersion: cloud.google.com/v1 +kind: BackendConfig +metadata: + name: {{ .Values.front.services.sync.backendConfig.name }} + annotations: + meta.helm.sh/release-name: {{ .Release.Name }} + meta.helm.sh/release-namespace: {{ .Release.Namespace }} + labels: + {{- include "affine.labels" . | nindent 4 }} +spec: + timeoutSec: {{ .Values.front.services.sync.backendConfig.timeoutSec }} + healthCheck: + timeoutSec: 1 + type: HTTP + requestPath: /info +{{- end }} diff --git a/.github/helm/affine/templates/configmap.yaml b/.github/helm/affine/templates/configmap.yaml new file mode 100644 index 00000000..ce0603f8 --- /dev/null +++ b/.github/helm/affine/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-runtime-config +data: + web-assets-manifest: |- + {{ .Files.Get "web-assets-manifest.json" | nindent 4 }} + mobile-assets-manifest: |- + {{ .Files.Get "mobile-assets-manifest.json" | nindent 4 }} diff --git a/.github/helm/affine/templates/indexer-secret.yaml b/.github/helm/affine/templates/indexer-secret.yaml new file mode 100644 index 00000000..5cbc8722 --- /dev/null +++ b/.github/helm/affine/templates/indexer-secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: indexer + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-2" + "helm.sh/hook-delete-policy": before-hook-creation +type: Opaque +data: + config.json: {{ include "affine.indexerConfig" . | b64enc | quote }} diff --git a/.github/helm/affine/templates/ingress.yaml b/.github/helm/affine/templates/ingress.yaml new file mode 100644 index 00000000..1c5bbcf4 --- /dev/null +++ b/.github/helm/affine/templates/ingress.yaml @@ -0,0 +1,79 @@ +{{- if .Values.global.ingress.enabled -}} +{{- $fullName := include "affine.fullname" . -}} +{{- if and .Values.global.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.global.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.global.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "affine.labels" . | nindent 4 }} + {{- with .Values.global.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.global.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.global.ingress.className }} + {{- end }} + {{- if .Values.global.ingress.tls }} + tls: + {{- range .Values.global.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.global.ingress.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: /socket.io + pathType: Prefix + backend: + service: + name: {{ $.Values.front.services.sync.name }} + port: + number: {{ $.Values.front.services.sync.port }} + - path: /graphql + pathType: Prefix + backend: + service: + name: affine-graphql + port: + number: {{ $.Values.graphql.service.port }} + - path: /api + pathType: Prefix + backend: + service: + name: affine-graphql + port: + number: {{ $.Values.graphql.service.port }} + - path: /workspace + pathType: Prefix + backend: + service: + name: {{ $.Values.front.services.renderer.name }} + port: + number: {{ $.Values.front.services.renderer.port }} + - path: / + pathType: Prefix + backend: + service: + name: {{ $.Values.front.services.web.name }} + port: + number: {{ $.Values.front.services.web.port }} + {{- end }} +{{- end }} diff --git a/.github/helm/affine/templates/monitoring.yaml b/.github/helm/affine/templates/monitoring.yaml new file mode 100644 index 00000000..661661f6 --- /dev/null +++ b/.github/helm/affine/templates/monitoring.yaml @@ -0,0 +1,13 @@ +{{- if eq .Values.global.deployment.platform "gcp" -}} +apiVersion: monitoring.googleapis.com/v1 +kind: PodMonitoring +metadata: + name: "{{ .Release.Name }}-monitoring" +spec: + selector: + matchLabels: + app.kubernetes.io/instance: {{ .Release.Name }} + endpoints: + - port: 9464 + interval: 30s +{{- end }} diff --git a/.github/helm/affine/templates/pg-secret.yaml b/.github/helm/affine/templates/pg-secret.yaml new file mode 100644 index 00000000..a52395d2 --- /dev/null +++ b/.github/helm/affine/templates/pg-secret.yaml @@ -0,0 +1,9 @@ +{{- if .Values.global.database.password -}} +apiVersion: v1 +kind: Secret +metadata: + name: pg-postgresql +type: Opaque +data: + postgres-password: {{ .Values.global.database.password | b64enc }} +{{- end }} diff --git a/.github/helm/affine/templates/redis-secret.yaml b/.github/helm/affine/templates/redis-secret.yaml new file mode 100644 index 00000000..eebcbc41 --- /dev/null +++ b/.github/helm/affine/templates/redis-secret.yaml @@ -0,0 +1,9 @@ +{{- if .Values.global.redis.password -}} +apiVersion: v1 +kind: Secret +metadata: + name: redis +type: Opaque +data: + redis-password: {{ .Values.global.redis.password | b64enc }} +{{- end }} diff --git a/.github/helm/affine/templates/worker-deployment.yaml b/.github/helm/affine/templates/worker-deployment.yaml new file mode 100644 index 00000000..77f35d44 --- /dev/null +++ b/.github/helm/affine/templates/worker-deployment.yaml @@ -0,0 +1,149 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: affine-worker + labels: + app.kubernetes.io/name: affine-worker + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + monitoring: enabled +spec: + replicas: {{ .Values.worker.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: affine-worker + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + annotations: + checksum/indexer-config: {{ include "affine.indexerConfig" . | sha256sum }} + {{- with .Values.worker.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app.kubernetes.io/name: affine-worker + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + serviceAccountName: {{ .Values.worker.serviceAccount.name }} + securityContext: + {{- toYaml .Values.worker.podSecurityContext | nindent 8 }} + {{- with .Values.global.database.gcloud }} + {{- if .enabled }} + initContainers: + - name: wait-for-cloud-sql-proxy + image: busybox:1.36.1 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + until wget -q -T 2 -O /dev/null "http://{{ $.Values.global.database.host }}:9801/startup"; do + echo "waiting for cloud sql proxy to become ready" + sleep 2 + done + {{- end }} + {{- end }} + containers: + - name: worker + image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.graphql.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.worker.securityContext | nindent 12 }} + env: + - name: AFFINE_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: "{{ .Values.global.secret.secretName }}" + key: key + - name: NODE_ENV + value: "{{ .Values.worker.env }}" + - name: NODE_OPTIONS + value: "{{ .Values.worker.nodeOptions }}" + - 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: "worker" + - 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_SERVER_PORT + value: "{{ .Values.worker.app.port }}" + - name: AFFINE_SERVER_HOST + value: "{{ .Values.worker.app.host }}" + - name: AFFINE_SERVER_HTTPS + value: "{{ .Values.worker.app.https }}" + ports: + - name: http + containerPort: {{ .Values.worker.app.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /info + port: http + initialDelaySeconds: {{ default .Values.worker.probe.initialDelaySeconds .Values.worker.probe.liveness.initialDelaySeconds }} + timeoutSeconds: {{ default .Values.worker.probe.timeoutSeconds .Values.worker.probe.liveness.timeoutSeconds }} + periodSeconds: {{ default .Values.worker.probe.periodSeconds .Values.worker.probe.liveness.periodSeconds }} + failureThreshold: {{ default .Values.worker.probe.failureThreshold .Values.worker.probe.liveness.failureThreshold }} + successThreshold: {{ default .Values.worker.probe.successThreshold .Values.worker.probe.liveness.successThreshold }} + readinessProbe: + httpGet: + path: /info + port: http + initialDelaySeconds: {{ default .Values.worker.probe.initialDelaySeconds .Values.worker.probe.readiness.initialDelaySeconds }} + timeoutSeconds: {{ default .Values.worker.probe.timeoutSeconds .Values.worker.probe.readiness.timeoutSeconds }} + periodSeconds: {{ default .Values.worker.probe.periodSeconds .Values.worker.probe.readiness.periodSeconds }} + failureThreshold: {{ default .Values.worker.probe.failureThreshold .Values.worker.probe.readiness.failureThreshold }} + successThreshold: {{ default .Values.worker.probe.successThreshold .Values.worker.probe.readiness.successThreshold }} + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + volumeMounts: + - name: indexer-config + mountPath: /app/config.json + subPath: config.json + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: indexer-config + secret: + secretName: indexer + - name: tmp + emptyDir: {} + {{- with .Values.worker.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/.github/helm/affine/templates/worker-serviceaccount.yaml b/.github/helm/affine/templates/worker-serviceaccount.yaml new file mode 100644 index 00000000..05c8447a --- /dev/null +++ b/.github/helm/affine/templates/worker-serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.worker.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.worker.serviceAccount.name }} + labels: + app.kubernetes.io/name: affine-worker + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- with .Values.worker.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/.github/helm/affine/values.yaml b/.github/helm/affine/values.yaml new file mode 100644 index 00000000..b7c5e768 --- /dev/null +++ b/.github/helm/affine/values.yaml @@ -0,0 +1,117 @@ +global: + app: + buildType: 'stable' + ingress: + enabled: false + className: '' + # hosts for ingress rules + # e.g. + # hosts: + # - affine.pro + # - www.affine.pro + hosts: + - affine.pro + tls: [] + secret: + secretName: 'server-private-key' + privateKey: '' + # Name of an externally managed Secret containing the license signing key. + # The chart never generates this key. Leave empty to disable license issuance. + licenseSecretName: '' + licensePrivateKeyKey: 'license-private-key' + database: + user: 'postgres' + host: 'pg-postgresql' + port: '5432' + name: 'affine' + password: '' + redis: + host: 'redis-master' + port: '6379' + username: '' + password: '' + database: 0 + indexer: + enabled: false + provider: embedded + endpoint: '' + apiKey: '' + username: '' + password: '' + deployment: + # change to 'selfhosted' and 'unknown' if this chart is ready to be used for selfhosted deployment + type: 'affine' + platform: 'gcp' + +graphql: + service: + type: ClusterIP + port: 3000 + annotations: + cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}' + +front: + services: + sync: + name: affine-sync + type: ClusterIP + port: 3010 + backendConfig: + name: affine-sync-backendconfig + timeoutSec: 10800 + annotations: {} + renderer: + name: affine-renderer + type: ClusterIP + port: 3000 + annotations: + cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}' + web: + name: affine-web + type: ClusterIP + port: 8080 + +worker: + replicaCount: 1 + image: + repository: ghcr.io/toeverything/affine + tag: '' + env: 'production' + nodeOptions: '--max-old-space-size=3072' + app: + port: 3002 + host: '0.0.0.0' + https: true + podAnnotations: {} + podSecurityContext: + fsGroup: 2000 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + serviceAccount: + create: true + annotations: {} + name: 'affine-worker' + resources: + limits: + cpu: '1' + memory: 4Gi + requests: + cpu: '1' + memory: 2Gi + probe: + initialDelaySeconds: 20 + timeoutSeconds: 5 + periodSeconds: 10 + failureThreshold: 6 + successThreshold: 1 + liveness: + initialDelaySeconds: 60 + failureThreshold: 12 + readiness: {} + nodeSelector: {} + tolerations: [] + affinity: {} diff --git a/.github/helm/releaser.yaml b/.github/helm/releaser.yaml new file mode 100644 index 00000000..e95657e9 --- /dev/null +++ b/.github/helm/releaser.yaml @@ -0,0 +1,2 @@ +owner: toeverything +git-repo: helm-charts diff --git a/.github/helm/separate-config/Dockerfile_pgvector b/.github/helm/separate-config/Dockerfile_pgvector new file mode 100644 index 00000000..1999273a --- /dev/null +++ b/.github/helm/separate-config/Dockerfile_pgvector @@ -0,0 +1,6 @@ +FROM pgvector/pgvector:pg15 AS builder + +FROM bitnami/postgresql:15 + +COPY --from=builder /usr/lib/postgresql/15/lib/vector.so /opt/bitnami/postgresql/lib/ +COPY --from=builder /usr/share/postgresql/15/extension/vector* /opt/bitnami/postgresql/share/extension/ \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..562fcb22 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,85 @@ +docs: + - changed-files: + - any-glob-to-any-file: + - 'docs/**/*' + - '**/README.md' + - 'packages/frontend/templates/**/*' + +test: + - changed-files: + - any-glob-to-any-file: + - 'tests/**/*' + - '**/tests/**/*' + - '**/__tests__/**/*' + +mod:dev: + - changed-files: + - any-glob-to-any-file: + - 'scripts/**/*' + - 'tools/cli/**/*' + - 'packages/common/debug/**/*' + +mod:infra: + - changed-files: + - any-glob-to-any-file: + - 'packages/common/infra/**/*' + +mod:plugin-cli: + - changed-files: + - any-glob-to-any-file: + - 'tools/plugin-cli/**/*' + +mod:i18n: + - changed-files: + - any-glob-to-any-file: + - 'packages/frontend/i18n/**/*' + +mod:env: + - changed-files: + - any-glob-to-any-file: + - 'packages/common/env/**/*' + +mod:component: + - changed-files: + - any-glob-to-any-file: + - 'packages/frontend/component/**/*' + +mod:server-native: + - changed-files: + - any-glob-to-any-file: + - 'packages/backend/native/**/*' + +mod:native: + - changed-files: + - any-glob-to-any-file: + - 'packages/frontend/native/**/*' + +mod:store: + - changed-files: + - any-glob-to-any-file: + - '**/atoms/**/*' + +rust: + - changed-files: + - any-glob-to-any-file: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '**/rust-toolchain' + - '**/rust-toolchain.toml' + - '**/rustfmt.toml' + +app:core: + - changed-files: + - any-glob-to-any-file: + - 'packages/frontend/core/**/*' + +app:electron: + - changed-files: + - any-glob-to-any-file: + - 'packages/frontend/apps/electron/**/*' + +app:server: + - changed-files: + - any-glob-to-any-file: + - 'packages/backend/server/**/*' diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 00000000..328d3736 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":disablePeerDependencies"], + "labels": ["dependencies"], + "ignorePaths": [ + "**/node_modules/**", + "**/bower_components/**", + "**/vendor/**", + "**/examples/**", + "**/__tests__/**" + ], + "packageRules": [ + { + "rangeStrategy": "replace", + "groupName": "linter", + "matchPackageNames": ["/^eslint-plugin/"] + }, + { + "matchDepNames": ["oxlint"], + "rangeStrategy": "replace", + "groupName": "oxlint" + }, + { + "groupName": "all non-major rust dependencies", + "groupSlug": "all-minor-patch", + "matchUpdateTypes": ["minor", "patch"], + "matchManagers": ["cargo"] + }, + { + "groupName": "all non-major npm dependencies", + "groupSlug": "all-minor-patch", + "matchUpdateTypes": ["minor", "patch"], + "matchManagers": ["npm"], + "excludePackagePatterns": ["^@blocksuite/", "^oxlint$"] + }, + { + "groupName": "all non-major dependencies", + "groupSlug": "all-minor-patch", + "matchUpdateTypes": ["minor", "patch"], + "matchManagers": [ + "dockerfile", + "github-actions", + "helmv3", + "helm-values", + "gradle-wrapper", + "gradle", + "docker-compose", + "devcontainer", + "cocoapods", + "bundler" + ] + }, + { + "groupName": "rust toolchain", + "matchManagers": ["custom.regex"], + "matchDepNames": ["rustc"] + }, + { + "groupName": "nestjs", + "matchPackageNames": ["/^@nestjs/"] + }, + { + "groupName": "opentelemetry", + "matchPackageNames": [ + "/^@opentelemetry/", + "/^@google-cloud/opentelemetry-/" + ] + } + ], + "commitMessagePrefix": "chore: ", + "commitMessageAction": "bump up", + "commitMessageTopic": "{{depName}} version", + "ignoreDeps": [], + "postUpdateOptions": ["yarnDedupeHighest"], + "lockFileMaintenance": { + "enabled": true, + "extends": ["schedule:weekly"] + }, + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^rust-toolchain\\.toml?$/"], + "matchStrings": [ + "channel\\s*=\\s*\"(?\\d+\\.\\d+(\\.\\d+)?)\"" + ], + "depNameTemplate": "rustc", + "packageNameTemplate": "rust-lang/rust", + "datasourceTemplate": "github-releases" + } + ] +} diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml new file mode 100644 index 00000000..7e337056 --- /dev/null +++ b/.github/workflows/auto-labeler.yml @@ -0,0 +1,17 @@ +name: 'Pull Request Labeler' +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/labeler@v7 diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml new file mode 100644 index 00000000..e78cddca --- /dev/null +++ b/.github/workflows/build-images.yml @@ -0,0 +1,272 @@ +name: Build Images + +on: + workflow_call: + inputs: + build-type: + type: string + required: true + app-version: + type: string + required: true + git-short-hash: + type: string + required: true + +permissions: + contents: 'write' + id-token: 'write' + packages: 'write' + +jobs: + build-web: + name: Build @affine/web + runs-on: ubuntu-latest + environment: ${{ inputs.build-type }} + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Build Core + run: yarn affine @affine/web build + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + BUILD_TYPE: ${{ inputs.build-type }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine-web' + SENTRY_RELEASE: ${{ inputs.app-version }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} + - name: Upload web artifact + uses: actions/upload-artifact@v4 + with: + name: web + path: ./packages/frontend/apps/web/dist + if-no-files-found: error + + build-admin: + name: Build @affine/admin + runs-on: ubuntu-latest + environment: ${{ inputs.build-type }} + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Build Admin + run: yarn affine @affine/admin build + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + BUILD_TYPE: ${{ inputs.build-type }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine-admin' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} + - name: Upload admin artifact + uses: actions/upload-artifact@v4 + with: + name: admin + path: ./packages/frontend/admin/dist + if-no-files-found: error + + build-mobile: + name: Build @affine/mobile + runs-on: ubuntu-latest + environment: ${{ inputs.build-type }} + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Build Mobile + run: yarn affine @affine/mobile build + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + BUILD_TYPE: ${{ inputs.build-type }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine-mobile' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} + - name: Upload mobile artifact + uses: actions/upload-artifact@v4 + with: + name: mobile + path: ./packages/frontend/apps/mobile/dist + if-no-files-found: error + + build-server-native: + name: Build Server native - ${{ matrix.targets.name }} + runs-on: ubuntu-22.04 + environment: ${{ inputs.build-type }} + strategy: + fail-fast: false + matrix: + targets: + - name: x86_64-unknown-linux-gnu + file: server-native.x64.node + - name: aarch64-unknown-linux-gnu + file: server-native.arm64.node + - name: armv7-unknown-linux-gnueabihf + file: server-native.armv7.node + + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + electron-install: false + extra-flags: workspaces focus @affine/server-native + - name: Build Rust + uses: ./.github/actions/build-rust + env: + AFFINE_PRO_PUBLIC_KEY: ${{ secrets.AFFINE_PRO_PUBLIC_KEY }} + AFFINE_PRO_LICENSE_AES_KEY: ${{ secrets.AFFINE_PRO_LICENSE_AES_KEY }} + with: + target: ${{ matrix.targets.name }} + package: '@affine/server-native' + - name: Rename ${{ matrix.targets.file }} + run: | + mv ./packages/backend/native/server-native.node ./packages/backend/native/${{ matrix.targets.file }} + - name: Upload ${{ matrix.targets.file }} + uses: actions/upload-artifact@v4 + with: + name: server-native-${{ matrix.targets.file }} + path: ./packages/backend/native/${{ matrix.targets.file }} + if-no-files-found: error + + build-server: + name: Build Server + runs-on: ubuntu-latest + needs: + - build-server-native + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + electron-install: false + extra-flags: workspaces focus @affine/server @types/affine__env + - name: Download server-native + uses: actions/download-artifact@v4 + with: + pattern: server-native-* + merge-multiple: true + path: ./packages/backend/native + - name: List server-native files + run: ls -alh ./packages/backend/native + - name: Build Server + run: yarn workspace @affine/server build + - name: Upload server dist + uses: actions/upload-artifact@v4 + with: + name: server-dist + path: ./packages/backend/server/dist + if-no-files-found: error + + build-images: + name: Build Images + runs-on: ubuntu-latest + needs: + - build-server + - build-web + - build-mobile + - build-admin + steps: + - uses: actions/checkout@v7 + - name: Download server dist + uses: actions/download-artifact@v4 + with: + name: server-dist + path: ./packages/backend/server/dist + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + logout: false + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # setup node without cache configuration + # Prisma cache is not compatible with docker build cache + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + registry-url: https://npm.pkg.github.com + scope: '@toeverything' + + - name: Download web artifact + uses: actions/download-artifact@v4 + with: + name: web + path: ./packages/frontend/apps/web/dist + + - name: Download mobile artifact + uses: actions/download-artifact@v4 + with: + name: mobile + path: ./packages/frontend/apps/mobile/dist + + - name: Download admin artifact + uses: actions/download-artifact@v4 + with: + name: admin + path: ./packages/frontend/admin/dist + + - name: Install Node.js dependencies + run: | + yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]' + yarn config set --json supportedArchitectures.libc '["glibc"]' + yarn workspaces focus @affine/server --production + + - name: Generate Prisma client + run: yarn workspace @affine/server prisma generate + + - name: Mv node_modules + run: mv ./node_modules ./packages/backend/server + + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + + - name: Build backend Dockerfile + uses: docker/build-push-action@v6 + with: + context: . + push: true + pull: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + provenance: true + file: .github/deployment/node/Dockerfile + tags: ghcr.io/toeverything/affine:${{inputs.build-type}}-${{ inputs.git-short-hash }} diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 00000000..4c307de8 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,1716 @@ +name: Build & Test + +on: + push: + branches: + - canary + - beta + - stable + - v[0-9]+.[0-9]+.x-staging + - v[0-9]+.[0-9]+.x + paths-ignore: + - README.md + pull_request: + merge_group: + +env: + DEBUG: napi:* + BUILD_TYPE: canary + APP_NAME: affine + AFFINE_ENV: dev + COVERAGE: true + MACOSX_DEPLOYMENT_TARGET: '11.6' + DEPLOYMENT_TYPE: affine + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + env: + NODE_OPTIONS: --max-old-space-size=14384 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ['javascript', 'typescript'] + project: ['affine', 'blocksuite'] + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + source-root: ${{ matrix.project == 'affine' && '.' || 'blocksuite' }} + + - name: Delete blocksuite before codeql analysis + if: ${{ matrix.project == 'affine' }} + run: rm -rf blocksuite + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + lint: + name: Lint + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + - name: Setup Go (for actionlint) + uses: actions/setup-go@v7 + with: + go-version: 'stable' + - name: Install actionlint + shell: bash + run: | + set -euo pipefail + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 + - name: Run actionlint + shell: bash + run: | + set -euo pipefail + "$(go env GOPATH)/bin/actionlint" + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + electron-install: false + full-cache: true + - name: Run oxlint + run: yarn lint:ox + - name: Run i18n codegen + run: yarn affine @affine/i18n build + - name: Run Oxfmt + # Set nmMode in `actions/setup-node` will modify the .yarnrc.yml + run: | + git checkout .yarnrc.yml + yarn lint:format + - name: Yarn Dedupe + run: yarn dedupe --check + + typecheck: + name: Typecheck + runs-on: ubuntu-24.04-arm + env: + NODE_OPTIONS: --max-old-space-size=14384 + steps: + - uses: actions/checkout@v7 + - 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 + git checkout packages/frontend/i18n/src/i18n-completenesses.json + if git status --porcelain | grep -q .; then + echo "Run 'yarn affine @affine/i18n build' and make sure all generated i18n changes are submitted" + exit 1 + else + echo "All generated i18n changes are submitted" + fi + - name: Run Type Check + run: yarn typecheck + - name: Run BS Docs Build + run: | + yarn affine bs-docs build + if git status --porcelain | grep -q .; then + echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted" + exit 1 + else + echo "All changes are submitted" + fi + + mobile-native-build-filter: + name: Mobile native build filter + runs-on: ubuntu-latest + outputs: + run-android: ${{ steps.mobile-native-filter.outputs.android }} + run-ios: ${{ steps.mobile-native-filter.outputs.ios }} + steps: + - uses: actions/checkout@v7 + + - uses: dorny/paths-filter@v3 + id: mobile-native-filter + with: + filters: | + android: + - '.github/workflows/build-test.yml' + - 'packages/frontend/apps/android/**' + - 'packages/frontend/mobile-native/**' + - '.cargo/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'rust-toolchain*' + ios: + - '.github/workflows/build-test.yml' + - 'packages/frontend/apps/ios/**' + - 'packages/frontend/mobile-native/**' + - '.cargo/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'rust-toolchain*' + + build-android-app: + name: Build Android app + if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }} + runs-on: ubuntu-latest + needs: + - mobile-native-build-filter + env: + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android + electron-install: false + + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'aarch64-linux-android' + package: 'affine_mobile_native' + no-build: 'true' + + - name: Build Android web assets + run: yarn affine @affine/android build + env: + PUBLIC_PATH: '/' + + - name: Write CI Firebase config + run: | + cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON' + { + "project_info": { + "project_number": "1", + "project_id": "affine-ci", + "storage_bucket": "affine-ci.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1:android:0000000000000000", + "android_client_info": { + "package_name": "app.affine.pro" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "ci-placeholder" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + JSON + + - name: Cap sync + run: yarn workspace @affine/android cap sync + + - name: Build Android debug app + working-directory: packages/frontend/apps/android/App + run: ./gradlew :app:assembleCanaryDebug --no-daemon --stacktrace + + test-android-app: + name: Test Android app + if: ${{ needs.mobile-native-build-filter.outputs.run-android == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 40 + needs: + - mobile-native-build-filter + env: + AFFINE_ANDROID_NATIVE_TARGET: x86_64 + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android + electron-install: false + + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'x86_64-linux-android' + package: 'affine_mobile_native' + no-build: 'true' + + - name: Build Android web assets + run: yarn affine @affine/android build + env: + PUBLIC_PATH: '/' + + - name: Write CI Firebase config + run: | + cat > packages/frontend/apps/android/App/app/google-services.json <<'JSON' + { + "project_info": { + "project_number": "1", + "project_id": "affine-ci", + "storage_bucket": "affine-ci.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1:android:0000000000000000", + "android_client_info": { + "package_name": "app.affine.pro" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "ci-placeholder" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + JSON + + - name: Cap sync + run: yarn workspace @affine/android cap sync + + - name: Run Android unit tests + working-directory: packages/frontend/apps/android/App + run: ./gradlew :app:testCanaryDebugUnitTest --no-daemon --stacktrace + + - name: Enable KVM + run: sudo chmod 666 /dev/kvm + + - name: Run Android instrumentation tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_6 + disable-animations: true + script: cd packages/frontend/apps/android/App && ./gradlew :app:connectedCanaryDebugAndroidTest --no-daemon --stacktrace + + build-ios-app: + name: Build iOS app + if: ${{ needs.mobile-native-build-filter.outputs.run-ios == 'true' }} + runs-on: macos-15 + needs: + - mobile-native-build-filter + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/ios + electron-install: false + hard-link-nm: false + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 26.2 + + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'aarch64-apple-ios-sim' + package: 'affine_mobile_native' + no-build: 'true' + + - name: Build iOS web assets + run: yarn affine @affine/ios build + env: + PUBLIC_PATH: '/' + + - name: Cap sync + run: yarn workspace @affine/ios sync + + - name: Build iOS simulator app + run: | + xcodebuild \ + -workspace packages/frontend/apps/ios/App/App.xcworkspace \ + -scheme App \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + + - name: Run iOS unit tests + run: | + device_id="$(xcrun simctl list devices available -j | ruby -rjson -e ' + devices = JSON.parse(STDIN.read).fetch("devices").values.flatten + device = devices.find { |candidate| candidate.fetch("name").start_with?("iPhone") } + abort "No available iPhone simulator" unless device + puts device.fetch("udid") + ')" + xcrun simctl boot "$device_id" || true + xcodebuild \ + -project packages/frontend/apps/ios/App/App.xcodeproj \ + -scheme AFFiNETests \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination "platform=iOS Simulator,id=$device_id" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + test + + rust-test-filter: + name: Rust test filter + runs-on: ubuntu-latest + outputs: + run-rust: ${{ steps.rust-filter.outputs.rust }} + steps: + - uses: actions/checkout@v7 + + - uses: dorny/paths-filter@v3 + id: rust-filter + with: + filters: | + rust: + - '**/*.rs' + - '**/Cargo.toml' + - '**/Cargo.lock' + - '.cargo/**' + - 'rust-toolchain*' + - '.github/actions/build-rust/**' + - '.github/workflows/build-test.yml' + + lint-rust: + name: Lint Rust + if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }} + runs-on: ubuntu-latest + needs: + - rust-test-filter + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/build-rust + with: + target: x86_64-unknown-linux-gnu + package: 'affine' + no-build: 'true' + - name: fmt check + run: | + rustup toolchain add nightly + rustup component add --toolchain nightly-x86_64-unknown-linux-gnu rustfmt + cargo +nightly fmt --all -- --check + - name: Clippy + run: | + rustup component add clippy + cargo clippy --workspace --exclude affine_server_native --all-targets --all-features -- -D warnings + cargo clippy -p affine_server_native --all-targets --all-features -- -D warnings + + check-git-status: + name: Check Git Status + runs-on: ubuntu-latest + needs: + - build-server-native + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Run Check + run: | + yarn affine init + yarn affine gql build + yarn affine i18n build + yarn affine server genconfig + git checkout packages/frontend/i18n/src/i18n-completenesses.json + if git status --porcelain | grep -q .; then + echo "Run 'yarn affine init && yarn affine gql build && yarn affine i18n build && yarn affine server genconfig' and make sure all changes are submitted" + exit 1 + else + echo "All changes are submitted" + fi + + check-yarn-binary: + name: Check yarn binary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Run check + run: | + set -euo pipefail + yarn_version="$(node -e "console.log(require('./package.json').packageManager.split('@')[1])")" + yarn set version "$yarn_version" + git diff --exit-code + + e2e-blocksuite-test: + name: E2E BlockSuite Test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5] + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test + playwright-install: true + playwright-platform: 'chromium' + electron-install: false + full-cache: true + + - name: Run playground build + run: yarn workspace @blocksuite/playground build + + - name: Run playwright tests + run: yarn workspace @affine-test/blocksuite test --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-bs-${{ matrix.shard }} + path: ./test-results + if-no-files-found: ignore + + e2e-blocksuite-cross-browser-test: + name: E2E BlockSuite Cross Browser Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test + playwright-install: true + playwright-platform: 'chromium,firefox,webkit' + electron-install: false + full-cache: true + + - name: Run playground build + run: yarn workspace @blocksuite/playground build + + - name: Run integration browser tests + timeout-minutes: 10 + run: yarn workspace @blocksuite/integration-test test:unit + + - name: Run cross-platform playwright tests + timeout-minutes: 10 + run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-bs-cross-browser + path: ./test-results + if-no-files-found: ignore + + e2e-test: + name: E2E Test + runs-on: ubuntu-24.04-arm + env: + DISTRIBUTION: web + IN_CI_TEST: true + NODE_OPTIONS: --max-old-space-size=14384 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5] + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-test/affine-local @affine/web @affine/server + playwright-install: true + playwright-platform: 'chromium' + electron-install: false + full-cache: true + + - name: Run playwright tests + run: yarn affine @affine-test/affine-local e2e --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-${{ matrix.shard }} + path: ./test-results + if-no-files-found: ignore + + e2e-mobile-test: + name: E2E Mobile Test + runs-on: ubuntu-latest + env: + DISTRIBUTION: mobile + IN_CI_TEST: true + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-test/affine-mobile @affine/mobile + playwright-install: true + electron-install: false + full-cache: true + + - name: Run playwright tests + run: yarn affine @affine-test/affine-mobile e2e --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-mobile-${{ matrix.shard }} + path: ./test-results + if-no-files-found: ignore + + unit-test: + name: Unit Test + runs-on: ubuntu-latest + needs: + - build-native-linux + env: + DISTRIBUTION: web + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5] + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + electron-install: true + playwright-install: true + playwright-platform: 'chromium,firefox,webkit' + full-cache: true + + - name: Download affine.linux-x64-gnu.node + uses: actions/download-artifact@v4 + with: + name: affine.linux-x64-gnu.node + path: ./packages/frontend/native + + - name: Unit Test + run: yarn test:coverage --shard=${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Upload unit test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./.coverage/store/lcov.info + flags: unittest + name: affine + fail_ci_if_error: false + + build-native-linux: + name: Build AFFiNE native (x86_64-unknown-linux-gnu) + runs-on: ubuntu-latest + env: + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + steps: + - uses: actions/checkout@v7 + - 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: | + PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")" + echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT" + - name: Build AFFiNE native + uses: ./.github/actions/build-rust + with: + target: x86_64-unknown-linux-gnu + package: '@affine/native' + - name: Upload ${{ steps.filename.outputs.filename }} + uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ steps.filename.outputs.filename }} + path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }} + if-no-files-found: error + + build-native-macos: + name: Build AFFiNE native (${{ matrix.spec.target }}) + runs-on: ${{ matrix.spec.os }} + env: + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + strategy: + fail-fast: false + matrix: + spec: + - { os: macos-latest, target: x86_64-apple-darwin } + - { os: macos-latest, target: aarch64-apple-darwin } + + steps: + - uses: actions/checkout@v7 + - 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: | + 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: Build AFFiNE native + uses: ./.github/actions/build-rust + with: + target: ${{ matrix.spec.target }} + 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 + + # Split Windows build because it's too slow + # and other ci jobs required linux native + build-native-windows: + name: Build AFFiNE native (${{ matrix.spec.target }}) + runs-on: ${{ matrix.spec.os }} + env: + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + strategy: + fail-fast: false + matrix: + spec: + - { os: windows-latest, target: x86_64-pc-windows-msvc } + - { os: windows-latest, target: aarch64-pc-windows-msvc } + + steps: + - uses: actions/checkout@v7 + - uses: samypr100/setup-dev-drive@v3 + with: + workspace-copy: true + drive-size: 8GB + drive-format: NTFS + env-mapping: | + CARGO_HOME,{{ DEV_DRIVE }}/.cargo + RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup + - 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: ${{ env.DEV_DRIVE_WORKSPACE }} + shell: bash + run: | + 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: Build AFFiNE native + uses: ./.github/actions/build-rust + with: + target: ${{ matrix.spec.target }} + package: '@affine/native' + - name: Upload ${{ steps.filename.outputs.filename }} + uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ steps.filename.outputs.filename }} + path: ${{ env.DEV_DRIVE_WORKSPACE }}/packages/frontend/native/${{ steps.filename.outputs.filename }} + if-no-files-found: error + + build-server-native: + name: Build Server native + runs-on: ubuntu-latest + env: + AFFINE_PRO_PUBLIC_KEY: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEObwJiTmbui7rkWfPJ7Lozvuy2Rcl + otcrb0V6dlS2ijKEShm7ZttTwQn08xzesdjX/AxpoR5X9yfoHkauIBuuMQ== + -----END PUBLIC KEY----- + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/server-native + electron-install: false + - name: Build Rust + uses: ./.github/actions/build-rust + with: + target: 'x86_64-unknown-linux-gnu' + package: '@affine/server-native' + - name: Upload server-native.node + uses: actions/upload-artifact@v4 + if: always() + with: + name: server-native.node + path: ./packages/backend/native/server-native.node + if-no-files-found: error + + build-electron-renderer: + name: Build @affine/electron renderer + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/electron-renderer @affine/nbstore @toeverything/infra + electron-install: false + full-cache: true + - name: Build Electron renderer + run: yarn affine @affine/electron-renderer build + env: + DISTRIBUTION: desktop + - name: zip web + run: tar -czf dist.tar.gz --directory=packages/frontend/apps/electron-renderer/dist . + - name: Upload web artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: web + path: dist.tar.gz + if-no-files-found: error + + native-unit-test: + name: Native Unit Test + runs-on: ubuntu-latest + needs: + - build-native-linux + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine-tools/cli @affine/monorepo @affine/native + electron-install: false + - name: Download affine.linux-x64-gnu.node + uses: actions/download-artifact@v4 + with: + name: affine.linux-x64-gnu.node + path: ./packages/frontend/native + - name: Unit Test + run: yarn affine @affine/native test + + server-test: + name: Server Test + runs-on: ubuntu-latest + needs: + - build-server-native + strategy: + fail-fast: false + matrix: + node_index: [0, 1, 2, 3] + total_nodes: [4] + env: + NODE_ENV: test + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env + + - name: Run server tests + run: yarn affine @affine/server test:coverage --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + CI_NODE_INDEX: ${{ matrix.node_index }} + CI_NODE_TOTAL: ${{ matrix.total_nodes }} + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + server-test-elasticsearch: + name: Server Test with Elasticsearch + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + # https://github.com/elastic/elastic-github-actions/blob/master/elasticsearch/README.md + - name: Configure sysctl limits for Elasticsearch + run: | + sudo swapoff -a + sudo sysctl -w vm.swappiness=1 + sudo sysctl -w fs.file-max=262144 + sudo sysctl -w vm.max_map_count=262144 + + - name: Runs Elasticsearch + uses: elastic/elastic-github-actions/elasticsearch@master + with: + stack-version: 9.0.1 + security-enabled: false + + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + with: + indexer-provider: elasticsearch + uses: ./.github/actions/server-test-env + + - name: Run server tests with elasticsearch only + run: yarn affine @affine/server e2e:coverage src/__tests__/e2e/indexer/search-docs.spec.ts --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + server-test-embedded: + name: Server Test with Embedded Search + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + with: + indexer-provider: embedded + uses: ./.github/actions/server-test-env + + - name: Run embedded Search provider E2E + run: >- + yarn affine @affine/server e2e:coverage + src/__tests__/e2e/indexer/aggregate.spec.ts + src/__tests__/e2e/indexer/search-docs.spec.ts + src/__tests__/e2e/indexer/search.spec.ts + --concurrency 1 + --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + server-test-manticoresearch: + name: Server Test with Manticore Search + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + manticore: + image: manticoresearch/manticore:29.0.2 + env: + 'searchd.listen': 'http:9308' + 'searchd.network_timeout': '30' + ports: + - 9308:9308 + options: >- + --health-cmd "wget -qO- --post-data='SHOW TABLES' 'http://127.0.0.1:9308/sql?mode=raw' >/dev/null" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + with: + indexer-provider: manticoresearch + uses: ./.github/actions/server-test-env + + - name: Run Manticore Search provider E2E + run: yarn affine @affine/server e2e:coverage src/__tests__/e2e/indexer/manticore-provider.spec.ts --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + server-e2e-test: + # the new version of server e2e test should be super fast, so sharding testing is not needed + name: Server E2E Test + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + with: + indexer-provider: disabled + uses: ./.github/actions/server-test-env + + - name: Run server tests + run: yarn affine @affine/server e2e:coverage --forbid-only + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + rust-test: + name: Run native tests + if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }} + runs-on: ubuntu-latest + needs: + - rust-test-filter + env: + CARGO_TERM_COLOR: always + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + INVALIDATION_REDIS_URL: redis://localhost:6379/ + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: x86_64-unknown-linux-gnu + package: 'affine' + no-build: 'true' + + - name: Install latest nextest release + uses: taiki-e/install-action@v2 + with: + tool: nextest@0.9.98 + + - name: Initialize server-native test database + run: | + psql -h localhost -U postgres -c "CREATE DATABASE affine;" + psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';" + psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;" + yarn affine @affine/server prisma migrate deploy + env: + PGPASSWORD: affine + + - name: Run workspace Rust tests + run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast + + - name: Run server-native Rust tests with PostgreSQL and Redis + run: cargo nextest run -p affine_server_native --release --no-fail-fast --test-threads=1 + + - name: Run native storage cleanup integration tests + run: cargo test -p affine_server_native --release blob_cleanup_projection_ -- --ignored --nocapture + + 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@v7 + + - uses: dorny/paths-filter@v3 + id: copilot-filter + with: + filters: | + api: + - 'packages/backend/server/src/plugins/copilot/**' + - 'packages/backend/server/tests/copilot.*' + e2e: + - 'packages/backend/server/src/plugins/copilot/**' + - 'packages/backend/server/tests/copilot.*' + - 'packages/frontend/core/src/blocksuite/ai/**' + - 'packages/frontend/core/src/modules/workspace-indexer-embedding/**' + - 'tests/affine-cloud-copilot/**' + + - name: Decide test scope + id: decision + run: | + if [[ "${{ steps.copilot-filter.outputs.api }}" == "true" ]]; then + echo "run_api=true" >> "$GITHUB_OUTPUT" + else + echo "run_api=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "${{ steps.copilot-filter.outputs.e2e }}" == "true" ]]; then + echo "run_e2e=true" >> "$GITHUB_OUTPUT" + else + echo "run_e2e=false" >> "$GITHUB_OUTPUT" + fi + + copilot-api-test: + name: Server Copilot Api Test + if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }} + runs-on: ubuntu-latest + needs: + - build-server-native + - copilot-test-filter + env: + NODE_ENV: test + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine/server + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + env: + SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }} + uses: ./.github/actions/server-test-env + + - name: Run server tests + run: yarn affine @affine/server test:copilot:coverage --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + copilot-e2e-test: + name: Frontend Copilot E2E Test + if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }} + runs-on: ubuntu-latest + env: + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + IN_CI_TEST: true + REDIS_SERVER_HOST: localhost + DEPLOYMENT_TYPE: affine + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4, 5] + shardTotal: [5] + needs: + - build-server-native + - copilot-test-filter + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud-copilot @affine/web @affine/server + playwright-install: true + playwright-platform: 'chromium' + electron-install: false + hard-link-nm: false + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + env: + SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }} + uses: ./.github/actions/server-test-env + + - name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + uses: ./.github/actions/copilot-test + with: + script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + cloud-e2e-test: + name: ${{ matrix.tests.name }} + runs-on: ubuntu-latest + needs: + - build-server-native + - build-native-linux + env: + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + IN_CI_TEST: true + strategy: + fail-fast: false + matrix: + tests: + - name: 'Cloud E2E Test 1/2' + shard: 1 + script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/2 + - name: 'Cloud E2E Test 2/2' + shard: 2 + script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/2 + - name: 'Cloud Desktop E2E Test' + shard: desktop + script: | + yarn affine @affine/electron build:dev + # Workaround for Electron apps failing to initialize on Ubuntu 24.04 due to AppArmor restrictions + # Disables unprivileged user namespaces restriction to allow Electron apps to run + # Reference: https://github.com/electron/electron/issues/42510 + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn affine @affine-test/affine-desktop-cloud e2e + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud @affine-test/affine-desktop-cloud @affine/web @affine/server @affine/electron @affine/electron-renderer @affine/nbstore @toeverything/infra + playwright-install: true + playwright-platform: 'chromium' + electron-install: ${{ matrix.tests.shard == 'desktop' && 'true' || 'false' }} + hard-link-nm: false + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Download affine.linux-x64-gnu.node + uses: actions/download-artifact@v4 + with: + name: affine.linux-x64-gnu.node + path: ./packages/frontend/native + + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env + + - name: ${{ matrix.tests.name }} + run: | + ${{ matrix.tests.script }} + env: + DEV_SERVER_URL: http://localhost:8080 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-server-${{ matrix.tests.shard }} + path: ./test-results + if-no-files-found: ignore + + desktop-test: + name: Desktop Test (${{ 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-linux + - build-native-macos + - build-native-windows + 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@v7 + - 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: ${{ matrix.spec.test && 'true' || 'false' }} + playwright-platform: 'chromium' + hard-link-nm: false + enableScripts: false + + - name: Setup filename + id: filename + shell: bash + run: | + 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: Run unit tests + if: ${{ matrix.spec.test }} + shell: bash + run: yarn affine @affine/electron vitest + + - 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: Run desktop tests + if: ${{ matrix.spec.os == 'ubuntu-latest' }} + run: | + # Workaround for Electron apps failing to initialize on Ubuntu 24.04 due to AppArmor restrictions + # Disables unprivileged user namespaces restriction to allow Electron apps to run + # Reference: https://github.com/electron/electron/issues/42510 + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn affine @affine-test/affine-desktop e2e + + - name: Run desktop tests + if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }} + run: yarn affine @affine-test/affine-desktop e2e + + - name: Make bundle (macOS) + if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }} + env: + SKIP_BUNDLE: true + SKIP_WEB_BUILD: true + HOIST_NODE_MODULES: 1 + run: yarn affine @affine/electron package --platform=darwin --arch=arm64 + + - name: Make Bundle (Windows) + if: ${{ matrix.spec.target == 'x86_64-pc-windows-msvc' }} + shell: bash + env: + SKIP_BUNDLE: true + SKIP_WEB_BUILD: true + HOIST_NODE_MODULES: 1 + run: | + rm -rf packages/frontend/apps/electron/node_modules/@affine/nbstore/node_modules/@blocksuite/affine/node_modules + rm -rf packages/frontend/apps/electron/node_modules/@affine/native/node_modules + yarn affine @affine/electron package --platform=win32 --arch=x64 + + - name: Make Bundle (Linux) + run: | + sudo add-apt-repository universe + sudo apt install -y libfuse2 elfutils flatpak flatpak-builder + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak update + # some flatpak deps need git protocol.file.allow + git config --global protocol.file.allow always + yarn affine @affine/electron make --platform=linux --arch=x64 + if: ${{ matrix.spec.target == 'x86_64-unknown-linux-gnu' }} + env: + SKIP_WEB_BUILD: 1 + HOIST_NODE_MODULES: 1 + + - name: Output check + if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }} + run: | + yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }} + path: ./test-results + if-no-files-found: ignore + + test-done: + needs: + - analyze + - lint + - typecheck + - mobile-native-build-filter + - build-android-app + - test-android-app + - build-ios-app + - lint-rust + - check-git-status + - check-yarn-binary + - e2e-test + - e2e-blocksuite-test + - e2e-blocksuite-cross-browser-test + - e2e-mobile-test + - unit-test + - build-native-linux + - build-native-macos + - build-native-windows + - build-server-native + - build-electron-renderer + - native-unit-test + - server-test + - server-test-elasticsearch + - server-test-embedded + - server-test-manticoresearch + - server-e2e-test + - rust-test + - rust-test-filter + - copilot-test-filter + - copilot-api-test + - copilot-e2e-test + - desktop-test + - cloud-e2e-test + if: always() + runs-on: ubuntu-latest + name: 3, 2, 1 Launch + steps: + - run: exit 1 + # Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379 + if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} diff --git a/.github/workflows/copilot-test-automatically.yml b/.github/workflows/copilot-test-automatically.yml new file mode 100644 index 00000000..b652c909 --- /dev/null +++ b/.github/workflows/copilot-test-automatically.yml @@ -0,0 +1,29 @@ +name: Copilot Test Automatically + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+-canary.[0-9]+' + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + +permissions: + actions: write + +jobs: + dispatch-test: + runs-on: ubuntu-latest + name: Setup Test + steps: + - name: dispatch test by tag + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: copilot-test.yml + - name: dispatch test by schedule + if: ${{ github.event_name == 'schedule' }} + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: copilot-test.yml + ref: canary diff --git a/.github/workflows/copilot-test.yml b/.github/workflows/copilot-test.yml new file mode 100644 index 00000000..e15b1f6e --- /dev/null +++ b/.github/workflows/copilot-test.yml @@ -0,0 +1,200 @@ +name: Copilot Cron Test + +on: + workflow_dispatch: + +jobs: + build-server-native: + name: Build Server native + runs-on: ubuntu-latest + env: + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + CARGO_PROFILE_RELEASE_DEBUG: '1' + CARGO_PROFILE_RELEASE_LTO: 'thin' + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/server-native + electron-install: false + - name: Build Rust + uses: ./.github/actions/build-rust + with: + target: 'x86_64-unknown-linux-gnu' + package: '@affine/server-native' + - name: Upload server-native.node + uses: actions/upload-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native/server-native.node + if-no-files-found: error + + copilot-api-test: + name: Server Copilot Api Test + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + playwright-install: true + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + env: + SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }} + uses: ./.github/actions/server-test-env + + - name: Run server tests + run: yarn affine @affine/server test:copilot:coverage --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + + - name: Upload server test coverage results + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + copilot-e2e-test: + name: Frontend Copilot E2E Test + runs-on: ubuntu-latest + env: + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + REDIS_SERVER_HOST: localhost + IN_CI_TEST: true + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + shardTotal: [10] + needs: + - build-server-native + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + redis: + image: redis + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + playwright-install: true + electron-install: false + hard-link-nm: false + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native + + - name: Prepare Server Test Environment + env: + SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }} + uses: ./.github/actions/server-test-env + + - name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + uses: ./.github/actions/copilot-test + with: + script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + test-done: + needs: + - copilot-api-test + - copilot-e2e-test + if: always() + runs-on: ubuntu-latest + name: Post test result message + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: 'workspaces focus @affine/copilot-result' + electron-install: false + - name: Post Success event to a Slack channel + if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + run: node ./tools/copilot-result/index.js + env: + CHANNEL_ID: ${{ secrets.RELEASE_SLACK_CHNNEL_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + BRANCH_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref }} + COPILOT_RESULT: success + - name: Post Failed event to a Slack channel + id: failed-slack + if: ${{ always() && contains(needs.*.result, 'failure') }} + run: node ./tools/copilot-result/index.js + env: + CHANNEL_ID: ${{ secrets.RELEASE_SLACK_CHNNEL_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + BRANCH_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref }} + COPILOT_RESULT: failed + - name: Post Cancel event to a Slack channel + id: cancel-slack + if: ${{ always() && contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'failure') }} + run: node ./tools/copilot-result/index.js + env: + CHANNEL_ID: ${{ secrets.RELEASE_SLACK_CHNNEL_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + BRANCH_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref }} + COPILOT_RESULT: canceled diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 00000000..fb16fb46 --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,32 @@ +name: PR Title Lint + +on: + pull_request: + types: + - opened + - edited + - synchronize + branches: + - canary + +permissions: + contents: read + +jobs: + check-pull-request-title: + name: Check pull request title + runs-on: ubuntu-latest + if: ${{ github.event.action != 'edited' || github.event.changes.title != null }} + steps: + - uses: actions/checkout@v7 + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + cache: 'yarn' + node-version-file: '.nvmrc' + - name: Install dependencies + run: yarn workspaces focus @affine/commitlint-config + - name: Check PR title + env: + TITLE: ${{ github.event.pull_request.title }} + run: echo "$TITLE" | yarn workspace @affine/commitlint-config commitlint -g ./.commitlintrc.json diff --git a/.github/workflows/release-cloud.yml b/.github/workflows/release-cloud.yml new file mode 100644 index 00000000..346371f1 --- /dev/null +++ b/.github/workflows/release-cloud.yml @@ -0,0 +1,66 @@ +name: Release Cloud + +on: + workflow_call: + inputs: + build-type: + required: true + type: string + app-version: + required: true + type: string + git-short-hash: + required: true + type: string + +permissions: + contents: 'write' + id-token: 'write' + packages: 'write' + +jobs: + build-images: + name: Build Images + uses: ./.github/workflows/build-images.yml + secrets: inherit + with: + build-type: ${{ inputs.build-type }} + app-version: ${{ inputs.app-version }} + git-short-hash: ${{ inputs.git-short-hash }} + + deploy: + name: Deploy to cluster + environment: ${{ inputs.build-type }} + needs: + - build-images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Deploy to ${{ inputs.build-type }} + uses: ./.github/actions/deploy + with: + gcp-project-number: ${{ secrets.GCP_PROJECT_NUMBER }} + gcp-project-id: ${{ secrets.GCP_PROJECT_ID }} + service-account: ${{ secrets.GCP_HELM_DEPLOY_SERVICE_ACCOUNT }} + cluster-name: ${{ secrets.GCP_CLUSTER_NAME }} + cluster-location: ${{ secrets.GCP_CLUSTER_LOCATION }} + env: + BUILD_TYPE: ${{ inputs.build-type }} + APP_VERSION: ${{ inputs.app-version }} + GIT_SHORT_HASH: ${{ inputs.git-short-hash }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + CANARY_DEPLOY_HOST: ${{ secrets.CANARY_DEPLOY_HOST }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }} + DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} + DATABASE_NAME: ${{ secrets.DATABASE_NAME }} + GCLOUD_CONNECTION_NAME: ${{ secrets.GCLOUD_CONNECTION_NAME }} + REDIS_SERVER_HOST: ${{ secrets.REDIS_SERVER_HOST }} + REDIS_SERVER_PASSWORD: ${{ secrets.REDIS_SERVER_PASSWORD }} + CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }} + APP_IAM_ACCOUNT: ${{ secrets.APP_IAM_ACCOUNT }} + STATIC_IP_NAME: ${{ secrets.STATIC_IP_NAME }} + AFFINE_INDEXER_SEARCH_PROVIDER: ${{ secrets.AFFINE_INDEXER_SEARCH_PROVIDER }} + AFFINE_INDEXER_SEARCH_ENDPOINT: ${{ secrets.AFFINE_INDEXER_SEARCH_ENDPOINT }} + AFFINE_INDEXER_SEARCH_API_KEY: ${{ secrets.AFFINE_INDEXER_SEARCH_API_KEY }} diff --git a/.github/workflows/release-desktop-platform.yml b/.github/workflows/release-desktop-platform.yml new file mode 100644 index 00000000..04ad715f --- /dev/null +++ b/.github/workflows/release-desktop-platform.yml @@ -0,0 +1,225 @@ +name: Release Desktop Platform + +on: + workflow_call: + inputs: + build_type: + required: true + type: string + app_version: + required: true + type: string + git_short_hash: + required: true + type: string + runner: + required: true + type: string + platform: + required: true + type: string + arch: + required: true + type: string + target: + required: true + type: string + apple_codesign: + required: false + default: false + type: boolean + install_linux_deps: + required: false + default: false + type: boolean + enable_scripts: + required: false + default: false + type: boolean + outputs: + files_to_be_signed: + description: Files to be signed (Windows only) + value: ${{ jobs.build.outputs.files_to_be_signed }} + +permissions: + actions: write + contents: write + security-events: write + id-token: write + attestations: write + +jobs: + build: + runs-on: ${{ inputs.runner }} + outputs: + files_to_be_signed: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }} + env: + BUILD_TYPE: ${{ inputs.build_type }} + RELEASE_VERSION: ${{ inputs.app_version }} + DEBUG: 'affine:*,napi:*' + APP_NAME: affine + MACOSX_DEPLOYMENT_TARGET: '12.0' + SKIP_GENERATE_ASSETS: 1 + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_RELEASE: ${{ inputs.app_version }} + steps: + - uses: actions/checkout@v7 + + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app_version }} + + - name: Setup Node.js + timeout-minutes: 10 + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/electron @affine/monorepo @affine/nbstore @toeverything/infra + hard-link-nm: false + nmHoistingLimits: workspaces + enableScripts: ${{ inputs.enable_scripts }} + + - name: Build AFFiNE native + uses: ./.github/actions/build-rust + with: + target: ${{ inputs.target }} + package: '@affine/native' + + - uses: actions/download-artifact@v4 + with: + name: desktop-web + path: packages/frontend/apps/electron/resources/web-static + + - name: Build Desktop Layers + run: yarn affine @affine/electron build + + - name: Signing By Apple Developer ID + if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }} + uses: apple-actions/import-codesign-certs@v7 + with: + p12-file-base64: ${{ secrets.CERTIFICATES_P12 }} + p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }} + + - name: Install additional dependencies on Linux + if: ${{ inputs.platform == 'linux' && inputs.install_linux_deps }} + run: | + df -h + sudo add-apt-repository universe + sudo apt install -y libfuse2 elfutils flatpak flatpak-builder + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak update + # some flatpak deps need git protocol.file.allow + git config --global protocol.file.allow always + # clean up apt cache to save disk space + sudo -E apt-get -y purge azure-cli* zulu* hhvm* llvm* firefox* google* dotnet* aspnetcore* powershell* adoptopenjdk* mysql* php* mongodb* moby* snap* || true + sudo -E apt-get -qq autoremove --purge + sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL /usr/local/lib/android + sudo apt-get clean + rm -rf ~/.cache/yarn ~/.npm + df -h + + - name: Remove nbstore node_modules (darwin/linux) + if: ${{ inputs.platform != 'win32' }} + shell: bash + # node_modules of nbstore is not needed for building, and it will make the build process out of memory + run: | + cargo clean + rm -rf packages/frontend/apps/electron/node_modules/@affine/nbstore/node_modules/@blocksuite + rm -rf packages/frontend/apps/electron/node_modules/@affine/native/node_modules + + - name: Remove nbstore node_modules (windows) + if: ${{ inputs.platform == 'win32' }} + shell: bash + run: | + rm -rf packages/frontend/apps/electron/node_modules/@affine/nbstore/node_modules/@blocksuite/affine/node_modules + rm -rf packages/frontend/apps/electron/node_modules/@affine/native/node_modules + + - name: make + if: ${{ inputs.platform != 'win32' }} + run: yarn affine @affine/electron make --platform=${{ inputs.platform }} --arch=${{ inputs.arch }} + env: + SKIP_WEB_BUILD: 1 + HOIST_NODE_MODULES: 1 + NODE_OPTIONS: --max-old-space-size=14384 + + - name: package + if: ${{ inputs.platform == 'win32' }} + run: | + yarn affine @affine/electron package --platform=${{ inputs.platform }} --arch=${{ inputs.arch }} + env: + SKIP_WEB_BUILD: 1 + HOIST_NODE_MODULES: 1 + NODE_OPTIONS: --max-old-space-size=14384 + + - name: signing DMG + if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }} + run: | + codesign --force --sign "Developer ID Application: TOEVERYTHING PTE. LTD." packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make/AFFiNE.dmg + + - name: Save artifacts (mac) + if: ${{ inputs.platform == 'darwin' }} + run: | + mkdir -p builds + mv packages/frontend/apps/electron/out/*/make/*.dmg ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.dmg + mv packages/frontend/apps/electron/out/*/make/zip/darwin/${{ inputs.arch }}/*.zip ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.zip + + - name: Save artifacts (linux) + if: ${{ inputs.platform == 'linux' }} + run: | + mkdir -p builds + mv packages/frontend/apps/electron/out/*/make/zip/linux/${{ inputs.arch }}/*.zip ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.zip + mv packages/frontend/apps/electron/out/*/make/AppImage/${{ inputs.arch }}/*.AppImage ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.appimage + mv packages/frontend/apps/electron/out/*/make/deb/${{ inputs.arch }}/*.deb ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.deb + mv packages/frontend/apps/electron/out/*/make/flatpak/*/*.flatpak ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.flatpak + + - uses: actions/attest-build-provenance@v4 + if: ${{ inputs.platform == 'darwin' }} + with: + subject-path: | + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.zip + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.dmg + + - uses: actions/attest-build-provenance@v4 + if: ${{ inputs.platform == 'linux' }} + with: + subject-path: | + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.zip + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.appimage + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.deb + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.flatpak + + - name: Upload Artifact + if: ${{ inputs.platform == 'darwin' || inputs.platform == 'linux' }} + uses: actions/upload-artifact@v4 + with: + name: affine-${{ inputs.platform }}-${{ inputs.arch }}-builds + path: builds + + - name: get all files to be signed + id: get_files_to_be_signed + if: ${{ inputs.platform == 'win32' }} + shell: pwsh + run: | + Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/apps/electron/out -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\apps\electron\out\', '') + '"' }) -join ' ') + "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT + echo $FILES_TO_BE_SIGNED + + - name: Zip artifacts for faster upload + if: ${{ inputs.platform == 'win32' }} + shell: pwsh + run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/apps/electron/out/* -DestinationPath archive.zip + + - name: Save packaged artifacts for signing + if: ${{ inputs.platform == 'win32' }} + uses: actions/upload-artifact@v4 + with: + name: packaged-${{ inputs.platform }}-${{ inputs.arch }} + path: | + archive.zip + !**/*.map diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 00000000..eafcc78c --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,430 @@ +name: Release Desktop + +on: + workflow_call: + inputs: + build-type: + required: true + type: string + app-version: + required: true + type: string + git-short-hash: + required: true + type: string + desktop_macos: + description: 'Desktop - macOS' + required: false + default: true + type: boolean + desktop_windows: + description: 'Desktop - Windows' + required: false + default: true + type: boolean + desktop_linux: + description: 'Desktop - Linux' + required: false + default: true + type: boolean + require-windows-signing: + description: 'Require all Windows signing steps to succeed before release' + required: false + default: false + type: boolean + +permissions: + actions: write + contents: write + security-events: write + id-token: write + attestations: write + +env: + BUILD_TYPE: ${{ inputs.build-type }} + RELEASE_VERSION: ${{ inputs.app-version }} + DEBUG: 'affine:*,napi:*' + APP_NAME: affine + MACOSX_DEPLOYMENT_TARGET: '11.6' + +jobs: + before-make: + if: ${{ inputs.desktop_macos || inputs.desktop_windows || inputs.desktop_linux }} + runs-on: ubuntu-latest + environment: ${{ inputs.build-type }} + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Setup @sentry/cli + uses: ./.github/actions/setup-sentry + - name: generate-assets + run: yarn affine @affine/electron generate-assets + env: + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_RELEASE: ${{ inputs.app-version }} + RELEASE_VERSION: ${{ inputs.app-version }} + + - name: Upload web artifact + uses: actions/upload-artifact@v4 + with: + name: desktop-web + path: packages/frontend/apps/electron/resources/web-static + + windows-signer-gate: + if: ${{ inputs.desktop_windows }} + runs-on: ubuntu-latest + outputs: + signer_available: ${{ steps.check.outputs.signer_available }} + steps: + - uses: actions/checkout@v7 + - name: Check windows signer availability + id: check + run: node ./scripts/check-windows-signer.mjs + env: + AFFINE_SIGN_CLIENT_HASH: ${{ secrets.AFFINE_SIGN_CLIENT_HASH }} + AFFINE_SIGNER_ADDR: ${{ secrets.AFFINE_SIGNER_ADDR }} + AFFINE_SIGNER_TOKEN: ${{ secrets.AFFINE_SIGNER_TOKEN }} + AFFINE_SIGNER_TS_AUTH_KEY: ${{ secrets.AFFINE_SIGNER_TS_AUTH_KEY }} + BUILD_TYPE: ${{ inputs.build-type }} + REQUIRE_SIGNER: ${{ inputs.require-windows-signing }} + WINDOWS_SIGNER_PUBLIC_CERT_BASE64: ${{ secrets.WINDOWS_SIGNER_PUBLIC_CERT_BASE64 }} + + make-distribution-macos: + if: ${{ inputs.desktop_macos }} + strategy: + fail-fast: false + matrix: + spec: + - runner: macos-latest + platform: darwin + arch: x64 + target: x86_64-apple-darwin + - runner: macos-latest + platform: darwin + arch: arm64 + target: aarch64-apple-darwin + needs: before-make + uses: ./.github/workflows/release-desktop-platform.yml + secrets: inherit + with: + build_type: ${{ inputs.build-type }} + app_version: ${{ inputs.app-version }} + git_short_hash: ${{ inputs.git-short-hash }} + runner: ${{ matrix.spec.runner }} + platform: ${{ matrix.spec.platform }} + arch: ${{ matrix.spec.arch }} + target: ${{ matrix.spec.target }} + apple_codesign: true + + make-distribution-linux: + if: ${{ inputs.desktop_linux }} + strategy: + fail-fast: false + matrix: + spec: + - runner: ubuntu-latest + platform: linux + arch: x64 + target: x86_64-unknown-linux-gnu + needs: before-make + uses: ./.github/workflows/release-desktop-platform.yml + secrets: inherit + with: + build_type: ${{ inputs.build-type }} + app_version: ${{ inputs.app-version }} + git_short_hash: ${{ inputs.git-short-hash }} + runner: ${{ matrix.spec.runner }} + platform: ${{ matrix.spec.platform }} + arch: ${{ matrix.spec.arch }} + target: ${{ matrix.spec.target }} + install_linux_deps: true + + build-and-sign-windows: + if: ${{ inputs.desktop_windows && needs.windows-signer-gate.outputs.signer_available == 'true' }} + needs: + - before-make + - windows-signer-gate + strategy: + fail-fast: false + matrix: + spec: + - platform: win32 + arch: x64 + target: x86_64-pc-windows-msvc + - platform: win32 + arch: arm64 + target: aarch64-pc-windows-msvc + runs-on: windows-latest + env: + AFFINE_SIGNER_ADDR: ${{ secrets.AFFINE_SIGNER_ADDR }} + AFFINE_SIGNER_TOKEN: ${{ secrets.AFFINE_SIGNER_TOKEN }} + APP_NAME: affine + BUILD_TYPE: ${{ inputs.build-type }} + DEBUG: 'affine:*,napi:*' + RELEASE_VERSION: ${{ inputs.app-version }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine' + SENTRY_RELEASE: ${{ inputs.app-version }} + SKIP_GENERATE_ASSETS: 1 + TS_RS_EXPERIMENT: this_is_unstable_software + steps: + - uses: actions/checkout@v7 + + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + + - name: Setup Node.js + timeout-minutes: 10 + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/electron @affine/monorepo @affine/nbstore @toeverything/infra + enableScripts: true + hard-link-nm: false + nmHoistingLimits: workspaces + env: + npm_config_arch: ${{ matrix.spec.arch }} + + - name: Build AFFiNE native + uses: ./.github/actions/build-rust + with: + target: ${{ matrix.spec.target }} + package: '@affine/native' + + - name: Download web artifact + uses: actions/download-artifact@v4 + with: + name: desktop-web + path: packages/frontend/apps/electron/resources/web-static + + - name: Build Desktop Layers + run: yarn affine @affine/electron build + + - name: Remove nbstore node_modules + shell: bash + run: | + rm -rf packages/frontend/apps/electron/node_modules/@affine/nbstore/node_modules/@blocksuite/affine/node_modules + rm -rf packages/frontend/apps/electron/node_modules/@affine/native/node_modules + + - name: Package windows app + run: yarn affine @affine/electron package --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }} + env: + HOIST_NODE_MODULES: 1 + NODE_OPTIONS: --max-old-space-size=14384 + SKIP_WEB_BUILD: 1 + + - name: Connect Tailscale + uses: tailscale/github-action@v4 + with: + authkey: ${{ secrets.AFFINE_SIGNER_TS_AUTH_KEY }} + hostname: affine-signer-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.spec.arch }} + + - name: Check signer connectivity + shell: pwsh + run: | + $Parts = "$env:AFFINE_SIGNER_ADDR".Split(':') + if ($Parts.Count -ne 2) { + throw "AFFINE_SIGNER_ADDR must be host:port, got $env:AFFINE_SIGNER_ADDR" + } + + $Result = Test-NetConnection -ComputerName $Parts[0] -Port ([int]$Parts[1]) + if (!$Result.TcpTestSucceeded) { + throw "Unable to connect to signer at $env:AFFINE_SIGNER_ADDR" + } + + - name: Download remote signer client + shell: pwsh + run: | + if (!$env:AFFINE_SIGN_CLIENT_HASH) { + throw 'AFFINE_SIGN_CLIENT_HASH is required.' + } + Invoke-WebRequest -Uri "https://cdn.affine.pro/sign-client/$env:AFFINE_SIGN_CLIENT_HASH" -OutFile affine-sign-client.exe + env: + AFFINE_SIGN_CLIENT_HASH: ${{ secrets.AFFINE_SIGN_CLIENT_HASH }} + + - name: Prepare public signing certificate + shell: pwsh + run: | + if (!$env:WINDOWS_SIGNER_PUBLIC_CERT_BASE64) { + throw 'WINDOWS_SIGNER_PUBLIC_CERT_BASE64 is required.' + } + [IO.File]::WriteAllBytes('windows-signer-public.cer', [Convert]::FromBase64String($env:WINDOWS_SIGNER_PUBLIC_CERT_BASE64)) + env: + WINDOWS_SIGNER_PUBLIC_CERT_BASE64: ${{ secrets.WINDOWS_SIGNER_PUBLIC_CERT_BASE64 }} + + - name: Resolve signtool + shell: pwsh + run: | + $Command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($Command) { + "SIGNTOOL=$($Command.Source)" >> $env:GITHUB_ENV + Write-Host "Using signtool: $($Command.Source)" + exit 0 + } + + $KitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $Candidates = @() + if (Test-Path -LiteralPath $KitsRoot) { + $Candidates = Get-ChildItem -Path $KitsRoot -Recurse -Filter signtool.exe | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending + } + + if ($Candidates.Count -eq 0) { + throw "Unable to find signtool.exe under PATH or $KitsRoot" + } + + "SIGNTOOL=$($Candidates[0].FullName)" >> $env:GITHUB_ENV + Write-Host "Using signtool: $($Candidates[0].FullName)" + + - name: Get packaged files to sign + id: packaged_files_to_sign + shell: pwsh + run: | + Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/apps/electron/out -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\apps\electron\out\', '') + '"' }) -join ' ') + "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT + echo $FILES_TO_BE_SIGNED + + - name: Sign packaged files + shell: pwsh + run: | + ./affine-sign-client.exe ` + --server "$env:AFFINE_SIGNER_ADDR" ` + --token "$env:AFFINE_SIGNER_TOKEN" ` + --workdir packages/frontend/apps/electron/out ` + --files '${{ steps.packaged_files_to_sign.outputs.FILES_TO_BE_SIGNED }}' ` + --cert windows-signer-public.cer ` + --plain-tcp + + - name: Make squirrel.windows installer + run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }} + + - name: Make nsis.windows installer + run: yarn affine @affine/electron make-nsis --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }} + + - name: Get installer files to sign + id: installer_files_to_sign + shell: pwsh + run: | + Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\apps\electron\out\${{ env.BUILD_TYPE }}\make\', '') + '"' }) -join ' ') + "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT + echo $FILES_TO_BE_SIGNED + + - name: Sign installer files + shell: pwsh + run: | + ./affine-sign-client.exe ` + --server "$env:AFFINE_SIGNER_ADDR" ` + --token "$env:AFFINE_SIGNER_TOKEN" ` + --workdir packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make ` + --files '${{ steps.installer_files_to_sign.outputs.FILES_TO_BE_SIGNED }}' ` + --cert windows-signer-public.cer ` + --plain-tcp + + - name: Save artifacts + run: | + mkdir -p builds + mv packages/frontend/apps/electron/out/*/make/zip/win32/${{ matrix.spec.arch }}/AFFiNE*-win32-${{ matrix.spec.arch }}-*.zip ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.zip + mv packages/frontend/apps/electron/out/*/make/squirrel.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.exe + mv packages/frontend/apps/electron/out/*/make/nsis.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.nsis.exe + + - uses: actions/attest-build-provenance@v4 + with: + subject-path: | + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.zip + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.exe + ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.nsis.exe + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds + path: builds + + release: + if: >- + ${{ + always() && + (inputs.desktop_macos || inputs.desktop_linux || inputs.desktop_windows) && + needs.before-make.result == 'success' && + (!inputs.desktop_macos || needs.make-distribution-macos.result == 'success') && + (!inputs.desktop_linux || needs.make-distribution-linux.result == 'success') && + ( + !inputs.desktop_windows || + !inputs.require-windows-signing || + needs.build-and-sign-windows.result == 'success' + ) + }} + needs: + [ + before-make, + make-distribution-macos, + make-distribution-linux, + build-and-sign-windows, + ] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Download Artifacts (macos-x64) + if: ${{ inputs.desktop_macos }} + uses: actions/download-artifact@v4 + with: + name: affine-darwin-x64-builds + path: ./release + - name: Download Artifacts (macos-arm64) + if: ${{ inputs.desktop_macos }} + uses: actions/download-artifact@v4 + with: + name: affine-darwin-arm64-builds + path: ./release + - name: Download Artifacts (windows-x64) + if: ${{ inputs.desktop_windows && needs.build-and-sign-windows.result == 'success' }} + uses: actions/download-artifact@v4 + with: + name: affine-win32-x64-builds + path: ./release + - name: Download Artifacts (windows-arm64) + if: ${{ inputs.desktop_windows && needs.build-and-sign-windows.result == 'success' }} + uses: actions/download-artifact@v4 + with: + name: affine-win32-arm64-builds + path: ./release + - name: Download Artifacts (linux-x64) + if: ${{ inputs.desktop_linux }} + uses: actions/download-artifact@v4 + with: + name: affine-linux-x64-builds + path: ./release + - uses: actions/setup-node@v7 + with: + node-version: 20 + - name: Copy Selfhost Release Files + run: | + cp ./.docker/selfhost/compose.yml ./release/docker-compose.yml + cp ./.docker/selfhost/config.json.example ./release/config.json.example + cp ./.docker/selfhost/schema.json ./release/config.schema.json + - name: Generate Release yml + run: | + node ./scripts/generate-release-yml.mjs + env: + RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ env.RELEASE_VERSION }} + draft: ${{ inputs.build-type == 'stable' }} + prerelease: ${{ inputs.build-type != 'stable' }} + tag_name: v${{ env.RELEASE_VERSION}} + files: ./release/* diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml new file mode 100644 index 00000000..5f2db2b9 --- /dev/null +++ b/.github/workflows/release-mobile.yml @@ -0,0 +1,228 @@ +name: Release Mobile + +on: + workflow_call: + inputs: + app-version: + type: string + required: true + git-short-hash: + type: string + required: true + build-type: + type: string + required: true + ios-app-version: + type: string + required: false + +env: + BUILD_TYPE: ${{ inputs.build-type }} + DEBUG: napi:* + KEYCHAIN_NAME: ${{ github.workspace }}/signing_temp + +jobs: + build-ios-web: + runs-on: ubuntu-latest + environment: ${{ inputs.build-type }} + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Setup @sentry/cli + uses: ./.github/actions/setup-sentry + - name: Build Mobile + run: yarn affine @affine/ios build + env: + PUBLIC_PATH: '/' + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_RELEASE: ${{ inputs.app-version }} + RELEASE_VERSION: ${{ inputs.app-version }} + - name: Upload ios artifact + uses: actions/upload-artifact@v4 + with: + name: ios + path: packages/frontend/apps/ios/dist + + build-android-web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Setup @sentry/cli + uses: ./.github/actions/setup-sentry + - name: Build Mobile + run: yarn affine @affine/android build + env: + PUBLIC_PATH: '/' + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: 'affine' + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_RELEASE: ${{ inputs.app-version }} + - name: Upload android artifact + uses: actions/upload-artifact@v4 + with: + name: android + path: packages/frontend/apps/android/dist + + ios: + runs-on: 'macos-15' + needs: + - build-ios-web + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + ios-app-version: ${{ inputs.ios-app-version }} + - name: 'Update Code Sign Identity' + shell: bash + run: ./packages/frontend/apps/ios/update_code_sign_identity.sh + - name: Download mobile artifact + uses: actions/download-artifact@v4 + with: + name: ios + path: packages/frontend/apps/ios/dist + - name: Setup Node.js + uses: ./.github/actions/setup-node + timeout-minutes: 10 + with: + extra-flags: workspaces focus @affine/ios + playwright-install: false + electron-install: false + hard-link-nm: false + enableScripts: false + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 26.2 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + - name: Install Swiftformat + run: brew install swiftformat + - name: Cap sync + run: yarn workspace @affine/ios sync + - name: Signing By Apple Developer ID + uses: apple-actions/import-codesign-certs@v7 + id: import-codesign-certs + with: + p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }} + p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD_MOBILE }} + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'aarch64-apple-ios' + package: 'affine_mobile_native' + no-build: 'true' + - name: Testflight + working-directory: packages/frontend/apps/ios/App + run: | + printf '%s' "$BUILD_PROVISION_PROFILE" | base64 --decode -o "$PP_PATH" + mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" + cp "$PP_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles" + bundle install + bundle exec fastlane beta + env: + BUNDLE_PATH: vendor/bundle + BUILD_TARGET: distribution + BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }} + PP_PATH: ${{ runner.temp }}/build_pp.mobileprovision + APPLE_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_STORE_CONNECT_API_KEY_ID }} + APPLE_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APPLE_STORE_CONNECT_API_ISSUER_ID }} + APPLE_STORE_CONNECT_API_KEY: ${{ secrets.APPLE_STORE_CONNECT_API_KEY }} + + android: + runs-on: ubuntu-latest + permissions: + id-token: 'write' + needs: + - build-android-web + steps: + - uses: actions/checkout@v7 + - name: Setup Version + uses: ./.github/actions/setup-version + with: + app-version: ${{ inputs.app-version }} + - name: Download mobile artifact + uses: actions/download-artifact@v4 + with: + name: android + path: packages/frontend/apps/android/dist + - name: Load Google Service file + env: + DATA: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICE_JSON }} + run: | + set -euo pipefail + printf '%s' "$DATA" | base64 -di > packages/frontend/apps/android/App/app/google-services.json + - name: Setup Node.js + uses: ./.github/actions/setup-node + timeout-minutes: 10 + with: + extra-flags: workspaces focus @affine/monorepo @affine-tools/cli @affine/android @affine/playstore-auto-bump + playwright-install: false + electron-install: false + hard-link-nm: false + enableScripts: false + - name: Setup Rust + uses: ./.github/actions/build-rust + with: + target: 'aarch64-linux-android' + package: 'affine_mobile_native' + no-build: 'true' + - name: Cap sync + run: yarn workspace @affine/android cap sync + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + - name: Auth gcloud + id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: 'projects/${{ secrets.GCP_PROJECT_NUMBER }}/locations/global/workloadIdentityPools/github-actions/providers/github-actions-helm-deploy' + service_account: '${{ secrets.GCP_HELM_DEPLOY_SERVICE_ACCOUNT }}' + token_format: 'access_token' + project_id: '${{ secrets.GCP_PROJECT_ID }}' + access_token_scopes: 'https://www.googleapis.com/auth/androidpublisher' + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + - name: Auto increment version code + id: bump + run: yarn affine @affine/playstore-auto-bump bump + env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ steps.auth.outputs.credentials_file_path }} + - name: Build + run: | + echo -n "${{ env.AFFINE_ANDROID_SIGN_KEYSTORE }}" | base64 --decode > packages/frontend/apps/android/affine.keystore + yarn workspace @affine/android cap build android --flavor ${{ env.BUILD_TYPE }} --androidreleasetype AAB + env: + AFFINE_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.AFFINE_ANDROID_KEYSTORE_PASSWORD }} + AFFINE_ANDROID_KEYSTORE_ALIAS_PASSWORD: ${{ secrets.AFFINE_ANDROID_KEYSTORE_ALIAS_PASSWORD }} + AFFINE_ANDROID_SIGN_KEYSTORE: ${{ secrets.AFFINE_ANDROID_SIGN_KEYSTORE }} + VERSION_NAME: ${{ inputs.app-version }} + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }} + packageName: app.affine.pro + releaseName: ${{ inputs.app-version }} + releaseFiles: packages/frontend/apps/android/App/app/build/outputs/bundle/${{ env.BUILD_TYPE }}Release/app-${{ env.BUILD_TYPE }}-release-signed.aab + track: internal + status: draft + existingEditId: ${{ steps.bump.outputs.EDIT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..53005572 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Release + +on: + schedule: + - cron: '0 9 * * *' + + workflow_dispatch: + inputs: + web: + description: 'Release Web?' + required: true + type: boolean + default: false + desktop_macos: + description: 'Desktop - macOS' + required: true + type: boolean + default: false + desktop_windows: + description: 'Desktop - Windows' + required: true + type: boolean + default: false + desktop_linux: + description: 'Desktop - Linux' + required: true + type: boolean + default: false + mobile: + description: 'Release Mobile?' + required: true + type: boolean + default: false + ios-app-version: + description: 'iOS App Store Version (Optional, use tag version if empty)' + required: false + type: string + +permissions: + contents: write + pull-requests: write + actions: write + id-token: write + packages: write + security-events: write + attestations: write + issues: write + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + outputs: + APP_VERSION: ${{ steps.prepare.outputs.APP_VERSION }} + GIT_SHORT_HASH: ${{ steps.prepare.outputs.GIT_SHORT_HASH }} + BUILD_TYPE: ${{ steps.prepare.outputs.BUILD_TYPE }} + steps: + - uses: actions/checkout@v7 + - name: Prepare Release + id: prepare + uses: ./.github/actions/prepare-release + + canary-gate: + name: Canary Gate + runs-on: ubuntu-latest + needs: + - prepare + outputs: + SHOULD_RELEASE: ${{ steps.decide.outputs.SHOULD_RELEASE }} + LAST_CANARY_TAG: ${{ steps.decide.outputs.LAST_CANARY_TAG }} + LAST_CANARY_SHA: ${{ steps.decide.outputs.LAST_CANARY_SHA }} + steps: + - name: Decide whether to release + id: decide + uses: actions/github-script@v9 + with: + script: | + const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}' + if (buildType !== 'canary') { + core.setOutput('SHOULD_RELEASE', 'true') + return + } + + const owner = context.repo.owner + const repo = context.repo.repo + const currentSha = context.sha + const canaryTagRe = /^v\d+\.\d+\.\d+-canary\.[0-9a-f]+$/i + + let page = 1 + const perPage = 100 + let lastCanary = null + + while (!lastCanary && page <= 10) { + const { data } = await github.rest.repos.listTags({ + owner, + repo, + per_page: perPage, + page, + }) + + for (const tag of data) { + if (canaryTagRe.test(tag.name)) { + lastCanary = tag + break + } + } + + if (data.length < perPage) break + page++ + } + + if (!lastCanary) { + core.warning('No canary tags found; proceeding with canary release.') + core.setOutput('SHOULD_RELEASE', 'true') + return + } + + core.setOutput('LAST_CANARY_TAG', lastCanary.name) + core.setOutput('LAST_CANARY_SHA', lastCanary.commit.sha) + + const shouldRelease = lastCanary.commit.sha !== currentSha + core.info(`Latest canary tag ${lastCanary.name} -> ${lastCanary.commit.sha}; current ${currentSha}; should_release=${shouldRelease}`) + core.setOutput('SHOULD_RELEASE', shouldRelease ? 'true' : 'false') + + cloud: + name: Release Cloud + if: ${{ inputs.web || github.event_name != 'workflow_dispatch' }} + needs: + - prepare + uses: ./.github/workflows/release-cloud.yml + secrets: inherit + with: + build-type: ${{ needs.prepare.outputs.BUILD_TYPE }} + app-version: ${{ needs.prepare.outputs.APP_VERSION }} + git-short-hash: ${{ needs.prepare.outputs.GIT_SHORT_HASH }} + + image: + name: Release Docker Image + if: ${{ needs.canary-gate.outputs.SHOULD_RELEASE == 'true' }} + runs-on: ubuntu-latest + needs: + - prepare + - canary-gate + - cloud + steps: + - uses: trstringer/manual-approval@v1 + if: ${{ needs.prepare.outputs.BUILD_TYPE == 'stable' }} + name: Wait for approval + with: + secret: ${{ secrets.GITHUB_TOKEN }} + approvers: darkskygit + minimum-approvals: 1 + fail-on-denial: true + issue-title: Please confirm to release docker image + issue-body: | + Env: ${{ needs.prepare.outputs.BUILD_TYPE }} + Candidate: ghcr.io/toeverything/affine:${{ needs.prepare.outputs.BUILD_TYPE }}-${{ needs.prepare.outputs.GIT_SHORT_HASH }} + Tag: ghcr.io/toeverything/affine:${{ needs.prepare.outputs.BUILD_TYPE }} + + > comment with "approve", "approved", "lgtm", "yes" to approve + > comment with "deny", "denied", "no" to deny + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + logout: false + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Tag Image + run: | + docker buildx imagetools create --tag ghcr.io/toeverything/affine:${{needs.prepare.outputs.BUILD_TYPE}} ghcr.io/toeverything/affine:${{needs.prepare.outputs.BUILD_TYPE}}-${{needs.prepare.outputs.GIT_SHORT_HASH}} + docker buildx imagetools create --tag ghcr.io/toeverything/affine:${{needs.prepare.outputs.APP_VERSION}} ghcr.io/toeverything/affine:${{needs.prepare.outputs.BUILD_TYPE}}-${{needs.prepare.outputs.GIT_SHORT_HASH}} + + desktop: + name: Release Desktop + if: >- + ${{ + (github.event_name != 'workflow_dispatch' && needs.canary-gate.outputs.SHOULD_RELEASE == 'true') || + inputs.desktop_macos || + inputs.desktop_windows || + inputs.desktop_linux + }} + needs: + - prepare + - canary-gate + uses: ./.github/workflows/release-desktop.yml + secrets: inherit + with: + build-type: ${{ needs.prepare.outputs.BUILD_TYPE }} + app-version: ${{ needs.prepare.outputs.APP_VERSION }} + git-short-hash: ${{ needs.prepare.outputs.GIT_SHORT_HASH }} + desktop_macos: ${{ github.event_name != 'workflow_dispatch' || inputs.desktop_macos }} + desktop_windows: ${{ github.event_name != 'workflow_dispatch' || inputs.desktop_windows }} + desktop_linux: ${{ github.event_name != 'workflow_dispatch' || inputs.desktop_linux }} + require-windows-signing: ${{ needs.prepare.outputs.BUILD_TYPE == 'stable' || (github.event_name == 'workflow_dispatch' && inputs.desktop_windows) }} + + mobile: + name: Release Mobile + if: ${{ inputs.mobile }} + needs: + - prepare + uses: ./.github/workflows/release-mobile.yml + secrets: inherit + with: + build-type: ${{ needs.prepare.outputs.BUILD_TYPE }} + app-version: ${{ needs.prepare.outputs.APP_VERSION }} + git-short-hash: ${{ needs.prepare.outputs.GIT_SHORT_HASH }} + ios-app-version: ${{ inputs.ios-app-version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..db5f8642 --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +.yarn/versions +.corepack-bin + +# compiled output +*dist +/tmp +/out-tsc +.nyc_output +.coverage +.swc + +# dependencies +node_modules + +# IDEs and editors +**/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.template.json +!.vscode/launch.template.json +!.vscode/extensions.json + +# Kiro +.kiro + +# misc +/.sass-cache +/connect.lock +coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +.pnpm-debug.log +/typings +tsconfig.tsbuildinfo +.context +/*.md +.codex +.cursor + +# System Files +.DS_Store +Thumbs.db + +# env +*.env.local +*.local.env +.history + +.next +.vercel +out/ +storybook-static + +test-results +playwright-report +playwright/.cache +download + +# Cache +next-env.d.ts +.rollup.cache + +# Rust +target +*.node +tsconfig.node.tsbuildinfo +lib +affine.db +apps/web/next-routes.conf +.nx + +packages/frontend/templates/edgeless +packages/frontend/core/public/static/templates + +# script +af +af.cmd +*.resolved + +# playwright +storageState.json +/.understand-anything + +# local test/browser artifacts +/.playwright-browsers/ +**/.vitest-attachments/ +/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..bdcffe49 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged && yarn lint:ox diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..548137ea --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +shell-emulator=true +electron_mirror="https://cdn.npmmirror.com/binaries/electron/" +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..c9471194 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.23.2 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..0fd2b803 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 80, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "arrowParens": "avoid", + "sortPackageJson": false, + "ignorePatterns": ["**/*.toml"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..ac353dda --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,495 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "jsPlugins": [ + "eslint-plugin-simple-import-sort", + "eslint-plugin-sonarjs", + "eslint-plugin-lit", + { + "name": "import-x-js", + "specifier": "eslint-plugin-import-x" + }, + { + "name": "react-js", + "specifier": "eslint-plugin-react" + }, + { + "name": "react-hooks-js", + "specifier": "eslint-plugin-react-hooks" + } + ], + "plugins": ["import", "react", "typescript", "unicorn", "promise"], + "settings": { + "_comment": "TODO: Restore rxjs/finnish when Oxlint JS plugins support type-aware rules.", + "react": { + "version": "19.2.1" + } + }, + "categories": { + "correctness": "error", + "perf": "error" + }, + "options": { + "typeAware": true + }, + "env": { + "builtin": true, + "es2026": true + }, + "ignorePatterns": [ + "**/node_modules", + ".yarn", + ".github/helm", + ".git", + ".vscode", + ".context", + ".codex", + ".yarnrc.yml", + ".docker", + "**/.storybook", + ".coverage", + ".nx/**", + "target", + "test-results", + "**/dist", + "**/lib", + "**/storybook-static", + "**/web-static", + "**/public", + "**/e2e-dist-*", + "**/static", + "**/*.gen.ts", + "**/*.gql", + "**/*.d.ts", + "tools/cli/src/webpack/error-handler.js", + "packages/backend/native/index.d.ts", + "packages/backend/server/src/__tests__/__snapshots__", + "packages/common/native/fixtures/**", + "packages/common/graphql/src/graphql/index.ts", + "packages/frontend/native/index.d.ts", + "packages/frontend/native/index.js", + "packages/frontend/apps/android/App/**", + "packages/frontend/apps/ios/App/**", + "tests/blocksuite/snapshots", + "blocksuite/docs/api/**", + "blocksuite/docs-site/.vitepress/.temp/**", + "blocksuite/docs-site/api/**", + "packages/frontend/admin/src/config.json", + "**/test-docs.json", + "**/test-blocks.json" + ], + "rules": { + "no-empty-static-block": "error", + "no-misleading-character-class": "error", + "no-new-native-nonconstructor": "error", + "no-unused-private-class-members": "error", + "no-useless-backreference": "error", + "react/display-name": "error", + "react-js/no-deprecated": "error", + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "react-hooks-js/component-hook-factories": "error", + "react-hooks-js/preserve-manual-memoization": "error", + "react-hooks-js/incompatible-library": "warn", + "react-hooks-js/globals": "error", + "react-hooks-js/error-boundaries": "error", + "react-hooks-js/purity": "error", + "react-hooks-js/set-state-in-render": "error", + "react-hooks-js/unsupported-syntax": "warn", + "react-hooks-js/config": "error", + "react-hooks-js/gating": "error", + "typescript/prefer-for-of": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/unified-signatures": "error", + "typescript/await-thenable": "allow", + "typescript/no-floating-promises": "allow", + "typescript/no-misused-promises": "allow", + "typescript/prefer-readonly": "allow", + "typescript/require-array-sort-compare": "allow", + "typescript/return-await": ["error", "error-handling-correctness-only"], + "typescript/no-array-delete": "allow", + "typescript/no-base-to-string": "allow", + "typescript/no-duplicate-type-constituents": "allow", + "typescript/no-for-in-array": "allow", + "typescript/no-implied-eval": "allow", + "typescript/no-meaningless-void-operator": "allow", + "typescript/no-misused-spread": "allow", + "typescript/no-redundant-type-constituents": "allow", + "typescript/no-unnecessary-parameter-property-assignment": "allow", + "typescript/no-unsafe-unary-minus": "allow", + "typescript/no-useless-empty-export": "allow", + "typescript/prefer-namespace-keyword": "allow", + "typescript/prefer-string-starts-ends-with": "allow", + "typescript/restrict-template-expressions": "allow", + "typescript/unbound-method": "allow", + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["**/dist"], + "message": "Don't import from dist", + "allowTypeImports": false + }, + { + "group": ["**/src"], + "message": "Don't import from src", + "allowTypeImports": false + } + ] + } + ], + "no-await-in-loop": "allow", + "no-redeclare": "allow", + "promise/no-callback-in-promise": "allow", + "typescript/ban-types": "allow", + "array-callback-return": "error", + "constructor-super": "error", + "eqeqeq": ["error", "smart"], + "getter-return": "error", + "import/named": "error", + "for-direction": "error", + "require-yield": "error", + "use-isnan": "error", + "valid-typeof": "error", + "no-empty-character-class": "error", + "no-self-compare": "error", + "no-empty": [ + "error", + { + "allowEmptyCatch": true + } + ], + "no-constant-binary-expression": "error", + "no-constructor-return": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-unused-vars": "error", + "no-unused-expressions": [ + "error", + { + "allowShortCircuit": true, + "allowTernary": true, + "allowTaggedTemplates": true + } + ], + "no-ex-assign": "error", + "no-loss-of-precision": "error", + "no-fallthrough": "error", + "no-nonoctal-decimal-escape": "error", + "no-irregular-whitespace": "error", + "no-control-regex": "error", + "no-with": "error", + "no-debugger": "error", + "no-const-assign": "error", + "no-setter-return": "error", + "no-obj-calls": "error", + "no-unsafe-negation": "error", + "no-dupe-class-members": "error", + "no-dupe-keys": "error", + "no-this-before-super": "error", + "no-useless-catch": "error", + "no-async-promise-executor": "error", + "no-unreachable": "error", + "no-unreachable-loop": "error", + "no-duplicate-case": "error", + "no-empty-pattern": "error", + "no-unused-labels": "error", + "no-sparse-arrays": "error", + "no-delete-var": "error", + "no-compare-neg-zero": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-var": "error", + "no-self-assign": "error", + "no-inner-declarations": "error", + "no-dupe-else-if": "error", + "no-invalid-regexp": "error", + "no-unsafe-finally": "error", + "no-prototype-builtins": "error", + "no-array-constructor": "error", + "no-shadow-restricted-names": "error", + "no-constant-condition": "error", + "no-useless-escape": "error", + "no-unsafe-optional-chaining": "error", + "no-extra-boolean-cast": "error", + "no-regex-spaces": "error", + "react/jsx-no-useless-fragment": "error", + "react/jsx-no-undef": "error", + "react/no-direct-mutation-state": "error", + "react/no-string-refs": "error", + "react/no-unknown-property": "error", + "react/no-danger-with-children": "error", + "react/no-unescaped-entities": "error", + "react/require-render-return": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-key": "error", + "react/no-is-mounted": "error", + "react/no-find-dom-node": "error", + "react/no-children-prop": "error", + "react/no-render-return-value": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-comment-textnodes": "error", + "react/no-array-index-key": "off", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "sonarjs/no-all-duplicated-branches": "error", + "sonarjs/no-element-overwrite": "error", + "sonarjs/no-empty-collection": "error", + "sonarjs/no-extra-arguments": "error", + "sonarjs/no-identical-conditions": "error", + "sonarjs/no-identical-expressions": "error", + "sonarjs/no-ignored-return": "error", + "sonarjs/no-use-of-empty-return-value": "error", + "sonarjs/non-existent-operator": "error", + "sonarjs/no-collapsible-if": "error", + "sonarjs/no-same-line-conditional": "error", + "sonarjs/no-duplicated-branches": "error", + "sonarjs/no-collection-size-mischeck": "error", + "sonarjs/no-identical-functions": "error", + "sonarjs/no-gratuitous-expressions": "error", + "lit/attribute-value-entities": "error", + "lit/binding-positions": "error", + "lit/no-duplicate-template-bindings": "error", + "lit/no-invalid-html": "error", + "lit/no-legacy-template-syntax": "error", + "lit/no-property-change-update": "error", + "typescript/consistent-type-imports": "error", + "typescript/no-non-null-assertion": "error", + "typescript/triple-slash-reference": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-misused-new": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-this-alias": [ + "error", + { + "allowDestructuring": true + } + ], + "typescript/prefer-as-const": "error", + "typescript/no-var-requires": "error", + "typescript/no-namespace": [ + "error", + { + "allowDeclarations": true + } + ], + "typescript/ban-ts-comment": [ + "error", + { + "ts-expect-error": "allow-with-description", + "ts-ignore": true, + "ts-nocheck": true, + "ts-check": false + } + ], + "unicorn/prefer-array-some": "error", + "unicorn/prefer-array-flat-map": "off", + "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-unnecessary-await": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/filename-case": [ + "error", + { + "case": "kebabCase" + } + ], + "unicorn/prefer-dom-node-dataset": "error", + "unicorn/prefer-dom-node-append": "error", + "unicorn/prefer-dom-node-remove": "error", + "unicorn/prefer-date-now": "error", + "unicorn/prefer-blob-reading-methods": "error", + "unicorn/no-typeof-undefined": "error", + "unicorn/no-new-array": "error", + "unicorn/new-for-builtins": "error", + "unicorn/prefer-node-protocol": "error", + "import/no-cycle": [ + "error", + { + "ignoreTypes": true + } + ], + "import/namespace": "off", + "import/no-webpack-loader-syntax": "error", + "import/no-duplicates": "error", + "no-import-assign": "error", + "import/no-self-import": "error" + }, + "overrides": [ + { + "files": ["*.stories.tsx"], + "rules": { + "react/no-array-index-key": "off" + } + }, + { + "files": ["packages/backend/**/*.ts"], + "rules": { + "typescript/consistent-type-imports": "off" + } + }, + { + "files": [ + "*.{spec,test,e2e,stories}.{ts,tsx}", + "tests/**/*", + "**/__tests__/**/*", + "tools/**/*" + ], + "rules": { + "typescript/no-non-null-assertion": "off", + "typescript/ban-ts-comment": [ + "error", + { + "ts-expect-error": false, + "ts-ignore": true, + "ts-nocheck": true, + "ts-check": false + } + ] + } + }, + { + "files": ["blocksuite/**/*.ts"], + "rules": { + "eqeqeq": "off", + "typescript/no-non-null-assertion": "off", + "unicorn/prefer-array-some": "off" + } + }, + { + "files": ["tests/blocksuite/**/*.ts"], + "rules": { + "typescript/ban-ts-comment": "off", + "unicorn/prefer-dom-node-dataset": "off", + "typescript/consistent-type-imports": "off", + "import/no-cycle": "off" + } + }, + { + "files": [ + "packages/**/*.{ts,tsx}", + "tools/**/*.{ts,tsx}", + "blocksuite/**/*.{ts,tsx}" + ], + "rules": { + "typescript/await-thenable": "error", + "typescript/no-floating-promises": [ + "error", + { + "ignoreVoid": false, + "ignoreIIFE": false + } + ], + "typescript/no-misused-promises": "error", + "typescript/prefer-readonly": "error", + "typescript/require-array-sort-compare": "error", + "react/exhaustive-deps": [ + "warn", + { + "additionalHooks": "(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)" + } + ] + } + }, + { + "files": [ + "**/__tests__/**/*", + "**/*.stories.tsx", + "**/*.spec.ts", + "**/tests/**/*", + "scripts/**/*", + "**/benchmark/**/*", + "**/__debug__/**/*", + "**/e2e/**/*" + ], + "rules": { + "typescript/no-floating-promises": [ + "error", + { + "ignoreVoid": true, + "ignoreIIFE": false + } + ], + "typescript/no-misused-promises": "off", + "no-restricted-imports": "off" + } + }, + { + "files": ["**/*.{ts,js,mjs}"], + "rules": { + "react/rules-of-hooks": "off" + } + }, + { + "files": ["blocksuite/framework/std/src/view/element/lit-host.ts"], + "rules": { + "lit/binding-positions": "off", + "lit/no-invalid-html": "off" + } + }, + { + "files": [ + "packages/**/*.{ts,tsx}", + "tools/**/*.{ts,tsx}", + "blocksuite/**/*.{ts,tsx}" + ], + "rules": { + "import-x-js/no-extraneous-dependencies": [ + "error", + { + "includeInternal": true + } + ] + } + }, + { + "files": ["packages/frontend/admin/**/*"], + "rules": { + "import-x-js/no-extraneous-dependencies": [ + "error", + { + "includeInternal": true, + "whitelist": ["@affine/admin"] + } + ] + } + }, + { + "files": ["packages/frontend/core/**/*"], + "rules": { + "import-x-js/no-extraneous-dependencies": [ + "error", + { + "includeInternal": true, + "whitelist": ["@affine/core"] + } + ] + } + }, + { + "files": ["packages/frontend/component/**/*"], + "rules": { + "import-x-js/no-extraneous-dependencies": [ + "error", + { + "includeInternal": true, + "whitelist": ["@affine/component"] + } + ] + } + }, + { + "files": [ + "packages/frontend/apps/electron/scripts/**/*", + "tests/blocksuite/**/*.{ts,tsx}", + "blocksuite/**/__tests__/**/*.{ts,tsx}" + ], + "rules": { + "import-x-js/no-extraneous-dependencies": "off" + } + } + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..236d7b6b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,47 @@ +# shared by oxfmt|oxlint +**/node_modules +.yarn +.github/helm +.git +.vscode +.context +.codex +.yarnrc.yml +.docker +**/.storybook + +# compiled output +.coverage +.nx/** +target +test-results +**/dist +**/lib +**/storybook-static +**/web-static +**/public +**/e2e-dist-* +**/static + +# generated files +**/*.gen.ts +**/*.gql +**/*.d.ts + +# per files +tools/cli/src/webpack/error-handler.js +packages/backend/native/index.d.ts +packages/backend/server/src/__tests__/__snapshots__ +packages/common/native/fixtures/** +packages/common/graphql/src/graphql/index.ts +packages/frontend/native/index.d.ts +packages/frontend/native/index.js +packages/frontend/apps/android/App/** +packages/frontend/apps/ios/App/** +tests/blocksuite/snapshots +blocksuite/docs/api/** +blocksuite/docs-site/.vitepress/.temp/** +blocksuite/docs-site/api/** +packages/frontend/admin/src/config.json +**/test-docs.json +**/test-blocks.json diff --git a/.render/Dockerfile b/.render/Dockerfile new file mode 100644 index 00000000..507cca3d --- /dev/null +++ b/.render/Dockerfile @@ -0,0 +1,8 @@ +# syntax=docker/dockerfile:1.7 + +# Adds a start script to the published AFFiNE image. Nothing is compiled here. +FROM ghcr.io/toeverything/affine:stable + +COPY --chmod=0755 start.sh /usr/local/bin/render-start.sh + +CMD ["/usr/local/bin/render-start.sh"] diff --git a/.render/start.sh b/.render/start.sh new file mode 100755 index 00000000..92464804 --- /dev/null +++ b/.render/start.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +# Use the hostname Render assigns, unless AFFINE_SERVER_HOST is already set to a +# custom domain. +export AFFINE_SERVER_HOST="${AFFINE_SERVER_HOST:-${RENDER_EXTERNAL_HOSTNAME:-localhost}}" + +# Generates the server private key on first boot, then runs the Prisma and data +# migrations. Safe to run on every boot. +node ./scripts/self-host-predeploy.js + +exec node ./dist/main.js diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 00000000..464186ec --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,12 @@ +exclude = [ + "node_modules/**/*.toml", + "target/**/*.toml", + "packages/frontend/apps/ios/App/Packages/AffineGraphQL/**/*.toml", + "packages/frontend/apps/ios/App/Packages/Intelligents/**/*.toml", +] + +# https://taplo.tamasfe.dev/configuration/formatter-options.html +[formatting] +align_entries = true +indent_tables = true +reorder_keys = true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..8e2a29e5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "ms-playwright.playwright", + "oxc.oxc-vscode", + "deepscan.vscode-deepscan", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/launch.template.json b/.vscode/launch.template.json new file mode 100644 index 00000000..0d91bf8b --- /dev/null +++ b/.vscode/launch.template.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch AFFiNE Cloud", + "type": "node", + "request": "launch", + "runtimeExecutable": "yarn", + "cwd": "${workspaceFolder}", + "runtimeArgs": [ + "affine", + "@affine/server", + "dev" + ] + }, + { + "name": "Lanuch AFFiNE Web", + "type": "node", + "request": "launch", + "runtimeExecutable": "yarn", + "cwd": "${workspaceFolder}", + "runtimeArgs": [ + "affine", + "@affine/web", + "dev" + ] + }, + { + "type": "chrome", + "request": "launch", + "name": "Debug AFFiNE Web", + "url": "http://localhost:8080" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.template.json b/.vscode/settings.template.json new file mode 100644 index 00000000..8899bd72 --- /dev/null +++ b/.vscode/settings.template.json @@ -0,0 +1,39 @@ +{ + "prisma.pinToPrisma6": true, + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "cSpell.words": [ + "blocksuite", + "livedemo", + "yarn", + "jwst", + "testid", + "octobase", + "selfhosted", + "testid", + "schemars" + ], + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map", + "package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, .oxlintrc.json, oxlint.json, nyc.config.*", + "Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml", + "README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*", + ".gitignore": ".gitattributes, .dockerignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore" + }, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"], + "rust-analyzer.check.extraEnv": { + "DATABASE_URL": "sqlite:affine.db" + }, + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch b/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch new file mode 100644 index 00000000..fbcf0013 --- /dev/null +++ b/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch @@ -0,0 +1,19 @@ +diff --git a/dist/esm/adapter/external-adapter.js b/dist/esm/adapter/external-adapter.js +index ef7a963d91f08c9e70c8ed9c6b41972bec349319..e682841ec10a4a8a9ce7a79642e58de5c9e664d5 100644 +--- a/dist/esm/adapter/external-adapter.js ++++ b/dist/esm/adapter/external-adapter.js +@@ -54,9 +54,11 @@ var adapter = makeAdapter({ + type: 'dragenter', + listener: function listener(event) { + // drag operation was started within the document, it won't be an "external" drag +- if (didDragStartLocally) { +- return; +- } ++ ++ // we will handle all events actually ++ // if (didDragStartLocally) { ++ // return; ++ // } + + // Note: not checking if event was cancelled (`event.defaultPrevented`) as + // cancelling a "dragenter" accepts the drag operation (not prevent it) diff --git a/.yarn/patches/@magic-works-i18n-codegen-virtual-25e53a17d6.patch b/.yarn/patches/@magic-works-i18n-codegen-virtual-25e53a17d6.patch new file mode 100644 index 00000000..fad2274f --- /dev/null +++ b/.yarn/patches/@magic-works-i18n-codegen-virtual-25e53a17d6.patch @@ -0,0 +1,26 @@ +diff --git a/dist/frameworks/i18next/generator/react-hooks.js b/dist/frameworks/i18next/generator/react-hooks.js +index ae1953ca1baceb09e587eda931567c8d8c5d671e..3bc918f6ff4f378e297d1fe927dcc90cf20c821e 100644 +--- a/dist/frameworks/i18next/generator/react-hooks.js ++++ b/dist/frameworks/i18next/generator/react-hooks.js +@@ -99,7 +99,7 @@ export function i18NextReactHooksGenerator(gen) { + // Codegen + const printedSourceFile = [ + gen.generatorOptions?.emitTS && '// @ts-nocheck', +- '/* eslint-disable */', ++ '/* oxlint-disable */', + printer.printFile(factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.Synthesized)), + ] + .filter(Boolean) +diff --git a/src/frameworks/i18next/generator/react-hooks.ts b/src/frameworks/i18next/generator/react-hooks.ts +index bbaf10a2b482e6118e786e634d15abab31c7ba32..a2dfd09ad422cb24c986d1a4ee3458197e70d5c2 100644 +--- a/src/frameworks/i18next/generator/react-hooks.ts ++++ b/src/frameworks/i18next/generator/react-hooks.ts +@@ -203,7 +203,7 @@ export function i18NextReactHooksGenerator(gen: GenType) { + // Codegen + const printedSourceFile = [ + gen.generatorOptions?.emitTS && '// @ts-nocheck', +- '/* eslint-disable */', ++ '/* oxlint-disable */', + printer.printFile( + factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.Synthesized), + ), diff --git a/.yarn/patches/@nestjs-testing-npm-11.1.18-32c0f6af12.patch b/.yarn/patches/@nestjs-testing-npm-11.1.18-32c0f6af12.patch new file mode 100644 index 00000000..6de89f67 --- /dev/null +++ b/.yarn/patches/@nestjs-testing-npm-11.1.18-32c0f6af12.patch @@ -0,0 +1,36 @@ +diff --git a/testing-module.d.ts b/testing-module.d.ts +index 0b08dfe24534c605f58f2104255eb2a20c3fb566..8fad3ab267decccca2d4d9a8e208ace2cd811e92 100644 +--- a/testing-module.d.ts ++++ b/testing-module.d.ts +@@ -13,6 +13,7 @@ export declare class TestingModule extends NestApplicationContext { + protected readonly graphInspector: GraphInspector; + constructor(container: NestContainer, graphInspector: GraphInspector, contextModule: Module, applicationConfig: ApplicationConfig, scope?: Type[]); + private isHttpServer; ++ useCustomApplicationConstructor(Ctor: Type): void; + createNestApplication(httpAdapter: HttpServer | AbstractHttpAdapter, options?: NestApplicationOptions): T; + createNestApplication(options?: NestApplicationOptions): T; + createNestMicroservice(options: NestMicroserviceOptions & T): INestMicroservice; +diff --git a/testing-module.js b/testing-module.js +index 17957b409b224bc43c7e40a1071cf08061366063..6bc4e8a694fdec02df91e512131ffd70259d8859 100644 +--- a/testing-module.js ++++ b/testing-module.js +@@ -15,6 +15,9 @@ class TestingModule extends core_1.NestApplicationContext { + this.applicationConfig = applicationConfig; + this.graphInspector = graphInspector; + } ++ useCustomApplicationConstructor(Ctor) { ++ this.applicationConstructor = Ctor; ++ } + isHttpServer(serverOrOptions) { + return !!(serverOrOptions && serverOrOptions.patch); + } +@@ -24,7 +27,8 @@ class TestingModule extends core_1.NestApplicationContext { + : [this.createHttpAdapter(), serverOrOptions]; + this.applyLogger(appOptions); + this.container.setHttpAdapter(httpAdapter); +- const instance = new core_1.NestApplication(this.container, httpAdapter, this.applicationConfig, this.graphInspector, appOptions); ++ const Ctor = this.applicationConstructor || core_1.NestApplication; ++ const instance = new Ctor(this.container, httpAdapter, this.applicationConfig, this.graphInspector, appOptions); + return this.createAdapterProxy(instance, httpAdapter); + } + createNestMicroservice(options) { diff --git a/.yarn/patches/decode-named-character-reference-npm-1.0.2-db17a755fd.patch b/.yarn/patches/decode-named-character-reference-npm-1.0.2-db17a755fd.patch new file mode 100644 index 00000000..716784a9 --- /dev/null +++ b/.yarn/patches/decode-named-character-reference-npm-1.0.2-db17a755fd.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 5fef2811aa86f3f1f8228daef7d867863e71db72..b795fbd2a0e1cba0b6389ff051220f4e3c52fc13 100644 +--- a/package.json ++++ b/package.json +@@ -34,7 +34,7 @@ + "deno": "./index.js", + "react-native": "./index.js", + "worker": "./index.js", +- "browser": "./index.dom.js", ++ "browser": "./index.js", + "default": "./index.js" + } + }, diff --git a/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch b/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch new file mode 100644 index 00000000..6c0c42ce --- /dev/null +++ b/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch @@ -0,0 +1,54 @@ +diff --git a/dist/tinykeys.module.js b/dist/tinykeys.module.js +deleted file mode 100644 +index cc374b8fc33dbd90e5daf525e929fd77c89c9c5d..0000000000000000000000000000000000000000 +--- a/dist/tinykeys.module.js ++++ /dev/null +@@ -1,2 +0,0 @@ +-var t=["Shift","Meta","Alt","Control"],e="object"==typeof navigator?navigator.platform:"",n=/Mac|iPod|iPhone|iPad/.test(e),r=n?"Meta":"Control",o="Win32"===e?["Control","Alt"]:n?["Alt"]:[];function i(t,e){return"function"==typeof t.getModifierState&&(t.getModifierState(e)||o.includes(e)&&t.getModifierState("AltGraph"))}function a(t){return t.trim().split(" ").map(function(t){var e=t.split(/\b\+/),n=e.pop();return[e=e.map(function(t){return"$mod"===t?r:t}),n]})}function u(e,n){var r;void 0===n&&(n={});var o=null!=(r=n.timeout)?r:1e3,u=Object.keys(e).map(function(t){return[a(t),e[t]]}),c=new Map,f=null;return function(e){e instanceof KeyboardEvent&&(u.forEach(function(n){var r=n[0],o=n[1],a=c.get(r)||r;!function(e,n){return!(n[1].toUpperCase()!==e.key.toUpperCase()&&n[1]!==e.code||n[0].find(function(t){return!i(e,t)})||t.find(function(t){return!n[0].includes(t)&&n[1]!==t&&i(e,t)}))}(e,a[0])?i(e,e.key)||c.delete(r):a.length>1?c.set(r,a.slice(1)):(c.delete(r),o(e))}),f&&clearTimeout(f),f=setTimeout(c.clear.bind(c),o))}}function c(t,e,n){var r;void 0===n&&(n={});var o=null!=(r=n.event)?r:"keydown",i=u(e,n);return t.addEventListener(o,i),function(){t.removeEventListener(o,i)}}export{u as createKeybindingsHandler,a as parseKeybinding,c as tinykeys}; +-//# sourceMappingURL=tinykeys.module.js.map +diff --git a/dist/tinykeys.module.js.map b/dist/tinykeys.module.js.map +deleted file mode 100644 +index 0959712c165780d0326d8a524518d9577b0d7f2e..0000000000000000000000000000000000000000 +--- a/dist/tinykeys.module.js.map ++++ /dev/null +@@ -1 +0,0 @@ +-{"version":3,"file":"tinykeys.module.js","sources":["../src/tinykeys.ts"],"sourcesContent":["type KeyBindingPress = [string[], string]\n\n/**\n * A map of keybinding strings to event handlers.\n */\nexport interface KeyBindingMap {\n\t[keybinding: string]: (event: KeyboardEvent) => void\n}\n\nexport interface KeyBindingHandlerOptions {\n\t/**\n\t * Keybinding sequences will wait this long between key presses before\n\t * cancelling (default: 1000).\n\t *\n\t * **Note:** Setting this value too low (i.e. `300`) will be too fast for many\n\t * of your users.\n\t */\n\ttimeout?: number\n}\n\n/**\n * Options to configure the behavior of keybindings.\n */\nexport interface KeyBindingOptions extends KeyBindingHandlerOptions {\n\t/**\n\t * Key presses will listen to this event (default: \"keydown\").\n\t */\n\tevent?: \"keydown\" | \"keyup\"\n}\n\n/**\n * These are the modifier keys that change the meaning of keybindings.\n *\n * Note: Ignoring \"AltGraph\" because it is covered by the others.\n */\nlet KEYBINDING_MODIFIER_KEYS = [\"Shift\", \"Meta\", \"Alt\", \"Control\"]\n\n/**\n * Keybinding sequences should timeout if individual key presses are more than\n * 1s apart by default.\n */\nlet DEFAULT_TIMEOUT = 1000\n\n/**\n * Keybinding sequences should bind to this event by default.\n */\nlet DEFAULT_EVENT = \"keydown\"\n\n/**\n * Platform detection code.\n * @see https://github.com/jamiebuilds/tinykeys/issues/184\n */\nlet PLATFORM = typeof navigator === \"object\" ? navigator.platform : \"\"\nlet APPLE_DEVICE = /Mac|iPod|iPhone|iPad/.test(PLATFORM)\n\n/**\n * An alias for creating platform-specific keybinding aliases.\n */\nlet MOD = APPLE_DEVICE ? \"Meta\" : \"Control\"\n\n/**\n * Meaning of `AltGraph`, from MDN:\n * - Windows: Both Alt and Ctrl keys are pressed, or AltGr key is pressed\n * - Mac: ⌥ Option key pressed\n * - Linux: Level 3 Shift key (or Level 5 Shift key) pressed\n * - Android: Not supported\n * @see https://github.com/jamiebuilds/tinykeys/issues/185\n */\nlet ALT_GRAPH_ALIASES =\n\tPLATFORM === \"Win32\" ? [\"Control\", \"Alt\"] : APPLE_DEVICE ? [\"Alt\"] : []\n\n/**\n * There's a bug in Chrome that causes event.getModifierState not to exist on\n * KeyboardEvent's for F1/F2/etc keys.\n */\nfunction getModifierState(event: KeyboardEvent, mod: string) {\n\treturn typeof event.getModifierState === \"function\"\n\t\t? event.getModifierState(mod) ||\n\t\t\t\t(ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState(\"AltGraph\"))\n\t\t: false\n}\n\n/**\n * Parses a \"Key Binding String\" into its parts\n *\n * grammar = ``\n * = ` ...`\n * = `` or `+`\n * = `++...`\n */\nexport function parseKeybinding(str: string): KeyBindingPress[] {\n\treturn str\n\t\t.trim()\n\t\t.split(\" \")\n\t\t.map(press => {\n\t\t\tlet mods = press.split(/\\b\\+/)\n\t\t\tlet key = mods.pop() as string\n\t\t\tmods = mods.map(mod => (mod === \"$mod\" ? MOD : mod))\n\t\t\treturn [mods, key]\n\t\t})\n}\n\n/**\n * This tells us if a series of events matches a key binding sequence either\n * partially or exactly.\n */\nfunction match(event: KeyboardEvent, press: KeyBindingPress): boolean {\n\t// prettier-ignore\n\treturn !(\n\t\t// Allow either the `event.key` or the `event.code`\n\t\t// MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key\n\t\t// MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code\n\t\t(\n\t\t\tpress[1].toUpperCase() !== event.key.toUpperCase() &&\n\t\t\tpress[1] !== event.code\n\t\t) ||\n\n\t\t// Ensure all the modifiers in the keybinding are pressed.\n\t\tpress[0].find(mod => {\n\t\t\treturn !getModifierState(event, mod)\n\t\t}) ||\n\n\t\t// KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a\n\t\t// keybinding. So if they are pressed but aren't part of the current\n\t\t// keybinding press, then we don't have a match.\n\t\tKEYBINDING_MODIFIER_KEYS.find(mod => {\n\t\t\treturn !press[0].includes(mod) && press[1] !== mod && getModifierState(event, mod)\n\t\t})\n\t)\n}\n\n/**\n * Creates an event listener for handling keybindings.\n *\n * @example\n * ```js\n * import { createKeybindingsHandler } from \"../src/keybindings\"\n *\n * let handler = createKeybindingsHandler({\n * \t\"Shift+d\": () => {\n * \t\talert(\"The 'Shift' and 'd' keys were pressed at the same time\")\n * \t},\n * \t\"y e e t\": () => {\n * \t\talert(\"The keys 'y', 'e', 'e', and 't' were pressed in order\")\n * \t},\n * \t\"$mod+d\": () => {\n * \t\talert(\"Either 'Control+d' or 'Meta+d' were pressed\")\n * \t},\n * })\n *\n * window.addEvenListener(\"keydown\", handler)\n * ```\n */\nexport function createKeybindingsHandler(\n\tkeyBindingMap: KeyBindingMap,\n\toptions: KeyBindingHandlerOptions = {},\n): EventListener {\n\tlet timeout = options.timeout ?? DEFAULT_TIMEOUT\n\n\tlet keyBindings = Object.keys(keyBindingMap).map(key => {\n\t\treturn [parseKeybinding(key), keyBindingMap[key]] as const\n\t})\n\n\tlet possibleMatches = new Map()\n\tlet timer: number | null = null\n\n\treturn event => {\n\t\t// Ensure and stop any event that isn't a full keyboard event.\n\t\t// Autocomplete option navigation and selection would fire a instanceof Event,\n\t\t// instead of the expected KeyboardEvent\n\t\tif (!(event instanceof KeyboardEvent)) {\n\t\t\treturn\n\t\t}\n\n\t\tkeyBindings.forEach(keyBinding => {\n\t\t\tlet sequence = keyBinding[0]\n\t\t\tlet callback = keyBinding[1]\n\n\t\t\tlet prev = possibleMatches.get(sequence)\n\t\t\tlet remainingExpectedPresses = prev ? prev : sequence\n\t\t\tlet currentExpectedPress = remainingExpectedPresses[0]\n\n\t\t\tlet matches = match(event, currentExpectedPress)\n\n\t\t\tif (!matches) {\n\t\t\t\t// Modifier keydown events shouldn't break sequences\n\t\t\t\t// Note: This works because:\n\t\t\t\t// - non-modifiers will always return false\n\t\t\t\t// - if the current keypress is a modifier then it will return true when we check its state\n\t\t\t\t// MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState\n\t\t\t\tif (!getModifierState(event, event.key)) {\n\t\t\t\t\tpossibleMatches.delete(sequence)\n\t\t\t\t}\n\t\t\t} else if (remainingExpectedPresses.length > 1) {\n\t\t\t\tpossibleMatches.set(sequence, remainingExpectedPresses.slice(1))\n\t\t\t} else {\n\t\t\t\tpossibleMatches.delete(sequence)\n\t\t\t\tcallback(event)\n\t\t\t}\n\t\t})\n\n\t\tif (timer) {\n\t\t\tclearTimeout(timer)\n\t\t}\n\n\t\ttimer = setTimeout(possibleMatches.clear.bind(possibleMatches), timeout)\n\t}\n}\n\n/**\n * Subscribes to keybindings.\n *\n * Returns an unsubscribe method.\n *\n * @example\n * ```js\n * import { tinykeys } from \"../src/tinykeys\"\n *\n * tinykeys(window, {\n * \t\"Shift+d\": () => {\n * \t\talert(\"The 'Shift' and 'd' keys were pressed at the same time\")\n * \t},\n * \t\"y e e t\": () => {\n * \t\talert(\"The keys 'y', 'e', 'e', and 't' were pressed in order\")\n * \t},\n * \t\"$mod+d\": () => {\n * \t\talert(\"Either 'Control+d' or 'Meta+d' were pressed\")\n * \t},\n * })\n * ```\n */\nexport function tinykeys(\n\ttarget: Window | HTMLElement,\n\tkeyBindingMap: KeyBindingMap,\n\toptions: KeyBindingOptions = {},\n): () => void {\n\tlet event = options.event ?? DEFAULT_EVENT\n\tlet onKeyEvent = createKeybindingsHandler(keyBindingMap, options)\n\n\ttarget.addEventListener(event, onKeyEvent)\n\n\treturn () => {\n\t\ttarget.removeEventListener(event, onKeyEvent)\n\t}\n}\n"],"names":["KEYBINDING_MODIFIER_KEYS","PLATFORM","navigator","platform","APPLE_DEVICE","test","MOD","ALT_GRAPH_ALIASES","getModifierState","event","mod","includes","parseKeybinding","str","trim","split","map","press","mods","key","pop","createKeybindingsHandler","keyBindingMap","options","timeout","_options$timeout","keyBindings","Object","keys","possibleMatches","Map","timer","KeyboardEvent","forEach","keyBinding","sequence","callback","remainingExpectedPresses","get","toUpperCase","code","find","match","length","set","slice","clearTimeout","setTimeout","clear","bind","tinykeys","target","_options$event","onKeyEvent","addEventListener","removeEventListener"],"mappings":"AAmCA,IAAIA,EAA2B,CAAC,QAAS,OAAQ,MAAO,WAiBpDC,EAAgC,iBAAdC,UAAyBA,UAAUC,SAAW,GAChEC,EAAe,uBAAuBC,KAAKJ,GAK3CK,EAAMF,EAAe,OAAS,UAU9BG,EACU,UAAbN,EAAuB,CAAC,UAAW,OAASG,EAAe,CAAC,OAAS,GAMtE,SAASI,EAAiBC,EAAsBC,GAC/C,MAAyC,mBAA3BD,EAAMD,mBACjBC,EAAMD,iBAAiBE,IACtBH,EAAkBI,SAASD,IAAQD,EAAMD,iBAAiB,sBAY/CI,EAAgBC,GAC/B,OAAOA,EACLC,OACAC,MAAM,KACNC,IAAI,SAAAC,GACJ,IAAIC,EAAOD,EAAMF,MAAM,QACnBI,EAAMD,EAAKE,MAEf,MAAO,CADPF,EAAOA,EAAKF,IAAI,SAAAN,SAAgB,SAARA,EAAiBJ,EAAMI,IACjCS,cAuDDE,EACfC,EACAC,kBAAAA,IAAAA,EAAoC,IAEpC,IAAIC,SAAOC,EAAGF,EAAQC,SAAOC,EApHR,IAsHjBC,EAAcC,OAAOC,KAAKN,GAAeN,IAAI,SAAAG,GAChD,MAAO,CAACP,EAAgBO,GAAMG,EAAcH,MAGzCU,EAAkB,IAAIC,IACtBC,EAAuB,KAE3B,gBAAOtB,GAIAA,aAAiBuB,gBAIvBN,EAAYO,QAAQ,SAAAC,GACnB,IAAIC,EAAWD,EAAW,GACtBE,EAAWF,EAAW,GAGtBG,EADOR,EAAgBS,IAAIH,IACcA,GAzEhD,SAAe1B,EAAsBQ,GAEpC,QAKEA,EAAM,GAAGsB,gBAAkB9B,EAAMU,IAAIoB,eACrCtB,EAAM,KAAOR,EAAM+B,MAIpBvB,EAAM,GAAGwB,KAAK,SAAA/B,GACb,OAAQF,EAAiBC,EAAOC,MAMjCV,EAAyByC,KAAK,SAAA/B,GAC7B,OAAQO,EAAM,GAAGN,SAASD,IAAQO,EAAM,KAAOP,GAAOF,EAAiBC,EAAOC,MAwDhEgC,CAAMjC,EAFO4B,EAAyB,IAU9C7B,EAAiBC,EAAOA,EAAMU,MAClCU,SAAuBM,GAEdE,EAAyBM,OAAS,EAC5Cd,EAAgBe,IAAIT,EAAUE,EAAyBQ,MAAM,KAE7DhB,SAAuBM,GACvBC,EAAS3B,MAIPsB,GACHe,aAAaf,GAGdA,EAAQgB,WAAWlB,EAAgBmB,MAAMC,KAAKpB,GAAkBL,cA0BlD0B,EACfC,EACA7B,EACAC,kBAAAA,IAAAA,EAA6B,IAE7B,IAAId,SAAK2C,EAAG7B,EAAQd,OAAK2C,EA9LN,UA+LfC,EAAahC,EAAyBC,EAAeC,GAIzD,OAFA4B,EAAOG,iBAAiB7C,EAAO4C,cAG9BF,EAAOI,oBAAoB9C,EAAO4C"} +\ No newline at end of file +diff --git a/dist/tinykeys.module.mjs b/dist/tinykeys.module.mjs +new file mode 100644 +index 0000000000000000000000000000000000000000..cc374b8fc33dbd90e5daf525e929fd77c89c9c5d +--- /dev/null ++++ b/dist/tinykeys.module.mjs +@@ -0,0 +1,2 @@ ++var t=["Shift","Meta","Alt","Control"],e="object"==typeof navigator?navigator.platform:"",n=/Mac|iPod|iPhone|iPad/.test(e),r=n?"Meta":"Control",o="Win32"===e?["Control","Alt"]:n?["Alt"]:[];function i(t,e){return"function"==typeof t.getModifierState&&(t.getModifierState(e)||o.includes(e)&&t.getModifierState("AltGraph"))}function a(t){return t.trim().split(" ").map(function(t){var e=t.split(/\b\+/),n=e.pop();return[e=e.map(function(t){return"$mod"===t?r:t}),n]})}function u(e,n){var r;void 0===n&&(n={});var o=null!=(r=n.timeout)?r:1e3,u=Object.keys(e).map(function(t){return[a(t),e[t]]}),c=new Map,f=null;return function(e){e instanceof KeyboardEvent&&(u.forEach(function(n){var r=n[0],o=n[1],a=c.get(r)||r;!function(e,n){return!(n[1].toUpperCase()!==e.key.toUpperCase()&&n[1]!==e.code||n[0].find(function(t){return!i(e,t)})||t.find(function(t){return!n[0].includes(t)&&n[1]!==t&&i(e,t)}))}(e,a[0])?i(e,e.key)||c.delete(r):a.length>1?c.set(r,a.slice(1)):(c.delete(r),o(e))}),f&&clearTimeout(f),f=setTimeout(c.clear.bind(c),o))}}function c(t,e,n){var r;void 0===n&&(n={});var o=null!=(r=n.event)?r:"keydown",i=u(e,n);return t.addEventListener(o,i),function(){t.removeEventListener(o,i)}}export{u as createKeybindingsHandler,a as parseKeybinding,c as tinykeys}; ++//# sourceMappingURL=tinykeys.module.js.map +diff --git a/dist/tinykeys.module.mjs.map b/dist/tinykeys.module.mjs.map +new file mode 100644 +index 0000000000000000000000000000000000000000..0959712c165780d0326d8a524518d9577b0d7f2e +--- /dev/null ++++ b/dist/tinykeys.module.mjs.map +@@ -0,0 +1 @@ ++{"version":3,"file":"tinykeys.module.js","sources":["../src/tinykeys.ts"],"sourcesContent":["type KeyBindingPress = [string[], string]\n\n/**\n * A map of keybinding strings to event handlers.\n */\nexport interface KeyBindingMap {\n\t[keybinding: string]: (event: KeyboardEvent) => void\n}\n\nexport interface KeyBindingHandlerOptions {\n\t/**\n\t * Keybinding sequences will wait this long between key presses before\n\t * cancelling (default: 1000).\n\t *\n\t * **Note:** Setting this value too low (i.e. `300`) will be too fast for many\n\t * of your users.\n\t */\n\ttimeout?: number\n}\n\n/**\n * Options to configure the behavior of keybindings.\n */\nexport interface KeyBindingOptions extends KeyBindingHandlerOptions {\n\t/**\n\t * Key presses will listen to this event (default: \"keydown\").\n\t */\n\tevent?: \"keydown\" | \"keyup\"\n}\n\n/**\n * These are the modifier keys that change the meaning of keybindings.\n *\n * Note: Ignoring \"AltGraph\" because it is covered by the others.\n */\nlet KEYBINDING_MODIFIER_KEYS = [\"Shift\", \"Meta\", \"Alt\", \"Control\"]\n\n/**\n * Keybinding sequences should timeout if individual key presses are more than\n * 1s apart by default.\n */\nlet DEFAULT_TIMEOUT = 1000\n\n/**\n * Keybinding sequences should bind to this event by default.\n */\nlet DEFAULT_EVENT = \"keydown\"\n\n/**\n * Platform detection code.\n * @see https://github.com/jamiebuilds/tinykeys/issues/184\n */\nlet PLATFORM = typeof navigator === \"object\" ? navigator.platform : \"\"\nlet APPLE_DEVICE = /Mac|iPod|iPhone|iPad/.test(PLATFORM)\n\n/**\n * An alias for creating platform-specific keybinding aliases.\n */\nlet MOD = APPLE_DEVICE ? \"Meta\" : \"Control\"\n\n/**\n * Meaning of `AltGraph`, from MDN:\n * - Windows: Both Alt and Ctrl keys are pressed, or AltGr key is pressed\n * - Mac: ⌥ Option key pressed\n * - Linux: Level 3 Shift key (or Level 5 Shift key) pressed\n * - Android: Not supported\n * @see https://github.com/jamiebuilds/tinykeys/issues/185\n */\nlet ALT_GRAPH_ALIASES =\n\tPLATFORM === \"Win32\" ? [\"Control\", \"Alt\"] : APPLE_DEVICE ? [\"Alt\"] : []\n\n/**\n * There's a bug in Chrome that causes event.getModifierState not to exist on\n * KeyboardEvent's for F1/F2/etc keys.\n */\nfunction getModifierState(event: KeyboardEvent, mod: string) {\n\treturn typeof event.getModifierState === \"function\"\n\t\t? event.getModifierState(mod) ||\n\t\t\t\t(ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState(\"AltGraph\"))\n\t\t: false\n}\n\n/**\n * Parses a \"Key Binding String\" into its parts\n *\n * grammar = ``\n * = ` ...`\n * = `` or `+`\n * = `++...`\n */\nexport function parseKeybinding(str: string): KeyBindingPress[] {\n\treturn str\n\t\t.trim()\n\t\t.split(\" \")\n\t\t.map(press => {\n\t\t\tlet mods = press.split(/\\b\\+/)\n\t\t\tlet key = mods.pop() as string\n\t\t\tmods = mods.map(mod => (mod === \"$mod\" ? MOD : mod))\n\t\t\treturn [mods, key]\n\t\t})\n}\n\n/**\n * This tells us if a series of events matches a key binding sequence either\n * partially or exactly.\n */\nfunction match(event: KeyboardEvent, press: KeyBindingPress): boolean {\n\t// prettier-ignore\n\treturn !(\n\t\t// Allow either the `event.key` or the `event.code`\n\t\t// MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key\n\t\t// MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code\n\t\t(\n\t\t\tpress[1].toUpperCase() !== event.key.toUpperCase() &&\n\t\t\tpress[1] !== event.code\n\t\t) ||\n\n\t\t// Ensure all the modifiers in the keybinding are pressed.\n\t\tpress[0].find(mod => {\n\t\t\treturn !getModifierState(event, mod)\n\t\t}) ||\n\n\t\t// KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a\n\t\t// keybinding. So if they are pressed but aren't part of the current\n\t\t// keybinding press, then we don't have a match.\n\t\tKEYBINDING_MODIFIER_KEYS.find(mod => {\n\t\t\treturn !press[0].includes(mod) && press[1] !== mod && getModifierState(event, mod)\n\t\t})\n\t)\n}\n\n/**\n * Creates an event listener for handling keybindings.\n *\n * @example\n * ```js\n * import { createKeybindingsHandler } from \"../src/keybindings\"\n *\n * let handler = createKeybindingsHandler({\n * \t\"Shift+d\": () => {\n * \t\talert(\"The 'Shift' and 'd' keys were pressed at the same time\")\n * \t},\n * \t\"y e e t\": () => {\n * \t\talert(\"The keys 'y', 'e', 'e', and 't' were pressed in order\")\n * \t},\n * \t\"$mod+d\": () => {\n * \t\talert(\"Either 'Control+d' or 'Meta+d' were pressed\")\n * \t},\n * })\n *\n * window.addEvenListener(\"keydown\", handler)\n * ```\n */\nexport function createKeybindingsHandler(\n\tkeyBindingMap: KeyBindingMap,\n\toptions: KeyBindingHandlerOptions = {},\n): EventListener {\n\tlet timeout = options.timeout ?? DEFAULT_TIMEOUT\n\n\tlet keyBindings = Object.keys(keyBindingMap).map(key => {\n\t\treturn [parseKeybinding(key), keyBindingMap[key]] as const\n\t})\n\n\tlet possibleMatches = new Map()\n\tlet timer: number | null = null\n\n\treturn event => {\n\t\t// Ensure and stop any event that isn't a full keyboard event.\n\t\t// Autocomplete option navigation and selection would fire a instanceof Event,\n\t\t// instead of the expected KeyboardEvent\n\t\tif (!(event instanceof KeyboardEvent)) {\n\t\t\treturn\n\t\t}\n\n\t\tkeyBindings.forEach(keyBinding => {\n\t\t\tlet sequence = keyBinding[0]\n\t\t\tlet callback = keyBinding[1]\n\n\t\t\tlet prev = possibleMatches.get(sequence)\n\t\t\tlet remainingExpectedPresses = prev ? prev : sequence\n\t\t\tlet currentExpectedPress = remainingExpectedPresses[0]\n\n\t\t\tlet matches = match(event, currentExpectedPress)\n\n\t\t\tif (!matches) {\n\t\t\t\t// Modifier keydown events shouldn't break sequences\n\t\t\t\t// Note: This works because:\n\t\t\t\t// - non-modifiers will always return false\n\t\t\t\t// - if the current keypress is a modifier then it will return true when we check its state\n\t\t\t\t// MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState\n\t\t\t\tif (!getModifierState(event, event.key)) {\n\t\t\t\t\tpossibleMatches.delete(sequence)\n\t\t\t\t}\n\t\t\t} else if (remainingExpectedPresses.length > 1) {\n\t\t\t\tpossibleMatches.set(sequence, remainingExpectedPresses.slice(1))\n\t\t\t} else {\n\t\t\t\tpossibleMatches.delete(sequence)\n\t\t\t\tcallback(event)\n\t\t\t}\n\t\t})\n\n\t\tif (timer) {\n\t\t\tclearTimeout(timer)\n\t\t}\n\n\t\ttimer = setTimeout(possibleMatches.clear.bind(possibleMatches), timeout)\n\t}\n}\n\n/**\n * Subscribes to keybindings.\n *\n * Returns an unsubscribe method.\n *\n * @example\n * ```js\n * import { tinykeys } from \"../src/tinykeys\"\n *\n * tinykeys(window, {\n * \t\"Shift+d\": () => {\n * \t\talert(\"The 'Shift' and 'd' keys were pressed at the same time\")\n * \t},\n * \t\"y e e t\": () => {\n * \t\talert(\"The keys 'y', 'e', 'e', and 't' were pressed in order\")\n * \t},\n * \t\"$mod+d\": () => {\n * \t\talert(\"Either 'Control+d' or 'Meta+d' were pressed\")\n * \t},\n * })\n * ```\n */\nexport function tinykeys(\n\ttarget: Window | HTMLElement,\n\tkeyBindingMap: KeyBindingMap,\n\toptions: KeyBindingOptions = {},\n): () => void {\n\tlet event = options.event ?? DEFAULT_EVENT\n\tlet onKeyEvent = createKeybindingsHandler(keyBindingMap, options)\n\n\ttarget.addEventListener(event, onKeyEvent)\n\n\treturn () => {\n\t\ttarget.removeEventListener(event, onKeyEvent)\n\t}\n}\n"],"names":["KEYBINDING_MODIFIER_KEYS","PLATFORM","navigator","platform","APPLE_DEVICE","test","MOD","ALT_GRAPH_ALIASES","getModifierState","event","mod","includes","parseKeybinding","str","trim","split","map","press","mods","key","pop","createKeybindingsHandler","keyBindingMap","options","timeout","_options$timeout","keyBindings","Object","keys","possibleMatches","Map","timer","KeyboardEvent","forEach","keyBinding","sequence","callback","remainingExpectedPresses","get","toUpperCase","code","find","match","length","set","slice","clearTimeout","setTimeout","clear","bind","tinykeys","target","_options$event","onKeyEvent","addEventListener","removeEventListener"],"mappings":"AAmCA,IAAIA,EAA2B,CAAC,QAAS,OAAQ,MAAO,WAiBpDC,EAAgC,iBAAdC,UAAyBA,UAAUC,SAAW,GAChEC,EAAe,uBAAuBC,KAAKJ,GAK3CK,EAAMF,EAAe,OAAS,UAU9BG,EACU,UAAbN,EAAuB,CAAC,UAAW,OAASG,EAAe,CAAC,OAAS,GAMtE,SAASI,EAAiBC,EAAsBC,GAC/C,MAAyC,mBAA3BD,EAAMD,mBACjBC,EAAMD,iBAAiBE,IACtBH,EAAkBI,SAASD,IAAQD,EAAMD,iBAAiB,sBAY/CI,EAAgBC,GAC/B,OAAOA,EACLC,OACAC,MAAM,KACNC,IAAI,SAAAC,GACJ,IAAIC,EAAOD,EAAMF,MAAM,QACnBI,EAAMD,EAAKE,MAEf,MAAO,CADPF,EAAOA,EAAKF,IAAI,SAAAN,SAAgB,SAARA,EAAiBJ,EAAMI,IACjCS,cAuDDE,EACfC,EACAC,kBAAAA,IAAAA,EAAoC,IAEpC,IAAIC,SAAOC,EAAGF,EAAQC,SAAOC,EApHR,IAsHjBC,EAAcC,OAAOC,KAAKN,GAAeN,IAAI,SAAAG,GAChD,MAAO,CAACP,EAAgBO,GAAMG,EAAcH,MAGzCU,EAAkB,IAAIC,IACtBC,EAAuB,KAE3B,gBAAOtB,GAIAA,aAAiBuB,gBAIvBN,EAAYO,QAAQ,SAAAC,GACnB,IAAIC,EAAWD,EAAW,GACtBE,EAAWF,EAAW,GAGtBG,EADOR,EAAgBS,IAAIH,IACcA,GAzEhD,SAAe1B,EAAsBQ,GAEpC,QAKEA,EAAM,GAAGsB,gBAAkB9B,EAAMU,IAAIoB,eACrCtB,EAAM,KAAOR,EAAM+B,MAIpBvB,EAAM,GAAGwB,KAAK,SAAA/B,GACb,OAAQF,EAAiBC,EAAOC,MAMjCV,EAAyByC,KAAK,SAAA/B,GAC7B,OAAQO,EAAM,GAAGN,SAASD,IAAQO,EAAM,KAAOP,GAAOF,EAAiBC,EAAOC,MAwDhEgC,CAAMjC,EAFO4B,EAAyB,IAU9C7B,EAAiBC,EAAOA,EAAMU,MAClCU,SAAuBM,GAEdE,EAAyBM,OAAS,EAC5Cd,EAAgBe,IAAIT,EAAUE,EAAyBQ,MAAM,KAE7DhB,SAAuBM,GACvBC,EAAS3B,MAIPsB,GACHe,aAAaf,GAGdA,EAAQgB,WAAWlB,EAAgBmB,MAAMC,KAAKpB,GAAkBL,cA0BlD0B,EACfC,EACA7B,EACAC,kBAAAA,IAAAA,EAA6B,IAE7B,IAAId,SAAK2C,EAAG7B,EAAQd,OAAK2C,EA9LN,UA+LfC,EAAahC,EAAyBC,EAAeC,GAIzD,OAFA4B,EAAOG,iBAAiB7C,EAAO4C,cAG9BF,EAAOI,oBAAoB9C,EAAO4C"} +\ No newline at end of file +diff --git a/package.json b/package.json +index d6a93bb7cce03b4836cf96deba308de0417079a3..ce382afc534607d67b04d877cdf6990769c59d1b 100644 +--- a/package.json ++++ b/package.json +@@ -7,7 +7,7 @@ + "repository": "jamiebuilds/tinykeys", + "source": "src/tinykeys.ts", + "main": "dist/tinykeys.js", +- "module": "dist/tinykeys.module.js", ++ "module": "dist/tinykeys.module.mjs", + "unpkg": "dist/tinykeys.umd.js", + "types": "dist/tinykeys.d.ts", + "files": [ +@@ -15,7 +15,7 @@ + ], + "exports": { + ".": { +- "import": "./dist/tinykeys.module.js", ++ "import": "./dist/tinykeys.module.mjs", + "require": "./dist/tinykeys.js" + } + }, diff --git a/.yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch b/.yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch new file mode 100644 index 00000000..aeb886a4 --- /dev/null +++ b/.yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch @@ -0,0 +1,39 @@ +diff --git a/dist/yjs.cjs b/dist/yjs.cjs +index 8a343ca9d0a153e95b27ad337e0553a8cc80d5ca..7199cf6e05d9c2c3491e56c4d4bda109e1755563 100644 +--- a/dist/yjs.cjs ++++ b/dist/yjs.cjs +@@ -416,7 +416,7 @@ const equalDeleteSets = (ds1, ds2) => { + */ + + +-const generateNewClientId = random__namespace.uint32; ++const generateNewClientId = random__namespace.uint53; + + /** + * @typedef {Object} DocOpts +diff --git a/dist/yjs.mjs b/dist/yjs.mjs +index 1c29ce7fe8f146b78911d0af9a53d1b516e86494..220fa0faacf4dc2a787e18f7cc79100e7c516e3a 100644 +--- a/dist/yjs.mjs ++++ b/dist/yjs.mjs +@@ -379,7 +379,7 @@ const equalDeleteSets = (ds1, ds2) => { + */ + + +-const generateNewClientId = random.uint32; ++const generateNewClientId = random.uint53; + + /** + * @typedef {Object} DocOpts +diff --git a/src/utils/Doc.js b/src/utils/Doc.js +index d5165426f2314fc3c2388e64841e7cd6498a92a9..4bb2e5a8b79bb59f08a011af77e69af862312292 100644 +--- a/src/utils/Doc.js ++++ b/src/utils/Doc.js +@@ -20,7 +20,7 @@ import * as map from 'lib0/map' + import * as array from 'lib0/array' + import * as promise from 'lib0/promise' + +-export const generateNewClientId = random.uint32 ++export const generateNewClientId = random.uint53 + + /** + * @typedef {Object} DocOpts diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..a658d8b4 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,21 @@ +approvedGitRepositories: + - "https://github.com/electron/node-gyp.git" + - "https://github.com/locize/html-parse-stringify2.git" + +compressionLevel: mixed + +enableGlobalCache: true + +enableScripts: false + +nmMode: hardlinks-local + +nodeLinker: node-modules + +npmAuthToken: "${NPM_TOKEN:-NONE}" + +npmPublishAccess: public + +npmRegistryServer: "https://registry.npmjs.org" + +yarnPath: .yarn/releases/yarn-4.18.0.cjs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..8d0901a8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,139 @@ +[workspace] +members = [ + "./packages/backend/native", + "./packages/common/native", + "./packages/frontend/mobile-native", + "./packages/frontend/native", + "./packages/frontend/native/nbstore", + "./packages/frontend/native/schema", + "./packages/frontend/native/sqlite_v1", +] +resolver = "3" + + [workspace.package] + edition = "2024" + + [workspace.dependencies] + aes-gcm = "0.10" + affine_common = { path = "./packages/common/native" } + affine_doc_loader = "0.1.8" + affine_importer = "0.1.2" + affine_nbstore = { path = "./packages/frontend/native/nbstore" } + affine_preview = { version = "0.1.0", default-features = false } + anyhow = "1" + assert-json-diff = "2.0" + base64 = "0.22.1" + base64-simd = "0.8" + block2 = "0.6" + bytes = "1" + chrono = "0.4" + core-foundation = "0.10" + coreaudio-rs = "0.12" + cpal = "0.15" + criterion = { version = "0.5", features = ["html_reports"] } + criterion2 = { version = "3", default-features = false } + crossbeam-channel = "0.5" + dispatch2 = "0.3" + doc_extractor = "0.1.1" + dotenvy = "0.15" + file-format = { version = "0.28", features = ["reader"] } + futures-util = "0.3" + hex = "0.4" + hkdf = "0.13" + homedir = "0.3" + image = { version = "0.25.9", default-features = false, features = [ + "bmp", + "gif", + "jpeg", + "png", + "webp", + ] } + infer = { version = "0.19.0" } + libc = "0.2" + little_exif = "0.6.23" + llm_adapter = { version = "0.2", default-features = false } + llm_runtime = { version = "0.2", default-features = false } + lru = "0.16" + matroska = "0.30" + memory-indexer = "0.4.0" + mimalloc = "0.1" + mp4parse = "0.17" + nanoid = "0.4" + napi = { version = "3.7.0", features = [ + "async", + "chrono_date", + "error_anyhow", + "napi9", + "serde", + ] } + napi-build = { version = "2" } + napi-derive = { version = "3.4" } + objc2 = "0.6" + objc2-foundation = "0.3" + ogg = "0.9" + once_cell = "1" + onenote_parser = { git = "https://github.com/msiemens/onenote.rs", rev = "5138a39a3f4e72b840932f9872fecde52fa9da60", package = "onenote_parser", features = [ + "onepkg", + ] } + p256 = { version = "0.13", features = ["ecdsa", "pem"] } + pulldown-cmark = "0.13" + rand = "0.9" + rayon = "1.10" + redis = { version = "1.6", features = ["tokio-comp", "tokio-rustls-comp"] } + rubato = "0.16" + safefetch = "0.1.0" + schemars = "1.2" + screencapturekit = "0.3" + serde = "1" + serde_json = "1" + sha2 = "0.11" + sha3 = "0.12" + sqlx = { version = "0.8", default-features = false, features = [ + "chrono", + "macros", + "migrate", + "runtime-tokio", + "sqlite", + ] } + symphonia = { version = "0.5", features = ["all", "opt-simd"] } + thiserror = "2" + tiktoken-rs = "0.7" + tokio = "1.45" + typed-path = "0.12.3" + uniffi = "0.29" + url = { version = "2.5" } + uuid = "1.8" + v_htmlescape = "0.15" + windows = { version = "0.62", features = [ + "Win32_Devices_FunctionDiscovery", + "Win32_Foundation", + "Win32_Media_Audio", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_ProcessStatus", + "Win32_System_Threading", + "Win32_System_Variant", + "Win32_UI_Shell_PropertiesSystem", + ] } + windows-core = { version = "0.62" } + y-octo = "0.1.0" + zeroize = "1.9" + zip = "8.6" + +[profile.dev.package.sqlx-macros] +opt-level = 3 + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = 3 +strip = "symbols" + + # android uniffi bindgen requires symbols + [profile.release.package.affine_mobile_native] + strip = "none" + + # [patch.crates-io] + # llm_adapter = { path = "../llm_adapter/crates/llm_adapter" } + # llm_runtime = { path = "../llm_adapter/crates/llm_runtime" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..ee4c98c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2022-present TOEVERYTHING PTE. LTD. and its affiliates. + +Portions of this software are licensed as follows: + +- All content that resides under the "packages/backend" and "packages/common/native" directory of this repository, if that directory exists, is licensed under the license defined in "packages/backend/server/LICENSE". +- All third party components incorporated into the AFFiNE Software are licensed under the original license provided by the owner of the applicable component. +- Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined in "LICENSE-MIT". + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..017855f5 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022-present TOEVERYTHING PTE. LTD. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/blocksuite/affine/all/package.json b/blocksuite/affine/all/package.json new file mode 100644 index 00000000..8ecdf0fd --- /dev/null +++ b/blocksuite/affine/all/package.json @@ -0,0 +1,304 @@ +{ + "name": "@blocksuite/affine", + "description": "BlockSuite for Affine", + "type": "module", + "scripts": { + "build": "yarn run -T tsc --build --verbose" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-block-attachment": "workspace:*", + "@blocksuite/affine-block-bookmark": "workspace:*", + "@blocksuite/affine-block-callout": "workspace:*", + "@blocksuite/affine-block-code": "workspace:*", + "@blocksuite/affine-block-data-view": "workspace:*", + "@blocksuite/affine-block-database": "workspace:*", + "@blocksuite/affine-block-divider": "workspace:*", + "@blocksuite/affine-block-edgeless-text": "workspace:*", + "@blocksuite/affine-block-embed": "workspace:*", + "@blocksuite/affine-block-embed-doc": "workspace:*", + "@blocksuite/affine-block-frame": "workspace:*", + "@blocksuite/affine-block-image": "workspace:*", + "@blocksuite/affine-block-latex": "workspace:*", + "@blocksuite/affine-block-list": "workspace:*", + "@blocksuite/affine-block-note": "workspace:*", + "@blocksuite/affine-block-paragraph": "workspace:*", + "@blocksuite/affine-block-root": "workspace:*", + "@blocksuite/affine-block-surface": "workspace:*", + "@blocksuite/affine-block-surface-ref": "workspace:*", + "@blocksuite/affine-block-table": "workspace:*", + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-foundation": "workspace:*", + "@blocksuite/affine-fragment-adapter-panel": "workspace:*", + "@blocksuite/affine-fragment-doc-title": "workspace:*", + "@blocksuite/affine-fragment-frame-panel": "workspace:*", + "@blocksuite/affine-fragment-outline": "workspace:*", + "@blocksuite/affine-gfx-brush": "workspace:*", + "@blocksuite/affine-gfx-connector": "workspace:*", + "@blocksuite/affine-gfx-group": "workspace:*", + "@blocksuite/affine-gfx-link": "workspace:*", + "@blocksuite/affine-gfx-mindmap": "workspace:*", + "@blocksuite/affine-gfx-note": "workspace:*", + "@blocksuite/affine-gfx-pointer": "workspace:*", + "@blocksuite/affine-gfx-shape": "workspace:*", + "@blocksuite/affine-gfx-template": "workspace:*", + "@blocksuite/affine-gfx-text": "workspace:*", + "@blocksuite/affine-gfx-turbo-renderer": "workspace:*", + "@blocksuite/affine-inline-comment": "workspace:*", + "@blocksuite/affine-inline-footnote": "workspace:*", + "@blocksuite/affine-inline-latex": "workspace:*", + "@blocksuite/affine-inline-link": "workspace:*", + "@blocksuite/affine-inline-mention": "workspace:*", + "@blocksuite/affine-inline-preset": "workspace:*", + "@blocksuite/affine-inline-reference": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-rich-text": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-drag-handle": "workspace:*", + "@blocksuite/affine-widget-edgeless-auto-connect": "workspace:*", + "@blocksuite/affine-widget-edgeless-dragging-area": "workspace:*", + "@blocksuite/affine-widget-edgeless-selected-rect": "workspace:*", + "@blocksuite/affine-widget-edgeless-toolbar": "workspace:*", + "@blocksuite/affine-widget-edgeless-zoom-toolbar": "workspace:*", + "@blocksuite/affine-widget-frame-title": "workspace:*", + "@blocksuite/affine-widget-keyboard-toolbar": "workspace:*", + "@blocksuite/affine-widget-linked-doc": "workspace:*", + "@blocksuite/affine-widget-note-slicer": "workspace:*", + "@blocksuite/affine-widget-page-dragging-area": "workspace:*", + "@blocksuite/affine-widget-remote-selection": "workspace:*", + "@blocksuite/affine-widget-scroll-anchoring": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/affine-widget-toolbar": "workspace:*", + "@blocksuite/affine-widget-viewport-overlay": "workspace:*", + "@blocksuite/data-view": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@blocksuite/sync": "workspace:*" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts", + "./ext-loader": "./src/ext-loader/index.ts", + "./std": "./src/std/index.ts", + "./std/gfx": "./src/std/gfx.ts", + "./std/inline": "./src/std/inline.ts", + "./std/effects": "./src/std/effects.ts", + "./global": "./src/global/index.ts", + "./global/utils": "./src/global/utils.ts", + "./global/env": "./src/global/env.ts", + "./global/exceptions": "./src/global/exceptions.ts", + "./global/di": "./src/global/di.ts", + "./global/types": "./src/global/types.ts", + "./global/gfx": "./src/global/gfx.ts", + "./global/disposable": "./src/global/disposable.ts", + "./global/lit": "./src/global/lit.ts", + "./store": "./src/store/index.ts", + "./store/test": "./src/store/test.ts", + "./blocks/attachment": "./src/blocks/attachment/index.ts", + "./blocks/attachment/store": "./src/blocks/attachment/store.ts", + "./blocks/attachment/view": "./src/blocks/attachment/view.ts", + "./blocks/bookmark": "./src/blocks/bookmark/index.ts", + "./blocks/bookmark/store": "./src/blocks/bookmark/store.ts", + "./blocks/bookmark/view": "./src/blocks/bookmark/view.ts", + "./blocks/callout": "./src/blocks/callout/index.ts", + "./blocks/callout/store": "./src/blocks/callout/store.ts", + "./blocks/callout/view": "./src/blocks/callout/view.ts", + "./blocks/code": "./src/blocks/code/index.ts", + "./blocks/code/store": "./src/blocks/code/store.ts", + "./blocks/code/view": "./src/blocks/code/view.ts", + "./blocks/data-view": "./src/blocks/data-view/index.ts", + "./blocks/data-view/store": "./src/blocks/data-view/store.ts", + "./blocks/data-view/view": "./src/blocks/data-view/view.ts", + "./blocks/database": "./src/blocks/database/index.ts", + "./blocks/database/store": "./src/blocks/database/store.ts", + "./blocks/database/view": "./src/blocks/database/view.ts", + "./blocks/divider": "./src/blocks/divider/index.ts", + "./blocks/divider/store": "./src/blocks/divider/store.ts", + "./blocks/divider/view": "./src/blocks/divider/view.ts", + "./blocks/edgeless-text": "./src/blocks/edgeless-text/index.ts", + "./blocks/edgeless-text/store": "./src/blocks/edgeless-text/store.ts", + "./blocks/edgeless-text/view": "./src/blocks/edgeless-text/view.ts", + "./blocks/embed": "./src/blocks/embed/index.ts", + "./blocks/embed/store": "./src/blocks/embed/store.ts", + "./blocks/embed/view": "./src/blocks/embed/view.ts", + "./blocks/embed-doc": "./src/blocks/embed-doc/index.ts", + "./blocks/embed-doc/store": "./src/blocks/embed-doc/store.ts", + "./blocks/embed-doc/view": "./src/blocks/embed-doc/view.ts", + "./blocks/frame": "./src/blocks/frame/index.ts", + "./blocks/frame/store": "./src/blocks/frame/store.ts", + "./blocks/frame/view": "./src/blocks/frame/view.ts", + "./blocks/image": "./src/blocks/image/index.ts", + "./blocks/image/store": "./src/blocks/image/store.ts", + "./blocks/image/view": "./src/blocks/image/view.ts", + "./blocks/latex": "./src/blocks/latex/index.ts", + "./blocks/latex/store": "./src/blocks/latex/store.ts", + "./blocks/latex/view": "./src/blocks/latex/view.ts", + "./blocks/list": "./src/blocks/list/index.ts", + "./blocks/list/store": "./src/blocks/list/store.ts", + "./blocks/list/view": "./src/blocks/list/view.ts", + "./blocks/note": "./src/blocks/note/index.ts", + "./blocks/note/store": "./src/blocks/note/store.ts", + "./blocks/note/view": "./src/blocks/note/view.ts", + "./blocks/paragraph": "./src/blocks/paragraph/index.ts", + "./blocks/paragraph/store": "./src/blocks/paragraph/store.ts", + "./blocks/paragraph/view": "./src/blocks/paragraph/view.ts", + "./blocks/root": "./src/blocks/root/index.ts", + "./blocks/root/store": "./src/blocks/root/store.ts", + "./blocks/root/view": "./src/blocks/root/view.ts", + "./blocks/surface": "./src/blocks/surface/index.ts", + "./blocks/surface/store": "./src/blocks/surface/store.ts", + "./blocks/surface/view": "./src/blocks/surface/view.ts", + "./blocks/surface-ref": "./src/blocks/surface-ref/index.ts", + "./blocks/surface-ref/store": "./src/blocks/surface-ref/store.ts", + "./blocks/surface-ref/view": "./src/blocks/surface-ref/view.ts", + "./blocks/table": "./src/blocks/table/index.ts", + "./blocks/table/store": "./src/blocks/table/store.ts", + "./blocks/table/view": "./src/blocks/table/view.ts", + "./data-view": "./src/data-view/index.ts", + "./data-view/effects": "./src/data-view/effects.ts", + "./inlines/link": "./src/inlines/link/index.ts", + "./inlines/link/store": "./src/inlines/link/store.ts", + "./inlines/link/view": "./src/inlines/link/view.ts", + "./inlines/reference": "./src/inlines/reference/index.ts", + "./inlines/reference/store": "./src/inlines/reference/store.ts", + "./inlines/reference/view": "./src/inlines/reference/view.ts", + "./inlines/preset": "./src/inlines/preset/index.ts", + "./inlines/preset/store": "./src/inlines/preset/store.ts", + "./inlines/preset/view": "./src/inlines/preset/view.ts", + "./inlines/footnote": "./src/inlines/footnote/index.ts", + "./inlines/footnote/view": "./src/inlines/footnote/view.ts", + "./inlines/footnote/store": "./src/inlines/footnote/store.ts", + "./inlines/comment": "./src/inlines/comment/index.ts", + "./inlines/latex": "./src/inlines/latex/index.ts", + "./inlines/latex/store": "./src/inlines/latex/store.ts", + "./inlines/latex/view": "./src/inlines/latex/view.ts", + "./inlines/mention": "./src/inlines/mention/index.ts", + "./inlines/mention/view": "./src/inlines/mention/view.ts", + "./widgets/drag-handle": "./src/widgets/drag-handle/index.ts", + "./widgets/drag-handle/view": "./src/widgets/drag-handle/view.ts", + "./widgets/edgeless-auto-connect": "./src/widgets/edgeless-auto-connect/index.ts", + "./widgets/edgeless-auto-connect/view": "./src/widgets/edgeless-auto-connect/view.ts", + "./widgets/edgeless-dragging-area": "./src/widgets/edgeless-dragging-area/index.ts", + "./widgets/edgeless-dragging-area/view": "./src/widgets/edgeless-dragging-area/view.ts", + "./widgets/edgeless-toolbar": "./src/widgets/edgeless-toolbar/index.ts", + "./widgets/edgeless-toolbar/view": "./src/widgets/edgeless-toolbar/view.ts", + "./widgets/frame-title": "./src/widgets/frame-title/index.ts", + "./widgets/frame-title/view": "./src/widgets/frame-title/view.ts", + "./widgets/linked-doc": "./src/widgets/linked-doc/index.ts", + "./widgets/linked-doc/view": "./src/widgets/linked-doc/view.ts", + "./widgets/remote-selection": "./src/widgets/remote-selection/index.ts", + "./widgets/remote-selection/view": "./src/widgets/remote-selection/view.ts", + "./widgets/scroll-anchoring": "./src/widgets/scroll-anchoring/index.ts", + "./widgets/scroll-anchoring/view": "./src/widgets/scroll-anchoring/view.ts", + "./widgets/slash-menu": "./src/widgets/slash-menu/index.ts", + "./widgets/slash-menu/view": "./src/widgets/slash-menu/view.ts", + "./widgets/toolbar": "./src/widgets/toolbar/index.ts", + "./widgets/toolbar/view": "./src/widgets/toolbar/view.ts", + "./widgets/keyboard-toolbar": "./src/widgets/keyboard-toolbar/index.ts", + "./widgets/keyboard-toolbar/view": "./src/widgets/keyboard-toolbar/view.ts", + "./widgets/viewport-overlay": "./src/widgets/viewport-overlay/index.ts", + "./widgets/viewport-overlay/view": "./src/widgets/viewport-overlay/view.ts", + "./widgets/page-dragging-area": "./src/widgets/page-dragging-area/index.ts", + "./widgets/page-dragging-area/view": "./src/widgets/page-dragging-area/view.ts", + "./fragments/doc-title": "./src/fragments/doc-title/index.ts", + "./fragments/doc-title/view": "./src/fragments/doc-title/view.ts", + "./fragments/frame-panel": "./src/fragments/frame-panel/index.ts", + "./fragments/frame-panel/view": "./src/fragments/frame-panel/view.ts", + "./fragments/outline": "./src/fragments/outline/index.ts", + "./fragments/outline/view": "./src/fragments/outline/view.ts", + "./fragments/adapter-panel": "./src/fragments/adapter-panel/index.ts", + "./fragments/adapter-panel/view": "./src/fragments/adapter-panel/view.ts", + "./gfx/text": "./src/gfx/text/index.ts", + "./gfx/text/store": "./src/gfx/text/store.ts", + "./gfx/text/view": "./src/gfx/text/view.ts", + "./gfx/brush": "./src/gfx/brush/index.ts", + "./gfx/brush/store": "./src/gfx/brush/store.ts", + "./gfx/brush/view": "./src/gfx/brush/view.ts", + "./gfx/pointer": "./src/gfx/pointer/index.ts", + "./gfx/pointer/view": "./src/gfx/pointer/view.ts", + "./gfx/shape": "./src/gfx/shape/index.ts", + "./gfx/shape/store": "./src/gfx/shape/store.ts", + "./gfx/shape/view": "./src/gfx/shape/view.ts", + "./gfx/link": "./src/gfx/link/index.ts", + "./gfx/link/view": "./src/gfx/link/view.ts", + "./gfx/note": "./src/gfx/note/index.ts", + "./gfx/note/view": "./src/gfx/note/view.ts", + "./gfx/mindmap": "./src/gfx/mindmap/index.ts", + "./gfx/mindmap/store": "./src/gfx/mindmap/store.ts", + "./gfx/mindmap/view": "./src/gfx/mindmap/view.ts", + "./gfx/connector": "./src/gfx/connector/index.ts", + "./gfx/connector/store": "./src/gfx/connector/store.ts", + "./gfx/connector/view": "./src/gfx/connector/view.ts", + "./gfx/group": "./src/gfx/group/index.ts", + "./gfx/group/store": "./src/gfx/group/store.ts", + "./gfx/group/view": "./src/gfx/group/view.ts", + "./gfx/template": "./src/gfx/template/index.ts", + "./gfx/template/view": "./src/gfx/template/view.ts", + "./gfx/turbo-renderer": "./src/gfx/turbo-renderer.ts", + "./components/block-selection": "./src/components/block-selection.ts", + "./components/block-zero-width": "./src/components/block-zero-width.ts", + "./components/caption": "./src/components/caption.ts", + "./components/card-style-dropdown-menu": "./src/components/card-style-dropdown-menu.ts", + "./components/citation": "./src/components/citation.ts", + "./components/color-picker": "./src/components/color-picker.ts", + "./components/context-menu": "./src/components/context-menu.ts", + "./components/date-picker": "./src/components/date-picker.ts", + "./components/drop-indicator": "./src/components/drop-indicator.ts", + "./components/embed-card-modal": "./src/components/embed-card-modal.ts", + "./components/filterable-list": "./src/components/filterable-list.ts", + "./components/highlight-dropdown-menu": "./src/components/highlight-dropdown-menu.ts", + "./components/hover": "./src/components/hover.ts", + "./components/icon-button": "./src/components/icon-button.ts", + "./components/icons": "./src/components/icons.ts", + "./components/link-preview": "./src/components/link-preview.ts", + "./components/linked-doc-title": "./src/components/linked-doc-title.ts", + "./components/notification": "./src/components/notification.ts", + "./components/peek": "./src/components/peek.ts", + "./components/portal": "./src/components/portal.ts", + "./components/smooth-corner": "./src/components/smooth-corner.ts", + "./components/toast": "./src/components/toast.ts", + "./components/toggle-button": "./src/components/toggle-button.ts", + "./components/toggle-switch": "./src/components/toggle-switch.ts", + "./components/toolbar": "./src/components/toolbar.ts", + "./components/tooltip": "./src/components/tooltip.ts", + "./components/view-dropdown-menu": "./src/components/view-dropdown-menu.ts", + "./components/tooltip-content-with-shortcut": "./src/components/tooltip-content-with-shortcut.ts", + "./components/resource": "./src/components/resource.ts", + "./rich-text": "./src/rich-text/index.ts", + "./rich-text/effects": "./src/rich-text/effects.ts", + "./shared/adapters": "./src/shared/adapters.ts", + "./shared/commands": "./src/shared/commands.ts", + "./shared/consts": "./src/shared/consts.ts", + "./shared/selection": "./src/shared/selection.ts", + "./shared/services": "./src/shared/services.ts", + "./shared/styles": "./src/shared/styles.ts", + "./shared/theme": "./src/shared/theme.ts", + "./shared/types": "./src/shared/types.ts", + "./shared/utils": "./src/shared/utils.ts", + "./schemas": "./src/schemas.ts", + "./model": "./src/model/index.ts", + "./sync": "./src/sync/index.ts", + "./extensions/store": "./src/extensions/store.ts", + "./extensions/view": "./src/extensions/view.ts", + "./foundation/clipboard": "./src/foundation/clipboard.ts", + "./foundation/store": "./src/foundation/store.ts", + "./foundation/view": "./src/foundation/view.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5", + "devDependencies": { + "@vanilla-extract/vite-plugin": "^5.0.0", + "msw": "^2.13.2", + "vitest": "^4.1.8" + } +} diff --git a/blocksuite/affine/all/src/__tests__/adapters/__snapshots__/markdown.unit.spec.ts.snap b/blocksuite/affine/all/src/__tests__/adapters/__snapshots__/markdown.unit.spec.ts.snap new file mode 100644 index 00000000..e8013008 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/__snapshots__/markdown.unit.spec.ts.snap @@ -0,0 +1,94 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`snapshot to markdown > imports obsidian vault fixtures 1`] = ` +{ + "entry": { + "children": [ + { + "children": [ + { + "children": [ + { + "delta": [ + { + "insert": "Panel +Body line", + }, + ], + "flavour": "affine:paragraph", + "type": "text", + }, + ], + "emoji": "💡", + "flavour": "affine:callout", + }, + { + "flavour": "affine:attachment", + "name": "archive.zip", + "style": "horizontalThin", + }, + { + "delta": [ + { + "footnote": { + "label": "1", + "reference": { + "title": "reference body", + "type": "url", + }, + }, + "insert": " ", + }, + ], + "flavour": "affine:paragraph", + "type": "text", + }, + { + "flavour": "affine:divider", + }, + { + "delta": [ + { + "insert": "after note", + }, + ], + "flavour": "affine:paragraph", + "type": "text", + }, + { + "delta": [ + { + "insert": " ", + "reference": { + "page": "linked", + "type": "LinkedPage", + }, + }, + ], + "flavour": "affine:paragraph", + "type": "text", + }, + { + "delta": [ + { + "insert": "Sources", + }, + ], + "flavour": "affine:paragraph", + "type": "h6", + }, + { + "flavour": "affine:bookmark", + }, + ], + "flavour": "affine:note", + }, + ], + "flavour": "affine:page", + }, + "titles": [ + "entry", + "linked", + ], +} +`; diff --git a/blocksuite/affine/all/src/__tests__/adapters/fixtures/obsidian/entry.md b/blocksuite/affine/all/src/__tests__/adapters/fixtures/obsidian/entry.md new file mode 100644 index 00000000..f43f9bad --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/fixtures/obsidian/entry.md @@ -0,0 +1,14 @@ +> [!custom] Panel +> Body line + +![[archive.zip]] + +[^1] + +--- + +after note + +[[linked]] + +[^1]: reference body diff --git a/blocksuite/affine/all/src/__tests__/adapters/fixtures/obsidian/linked.md b/blocksuite/affine/all/src/__tests__/adapters/fixtures/obsidian/linked.md new file mode 100644 index 00000000..ab06e6bf --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/fixtures/obsidian/linked.md @@ -0,0 +1 @@ +plain linked page diff --git a/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts new file mode 100644 index 00000000..099869f2 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts @@ -0,0 +1,3182 @@ +import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model'; +import { + embedSyncedDocMiddleware, + HtmlAdapter, +} from '@blocksuite/affine-shared/adapters'; +import type { + BlockSnapshot, + DocSnapshot, + TransformerMiddleware, +} from '@blocksuite/store'; +import { AssetsManager, MemoryBlobCRUD } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { createJob } from '../utils/create-job.js'; +import { getProvider } from '../utils/get-provider.js'; +import { nanoidReplacement } from '../utils/nanoid-replacement.js'; + +const provider = getProvider(); + +describe('snapshot to html', () => { + const template = (html: string, title?: string) => { + let htmlTemplate = ` + + + + + + +
+ +
+ + + ` + .replace(/\s\s+|\n/g, '') + .replace('', html); + if (title) { + htmlTemplate = htmlTemplate.replace( + '', + `

${title}

` + ); + } + return htmlTemplate; + }; + + const paragraphTemplate = (html: string) => + `
${html}
`; + + test('code', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `
import this
` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('code upper case', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:code', + props: { + language: 'PYTHON', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `
import this
` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('code unknown', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:code', + props: { + language: 'unknown', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `
import this
` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('paragraph', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:sP3bU52el7', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:X_HMxP4wxC', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:iA34Rb-RvV', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:I0Fmz5Nv02', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ggg', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'hhh', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const html = template( + `

aaa

bbb

ccc

ddd

eee

fff

ggg

hhh

` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('bulleted list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:kYliRIovvL', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:UyvxA_gqCJ', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:-guNZRm5u1', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:B9CaZzQ2CO', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + const html = template( + `
  • aaa
    • bbb
      • ccc
    • ddd
  • eee
` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('different list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + const html = template( + `
  • aaa
  • bbb
  • ccc
` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('code inline', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:qhpbuss-KN', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'bbb', + attributes: { + code: true, + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const html = template( + `

aaa bbb ccc

` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('link', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'bbb', + attributes: { + link: 'https://affine.pro/', + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const html = template( + `

aaa bbb ccc

` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('bold', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:zxDyvrg1Mh', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + bold: true, + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `

aaabbbccc

` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('italic', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:zxDyvrg1Mh', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `

aaabbbccc

` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('image', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:WcYcyv-oZY', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:zqtuv999Ww', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:UTUZojv22c', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Gan31s-dYK', + flavour: 'affine:image', + props: { + sourceId: 'YXXTjRmLlNyiOUnHb8nAIvUP6V7PAXhwW9F5_tc2LGs=', + caption: 'aaa', + width: 0, + height: 0, + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + }, + children: [], + }, + { + type: 'block', + id: 'block:If92CIQiOl', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `
YXXTjRmLlNyiOUnHb8nAIvUP6V7PAXhwW9F5_tc2LGs=.blob

` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const blobManager = new MemoryBlobCRUD(); + await blobManager.set( + 'YXXTjRmLlNyiOUnHb8nAIvUP6V7PAXhwW9F5_tc2LGs=', + new Blob() + ); + const assets = new AssetsManager({ blob: blobManager }); + + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + assets, + }); + expect(target.file).toBe(html); + }); + + describe('embed link block', () => { + const embedTestCases = [ + { + name: 'bookmark', + flavour: 'affine:bookmark', + url: 'https://example.com', + title: 'example', + }, + { + name: 'embed github', + flavour: 'affine:embed-github', + url: 'https://github.com/toeverything/blocksuite/pull/66666', + title: 'example github pr title', + }, + { + name: 'embed figma', + flavour: 'affine:embed-figma', + url: 'https://www.figma.com/file/1234567890', + title: 'example figma title', + }, + { + name: 'embed youtube', + flavour: 'affine:embed-youtube', + url: 'https://www.youtube.com/watch?v=1234567890', + title: 'example youtube title', + }, + { + name: 'embed loom', + flavour: 'affine:embed-loom', + url: 'https://www.loom.com/share/1234567890', + title: 'example loom title', + }, + ]; + + for (const testCase of embedTestCases) { + test(testCase.name, async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: testCase.flavour, + props: { + url: testCase.url, + title: testCase.title, + }, + children: [], + }, + ], + }, + ], + }; + + const html = template( + `` + ); + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + } + }); + + test('database', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8Wb7CSJ9Qe', + flavour: 'affine:database', + props: { + cells: { + 'block:P_-Wg7Rg9O': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'TKip9uc7Yx', + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1702598400000, + }, + 'block:8Fa0JQe7WY': { + columnId: 'block:8Fa0JQe7WY', + value: 1, + }, + 'block:5ej6StPuF_': { + columnId: 'block:5ej6StPuF_', + value: 65, + }, + 'block:DPhZ6JBziD': { + columnId: 'block:DPhZ6JBziD', + value: ['-2_QD3GZT1', '73UrEZWaKk'], + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test2', + attributes: { + link: 'https://google.com', + }, + }, + ], + }, + }, + 'block:U8lPD59MkF': { + columnId: 'block:U8lPD59MkF', + value: 'https://google.com', + }, + 'block:-DT7B0TafG': { + columnId: 'block:-DT7B0TafG', + value: true, + }, + }, + 'block:0vhfgcHtPF': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'F2bgsaE3X2', + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test1', + }, + ], + }, + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1703030400000, + }, + }, + 'block:b4_02QXMAM': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'y3O1A2IHHu', + }, + }, + 'block:W_eirvg7EJ': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + }, + }, + }, + columns: [ + { + type: 'title', + name: 'Title', + data: {}, + id: 'block:2VfUaitjf9', + }, + { + type: 'select', + name: 'Status', + data: { + options: [ + { + id: 'TKip9uc7Yx', + color: 'var(--affine-tag-white)', + value: 'TODO', + }, + { + id: 'F2bgsaE3X2', + color: 'var(--affine-tag-green)', + value: 'In Progress', + }, + { + id: 'y3O1A2IHHu', + color: 'var(--affine-tag-gray)', + value: 'Done', + }, + ], + }, + id: 'block:qyo8q9VPWU', + }, + { + type: 'date', + name: 'Date', + data: {}, + id: 'block:5cglrBmAr3', + }, + { + type: 'number', + name: 'Number', + data: { + decimal: 0, + }, + id: 'block:8Fa0JQe7WY', + }, + { + type: 'progress', + name: 'Progress', + data: {}, + id: 'block:5ej6StPuF_', + }, + { + type: 'multi-select', + name: 'MultiSelect', + data: { + options: [ + { + id: '73UrEZWaKk', + value: 'test2', + color: 'var(--affine-tag-purple)', + }, + { + id: '-2_QD3GZT1', + value: 'test1', + color: 'var(--affine-tag-teal)', + }, + ], + }, + id: 'block:DPhZ6JBziD', + }, + { + type: 'rich-text', + name: 'RichText', + data: {}, + id: 'block:O8dpIDiP7-', + }, + { + type: 'link', + name: 'Link', + data: {}, + id: 'block:U8lPD59MkF', + }, + { + type: 'checkbox', + name: 'Checkbox', + data: {}, + id: 'block:-DT7B0TafG', + }, + ], + }, + children: [ + { + type: 'block', + id: 'block:P_-Wg7Rg9O', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:0vhfgcHtPF', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 2', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }; + const html = template( + '
TitleStatusDateNumberProgressMultiSelectRichTextLinkCheckbox
Task 1TODO2023-12-15165test1,test2test2https://google.comTrue
Task 2In Progress2023-12-20test1
' + ); + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(html); + }); + + test('linked doc block', async () => { + const blockSnapShot: BlockSnapshot = { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'C0sH2Ee6cz-MysVNLNrBt', + flavour: 'affine:embed-linked-doc', + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: '4T5ObMgEIMII-4Bexyta1', + style: 'horizontal', + caption: null, + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + children: [], + }, + ], + }, + ], + }; + + const middleware: TransformerMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('title:4T5ObMgEIMII-4Bexyta1', 'Test Doc'); + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const html = template( + '' + ); + const htmlAdapter = new HtmlAdapter(createJob([middleware]), provider); + const target = await htmlAdapter.fromBlockSnapshot({ + snapshot: blockSnapShot, + }); + expect(target.file).toBe(html); + }); + + test('synced doc block', async () => { + // doc -> synced doc block -> deepest synced doc block + // The deepest synced doc block only export it's title + + const deepestSyncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'deepestSyncedDoc', + title: 'Deepest Doc', + createDate: 1715762171116, + tags: [], + }, + blocks: { + type: 'block', + id: '8WdJmN5FTT', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Deepest Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'zVN1EZFuZe', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: '2s9sJlphLH', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'vNp5XrR5yw', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + { + type: 'block', + id: 'JTdfSl1ygZ', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello, This is deepest doc.', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'syncedDoc', + title: 'Synced Doc', + createDate: 1719212435051, + tags: [], + }, + blocks: { + type: 'block', + id: 'AGOahFisBN', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Synced Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'gfVzx5tGpB', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'CzEfaUret4', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'yFlNufsgke', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Heading 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'oMuLcD6XS3', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h2', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'heading 2', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'PQ8FhGV6VM', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'sA9paSrdEN', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'strike', + attributes: { + strike: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: '-3bbVQTvI2', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'deepestSyncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocHtml = + paragraphTemplate('

Heading 1

') + + paragraphTemplate('

heading 2

') + + paragraphTemplate('

paragraph

') + + paragraphTemplate('

strike

'); + + const docSnapShot: DocSnapshot = { + type: 'page', + meta: { + id: 'y5nsrywQtr', + title: 'Test Doc', + createDate: 1719222172042, + tags: [], + }, + blocks: { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'cWBI4UGTqh', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVxas19', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'syncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + { + type: 'block', + id: 'Db976U9v18', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'World!', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const docHtml = template( + paragraphTemplate('

Hello

') + + syncedDocHtml + + '

Deepest Doc

' + + paragraphTemplate('

World!

'), + 'Test Doc' + ); + + const job = createJob([embedSyncedDocMiddleware('content')]); + + // workaround for adding docs to collection + await job.snapshotToDoc(deepestSyncedDocSnapshot); + await job.snapshotToDoc(syncedDocSnapshot); + await job.snapshotToDoc(docSnapShot); + + const mdAdapter = new HtmlAdapter(job, provider); + const target = await mdAdapter.fromDocSnapshot({ + snapshot: docSnapShot, + }); + expect(target.file).toBe(docHtml); + }); +}); + +describe('html to snapshot', () => { + const template = (html: string) => + ` + + + + + + + + + + + + + `.replace('', html); + + test('code', async () => { + const html = template( + `
import this
` + ); + + 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:code', + props: { + language: 'python', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('paragraph', async () => { + const html = template(`

aaa

bbb

ccc

ddd

eee

`); + + 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: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('paragraph with br should split into multiple blocks', async () => { + const html = template(`

aaa
bbb
ccc

`); + + 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( + `

aaa
bbb
ccc

` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + attributes: { + link: 'https://www.google.com/', + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + attributes: { + italic: true, + }, + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('nested list', async () => { + const html = template(`
  • 111
    • 222
`); + + 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:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '111', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '222', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('p in list', async () => { + const html = template(`
  1. p in list

`); + + 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:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'p in list', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('iframe', async () => { + const html = template( + `` + ); + + 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:embed-youtube', + props: { + url: 'https://www.youtube.com/watch?v=QDsd0nyzwz0', + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('wrap', async () => { + const html = template( + `

a\n aa

b\t bb

c cc

ddd

eee

` + ); + + 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: 'a aa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'b bb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'c cc', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('should preserve space in p', async () => { + const html = template( + `

A bold text followed by a italic text

` + ); + + 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: 'A ', + }, + { + insert: 'bold text', + attributes: { + bold: true, + }, + }, + { + insert: ' followed by a ', + }, + { + insert: 'italic text', + attributes: { + italic: true, + }, + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('span nested in p', async () => { + const html = template( + `

aaabbbccc

` + ); + + 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: 'aaabbbccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('span nested in div', async () => { + const html = template( + `
aaabbbccc
` + ); + + 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: 'aaabbbccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline div', async () => { + const html = template( + `aaabbb
ccc
` + ); + + 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', + }, + { + insert: 'bbb', + attributes: { + link: 'https://www.google.com/', + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('flex div', async () => { + const html = template( + `
aaabbb
ccc
` + ); + + 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', + }, + { + insert: 'bbb', + attributes: { + link: 'https://www.google.com/', + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('span inside h1', async () => { + const html = template(`

aaa

`); + 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: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('p in ancestor', async () => { + const html = template(`

aaa

`); + 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: [ + { + attributes: { + bold: true, + }, + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('block level element in b should not be treated as inline', async () => { + const html = template(`

aaa

`); + 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: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + describe('strong element', () => { + test('should not be bold when font-weight is normal', async () => { + const html = template(`aaa`); + 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: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('should be bold when font-weight is bold or 500-900 ', async () => { + const html = template( + `

aaaaaabbbbbbccccccddd

` + ); + 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: [ + { + attributes: { + bold: true, + }, + insert: 'aaa', + }, + { + insert: 'aaa', + }, + { + attributes: { + bold: true, + }, + insert: 'bbb', + }, + { + insert: 'bbb', + }, + { + attributes: { + bold: true, + }, + insert: 'ccc', + }, + { + insert: 'ccc', + }, + { + attributes: { + bold: true, + }, + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + }); + + test('should be italic when tag is i or em or span with style font-style: italic', async () => { + const html = template( + `

aaaaaabbbbbbccc

` + ); + 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: [ + { + attributes: { + italic: true, + }, + insert: 'aaa', + }, + { + insert: 'aaa', + }, + { + attributes: { + italic: true, + }, + insert: 'bbb', + }, + { + insert: 'bbb', + }, + { + attributes: { + italic: true, + }, + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('should be underline when tag is u or span with style text-decoration: underline', async () => { + const html = template( + `

aaaaaabbb

` + ); + 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: [ + { + attributes: { + underline: true, + }, + insert: 'aaa', + }, + { + insert: 'aaa', + }, + { + attributes: { + underline: true, + }, + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('should be strike when tag is del or span with style text-decoration: line-through', async () => { + const html = template( + `

aaaaaabbb

` + ); + 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: [ + { + attributes: { + strike: true, + }, + insert: 'aaa', + }, + { + insert: 'aaa', + }, + { + attributes: { + strike: true, + }, + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/adapters/notion-html.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/notion-html.unit.spec.ts new file mode 100644 index 00000000..8dbd196a --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/notion-html.unit.spec.ts @@ -0,0 +1,2093 @@ +import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model'; +import { NotionHtmlAdapter } from '@blocksuite/affine-shared/adapters'; +import { DEFAULT_IMAGE_PROXY_ENDPOINT } from '@blocksuite/affine-shared/consts'; +import { + AssetsManager, + type BlockSnapshot, + MemoryBlobCRUD, +} from '@blocksuite/store'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; + +import { createJob } from '../utils/create-job.js'; +import { getProvider } from '../utils/get-provider.js'; +import { nanoidReplacement } from '../utils/nanoid-replacement.js'; + +const provider = getProvider(); + +describe('notion html to snapshot', () => { + test('code', async () => { + const html = `
+
def fib(n):
+  a,b = 1,1
+  for i in range(n-1):
+      a,b = b,a+b
+  return a
+
`; + + 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:code', + props: { + language: 'Plain Text', + wrap: false, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: + 'def fib(n):\n a,b = 1,1\n for i in range(n-1):\n a,b = b,a+b\n return a', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('paragraph', async () => { + const html = `
+

aaa +

+

bbb +

+

ccc

+
+

+

ddd

+
+

+

eee

+
`; + + 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: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }; + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('heading', async () => { + const html = `
+

1

+

2

+

3

+
`; + + 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: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'h2', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '2', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'h3', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '3', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('list', async () => { + const html = `
+
    +
  • aaa +
      +
    • bbb +
        +
      • ccc
      • +
      +
    • +
    +
      +
    • ddd
    • +
    +
  • +
+
    +
  • eee
  • +
+
    +
  • +
    aaa +
    +
      +
    • +
      bbb +
      +
        +
      • +
        ccc +
        +
      • +
      +
      +
    • +
    +
      +
    • +
      ddd +
      +
    • +
    +
    +
  • +
+
    +
  • +
    eee +
    +
  • +
+
    +
  1. aaa
      +
    1. bbb
        +
      1. ccc
      2. +
      +
    2. +
    +
      +
    1. ddd
    2. +
    +
  2. +
+
    +
  1. eee
  2. +
+
    +
  • +
    + aaa +
      +
    • +
      + bbb +
        +
      • +
        + ccc +

        ddd

        +
        +
      • +
      +

      eee

      +
      +
    • +
    +

    fff

    +
    +
  • +
+
`; + + 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:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[6]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[7]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: true, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[8]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[9]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[10]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: true, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[11]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[12]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[13]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[14]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[15]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[16]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[17]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[18]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[19]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[20]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[21]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('nested list with nested paragraph', async () => { + const html = `
+
    +
  • list 1 +
      +
    • list 2 +
        +
      • list 3 +

        paragraph 1 +

        +

        paragraph 2

        +
        +

        +
      • +
      +
    • +
    +
  • +
+
`; + + 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:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'list 1', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'list 2', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'list 3', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph 1', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph 2', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('quote', async () => { + const html = `
+
aaa
+
bbb

ccc +

+

ddd

+
+

+
+
`; + + 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: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('callout', async () => { + const html = `
+
+
💡
+
aaa
+
+
`; + + 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: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '💡\n', + }, + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('divider', async () => { + const html = `
+

aaa

+
+

bbb

+
`; + + 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:divider', + props: {}, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('page-link', async () => { + const html = `
+ +
`; + + 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: 'Untitled', + attributes: { + link: 'https://www.notion.so/ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + describe('image', () => { + const originalUrl = + 'https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg'; + + const imageProxy = DEFAULT_IMAGE_PROXY_ENDPOINT; + const imageUrl = `${imageProxy}?url=${encodeURIComponent(originalUrl)}`; + + // Mock the image request + const imageRequestHandlers = [ + http.get(imageUrl.toString(), async () => { + // Return a mock image blob + const mockImageBlob = new Blob(['mock image data'], { + type: 'image/svg+xml', + }); + return new HttpResponse(mockImageBlob, { + headers: { + 'Content-Type': 'image/svg+xml', + }, + }); + }), + ]; + + const server = setupServer(...imageRequestHandlers); + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + test('network image resource', async () => { + const html = `
+
+
+
`; + + 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:image', + props: { + sourceId: 'matchesReplaceMap[2]', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + assets: new AssetsManager({ blob: new MemoryBlobCRUD() }), + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + }); + + test('bookmark', async () => { + const html = ``; + + 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:bookmark', + props: { + type: 'card', + url: 'https://affine.pro/', + title: 'AFFiNE - All In One KnowledgeOS', + description: + 'The universal editor that lets you work, play, present or create just about anything.', + icon: 'https://affine.pro/favicon-96.png', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('embeded', async () => { + const html = `
+
+ +
+
`; + + 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:attachment', + props: { + name: 'README.pdf', + size: 0, + type: '', + sourceId: 'matchesReplaceMap[2]', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const blobCRUD = new MemoryBlobCRUD(); + const key = await blobCRUD.set(new File([], 'README.pdf')); + const assestsManager = new AssetsManager({ blob: blobCRUD }); + assestsManager + .getPathBlobIdMap() + .set('Untitled 3d2ae962f5433a90499ddbd1c81ac507/README.pdf', key); + await assestsManager.readFromBlob(key); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + assets: assestsManager, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('table', async () => { + const html = `
+
+

Table View

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + Tags + Multi-select + Number + Status + Checkbox
https://affine.pro + aaaaaabbb5 +
Not started +
+
+
Untitledbbbaaaccc7 +
Not started +
+
+
Untitledaaabbb9 +
Not started +
+
+
+
+

+
`; + + 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:database', + props: { + views: [ + { + id: 'matchesReplaceMap[2]', + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: 'matchesReplaceMap[4]', + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Table View', + }, + ], + }, + columns: [ + { + type: 'title', + name: 'Name', + data: {}, + id: 'matchesReplaceMap[4]', + }, + { + type: 'multi-select', + name: 'Tags', + data: { + options: [ + { + id: 'matchesReplaceMap[51]', + value: 'aaa', + color: 'matchesReplaceMap[6]', + }, + { + id: 'matchesReplaceMap[37]', + value: 'bbb', + color: 'matchesReplaceMap[8]', + }, + ], + }, + id: 'matchesReplaceMap[50]', + }, + { + type: 'multi-select', + name: 'Multi-select', + data: { + options: [ + { + id: 'matchesReplaceMap[40]', + value: 'aaa', + color: 'matchesReplaceMap[11]', + }, + { + id: 'matchesReplaceMap[54]', + value: 'bbb', + color: 'matchesReplaceMap[13]', + }, + { + id: 'matchesReplaceMap[41]', + value: 'ccc', + color: 'matchesReplaceMap[15]', + }, + ], + }, + id: 'matchesReplaceMap[53]', + }, + { + type: 'number', + name: 'Number', + data: {}, + id: 'matchesReplaceMap[56]', + }, + { + type: 'rich-text', + name: 'Status', + data: {}, + id: 'matchesReplaceMap[58]', + }, + { + type: 'checkbox', + name: 'Checkbox', + data: {}, + id: 'matchesReplaceMap[60]', + }, + ], + cells: { + 'matchesReplaceMap[61]': { + 'matchesReplaceMap[50]': { + columnId: 'matchesReplaceMap[50]', + value: ['matchesReplaceMap[51]'], + }, + 'matchesReplaceMap[53]': { + columnId: 'matchesReplaceMap[53]', + value: ['matchesReplaceMap[40]', 'matchesReplaceMap[54]'], + }, + 'matchesReplaceMap[56]': { + columnId: 'matchesReplaceMap[56]', + value: 5, + }, + 'matchesReplaceMap[58]': { + columnId: 'matchesReplaceMap[58]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' Not started ', + }, + ], + }, + }, + 'matchesReplaceMap[60]': { + columnId: 'matchesReplaceMap[60]', + value: false, + }, + }, + 'matchesReplaceMap[62]': { + 'matchesReplaceMap[50]': { + columnId: 'matchesReplaceMap[50]', + value: ['matchesReplaceMap[37]'], + }, + 'matchesReplaceMap[53]': { + columnId: 'matchesReplaceMap[53]', + value: ['matchesReplaceMap[40]', 'matchesReplaceMap[41]'], + }, + 'matchesReplaceMap[56]': { + columnId: 'matchesReplaceMap[56]', + value: 7, + }, + 'matchesReplaceMap[58]': { + columnId: 'matchesReplaceMap[58]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' Not started ', + }, + ], + }, + }, + 'matchesReplaceMap[60]': { + columnId: 'matchesReplaceMap[60]', + value: true, + }, + }, + 'matchesReplaceMap[63]': { + 'matchesReplaceMap[50]': { + columnId: 'matchesReplaceMap[50]', + value: ['matchesReplaceMap[51]'], + }, + 'matchesReplaceMap[53]': { + columnId: 'matchesReplaceMap[53]', + value: ['matchesReplaceMap[54]'], + }, + 'matchesReplaceMap[56]': { + columnId: 'matchesReplaceMap[56]', + value: 9, + }, + 'matchesReplaceMap[58]': { + columnId: 'matchesReplaceMap[58]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' Not started ', + }, + ], + }, + }, + 'matchesReplaceMap[60]': { + columnId: 'matchesReplaceMap[60]', + value: false, + }, + }, + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[61]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'https://affine.pro', + attributes: { + link: 'https://www.notion.so/https-affine-pro-ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[62]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + attributes: { + link: 'https://www.notion.so/ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[63]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + attributes: { + link: 'https://www.notion.so/ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('plain table', async () => { + const html = `
+
aa
1
+
`; + + 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: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:database', + props: { + views: [ + { + id: 'matchesReplaceMap[2]', + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: '', + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + columns: [ + { + type: 'rich-text', + name: '', + data: {}, + id: 'matchesReplaceMap[17]', + }, + { + type: 'rich-text', + name: '', + data: {}, + id: 'matchesReplaceMap[19]', + }, + ], + cells: { + 'matchesReplaceMap[20]': { + 'matchesReplaceMap[17]': { + columnId: 'matchesReplaceMap[17]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aa', + }, + ], + }, + }, + 'matchesReplaceMap[19]': { + columnId: 'matchesReplaceMap[19]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + }, + 'matchesReplaceMap[21]': { + 'matchesReplaceMap[17]': { + columnId: 'matchesReplaceMap[17]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '1', + }, + ], + }, + }, + 'matchesReplaceMap[19]': { + columnId: 'matchesReplaceMap[19]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + }, + 'matchesReplaceMap[22]': { + 'matchesReplaceMap[17]': { + columnId: 'matchesReplaceMap[17]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + 'matchesReplaceMap[19]': { + columnId: 'matchesReplaceMap[19]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + }, + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[20]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aa', + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[21]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aa', + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[22]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[23]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + type: 'text', + }, + children: [], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('block equation', async () => { + const html = `
+
+
+ E = mc^2 +
+
+
`; + + 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:latex', + props: { + latex: 'E = mc^2', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline equation', async () => { + const html = `
+

inline equation + + + E = mc^2 + +

+
`; + + 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: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'inline equation', + }, + { + insert: ' ', + attributes: { + latex: 'E = mc^2', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline style', async () => { + const html = `
+

+ strong italic underline strikethrough code +

+
`; + + 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: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'strong', + attributes: { + bold: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'italic', + attributes: { + italic: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'underline', + attributes: { + underline: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'strikethrough', + attributes: { + strike: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'code', + attributes: { + code: true, + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob(), provider); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts new file mode 100644 index 00000000..a4d98aa6 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts @@ -0,0 +1,170 @@ +import { DefaultTheme } from '@blocksuite/affine-model'; +import { NotionTextAdapter } from '@blocksuite/affine-shared/adapters'; +import type { SliceSnapshot } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { createJob } from '../utils/create-job.js'; +import { getProvider } from '../utils/get-provider.js'; +import { nanoidReplacement } from '../utils/nanoid-replacement.js'; + +const provider = getProvider(); + +describe('notion-text to snapshot', () => { + test('basic', () => { + const notionText = + '{"blockType":"text","editing":[["aaa ",[["_"],["b"],["i"]]],["nbbbb ",[["_"],["i"]]],["hjhj ",[["_"]]],["a",[["_"],["c"]]],[" ",[["_"]]],["ccc d",[["_"],["s"]]],["dd",[["_"],["s"]]]]}'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + attributes: { + underline: true, + bold: true, + italic: true, + }, + }, + { + insert: 'nbbbb ', + attributes: { + underline: true, + italic: true, + }, + }, + { + insert: 'hjhj ', + attributes: { + underline: true, + }, + }, + { + insert: 'a', + attributes: { + underline: true, + code: true, + }, + }, + { + insert: ' ', + attributes: { + underline: true, + }, + }, + { + insert: 'ccc d', + attributes: { + underline: true, + strike: true, + }, + }, + { + insert: 'dd', + attributes: { + underline: true, + strike: true, + }, + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const ntAdapter = new NotionTextAdapter(createJob(), provider); + const target = ntAdapter.toSliceSnapshot({ + file: notionText, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(target!)).toEqual(sliceSnapshot); + }); + + test('notion text with empty styles array', () => { + const notionText = + '{"blockType":"text","editing":[["a "],["bold text",[["b"]]],[" hello world"]],"selection":{"startIndex":0,"endIndex":23},"action":"copy"}'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'a ', + }, + { + insert: 'bold text', + attributes: { + bold: true, + }, + }, + { + insert: ' hello world', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const ntAdapter = new NotionTextAdapter(createJob(), provider); + const target = ntAdapter.toSliceSnapshot({ + file: notionText, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(target!)).toEqual(sliceSnapshot); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts new file mode 100644 index 00000000..49f69873 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/pdf.unit.spec.ts @@ -0,0 +1,1618 @@ +import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model'; +import { PdfAdapter } from '@blocksuite/affine-shared/adapters'; +import type { BlockSnapshot, DocSnapshot } from '@blocksuite/store'; +import { AssetsManager, MemoryBlobCRUD } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { createJob } from '../utils/create-job.js'; +import { getProvider } from '../utils/get-provider.js'; + +const provider = getProvider(); + +// Helper function to create a base snapshot structure +function createBaseSnapshot( + children: BlockSnapshot['children'] +): BlockSnapshot { + return { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children, + }, + ], + }; +} + +describe('snapshot to pdf', () => { + test('paragraph', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello World', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + expect(Array.isArray(definition.content)).toBe(true); + const content = definition.content as any[]; + expect(content.length).toBeGreaterThan(0); + + // Find the paragraph content + const paragraphContent = content.find( + (item: any) => + item.text === 'Hello World' || + (Array.isArray(item.text) && item.text.includes('Hello World')) + ); + expect(paragraphContent).toBeDefined(); + }); + + test('code block', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:code', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'print("Hello")', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // Find code block table + const codeBlock = content.find( + (item: any) => item.table && item.table.body + ); + expect(codeBlock).toBeDefined(); + expect(codeBlock.table.body).toBeDefined(); + }); + + test('list items', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:list1', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Item 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:list2', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Item 2', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // Find list items (they should be tables with 2 columns) + const listItems = content.filter( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 2 + ); + expect(listItems.length).toBeGreaterThanOrEqual(2); + }); + + test('header', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:header', + flavour: 'affine:paragraph', + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Heading 1', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // Find header content + const header = content.find( + (item: any) => + item.style === 'header1' || + (item.text && + (item.text === 'Heading 1' || + (Array.isArray(item.text) && item.text.includes('Heading 1')))) + ); + expect(header).toBeDefined(); + if (header.style) { + expect(header.style).toBe('header1'); + } + }); + + test('document with title', async () => { + const docSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'testDocument', + title: 'Test Document', + createDate: 1718225423102, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:test', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:surface', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:note', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }, + ], + }, + }; + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [docSnapshot.blocks], + docSnapshot.meta?.title + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + + // First item should be the title + expect(content[0].text).toBe('Test Document'); + expect(content[0].style).toBe('title'); + }); + + test('styles definition', async () => { + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition([], undefined); + + expect(definition.styles).toBeDefined(); + expect(definition.styles?.title).toBeDefined(); + expect(definition.styles?.header1).toBeDefined(); + expect(definition.styles?.header2).toBeDefined(); + expect(definition.styles?.header3).toBeDefined(); + expect(definition.styles?.header4).toBeDefined(); + expect(definition.styles?.code).toBeDefined(); + + expect(definition.defaultStyle).toBeDefined(); + expect(definition.defaultStyle?.font).toBe('SarasaGothicCL'); + }); + + describe('inline text styling', () => { + test('bold text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Bold text', + attributes: { bold: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => typeof t === 'object' && t.bold === true + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Bold text'); + } + }); + + test('italic text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Italic text', + attributes: { italic: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => typeof t === 'object' && t.italics === true + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Italic text'); + } + }); + + test('underline text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Underlined text', + attributes: { underline: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => + typeof t === 'object' && + (t.decoration === 'underline' || + (Array.isArray(t.decoration) && + t.decoration.includes('underline'))) + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Underlined text'); + } + }); + + test('strikethrough text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Strikethrough text', + attributes: { strike: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => + typeof t === 'object' && + (t.decoration === 'lineThrough' || + (Array.isArray(t.decoration) && + t.decoration.includes('lineThrough'))) + ); + expect(styledText).toBeDefined(); + expect(styledText.text).toBe('Strikethrough text'); + } + }); + + test('inline code', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'code', + attributes: { code: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const codeText = textContent.text.find( + (t: any) => + typeof t === 'object' && + t.font === 'Inter' && + t.background === '#f5f5f5' + ); + expect(codeText).toBeDefined(); + expect(codeText.text).toContain('code'); + } + }); + + test('combined styles', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Bold and italic', + attributes: { bold: true, italic: true }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const styledText = textContent.text.find( + (t: any) => + typeof t === 'object' && t.bold === true && t.italics === true + ); + expect(styledText).toBeDefined(); + } + }); + }); + + describe('links and references', () => { + test('link attribute', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Click here', + attributes: { link: 'https://example.com' }, + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const linkText = textContent.text.find( + (t: any) => typeof t === 'object' && t.link === 'https://example.com' + ); + expect(linkText).toBeDefined(); + expect(linkText.color).toBe('#0066cc'); + } + }); + + test('linked page reference - found', async () => { + const job = createJob(); + const pdfAdapter = new PdfAdapter(job, provider); + pdfAdapter.configs.set('title:page123', 'Referenced Page'); + pdfAdapter.configs.set('docLinkBaseUrl', 'https://example.com/doc'); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'page123', + }, + }, + }, + ], + }, + }, + children: [], + }, + ]); + + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const refText = textContent.text.find( + (t: any) => + typeof t === 'object' && + t.text === 'Referenced Page' && + t.link === 'https://example.com/doc/page123' + ); + expect(refText).toBeDefined(); + expect(refText.color).toBe('#0066cc'); + } + }); + + test('linked page reference - not found', async () => { + const job = createJob(); + const pdfAdapter = new PdfAdapter(job, provider); + pdfAdapter.configs.set('docLinkBaseUrl', 'https://example.com/doc'); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'nonexistent', + }, + }, + }, + ], + }, + }, + children: [], + }, + ]); + + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + + if (Array.isArray(textContent.text)) { + const refText = textContent.text.find( + (t: any) => typeof t === 'object' && t.text === 'Page not found' + ); + expect(refText).toBeDefined(); + } + }); + }); + + describe('quote blocks', () => { + test('quote block with text', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:quote', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'This is a quote', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const quoteBlock = content.find( + (item: any) => + item.table && + item.table.widths && + item.table.widths.length === 2 && + item.table.widths[0] === 2 + ); + expect(quoteBlock).toBeDefined(); + expect(quoteBlock.table.body[0][0].fillColor).toBe('#cccccc'); + }); + + test('quote block with children', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:quote', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Quote text', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:child', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Child paragraph', + }, + ], + }, + }, + children: [], + }, + ], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const quoteBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 2 + ); + expect(quoteBlock).toBeDefined(); + expect(quoteBlock.table.body[0][1].stack).toBeDefined(); + }); + }); + + describe('callout blocks', () => { + test('callout with default background', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:callout', + flavour: 'affine:callout', + props: { + backgroundColorName: 'grey', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Callout content', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const calloutBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 1 + ); + expect(calloutBlock).toBeDefined(); + expect(calloutBlock.table.body[0][0].fillColor).toBeDefined(); + }); + + test('callout with custom background color', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:callout', + flavour: 'affine:callout', + props: { + backgroundColorName: 'blue', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Blue callout', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const calloutBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 1 + ); + expect(calloutBlock).toBeDefined(); + expect(calloutBlock.table.body[0][0].fillColor).toBeDefined(); + }); + + test('callout with children', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:callout', + flavour: 'affine:callout', + props: { + backgroundColorName: 'grey', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Callout', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:child', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Child content', + }, + ], + }, + }, + children: [], + }, + ], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const calloutBlock = content.find( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 1 + ); + expect(calloutBlock).toBeDefined(); + expect(calloutBlock.table.body[0][0].stack).toBeDefined(); + }); + }); + + describe('image handling', () => { + test('image without sourceId', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: undefined, + caption: 'Test caption', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const imageContent = content.find( + (item: any) => + item.text && + (item.text === '[Image: Test caption]' || item.text.includes('Image')) + ); + expect(imageContent).toBeDefined(); + expect(imageContent.italics).toBe(true); + }); + + test('SVG image', async () => { + const blobCRUD = new MemoryBlobCRUD(); + const svgBlob = new Blob( + [ + '', + ], + { + type: 'image/svg+xml', + } + ); + const blobId = await blobCRUD.set(svgBlob); + const assets = new AssetsManager({ blob: blobCRUD }); + await assets.readFromBlob(blobId); + assets.getAssets().set(blobId, svgBlob); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: blobId, + caption: 'SVG Image', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined, + assets + ); + + const content = definition.content as any[]; + const svgContent = content.find((item: any) => item.svg); + expect(svgContent).toBeDefined(); + expect(svgContent.svg).toContain(' { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: 'nonexistent-blob-id', + caption: 'Missing image', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const fallbackContent = content.find( + (item: any) => + item.text && + (item.text === '[Image: Missing image]' || + item.text.includes('Image')) + ); + expect(fallbackContent).toBeDefined(); + }); + + test('image with width and height', async () => { + const blobCRUD = new MemoryBlobCRUD(); + const svgBlob = new Blob( + [ + '', + ], + { + type: 'image/svg+xml', + } + ); + const blobId = await blobCRUD.set(svgBlob); + const assets = new AssetsManager({ blob: blobCRUD }); + await assets.readFromBlob(blobId); + assets.getAssets().set(blobId, svgBlob); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: blobId, + width: 300, + height: 200, + caption: 'Sized image', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined, + assets + ); + + const content = definition.content as any[]; + const svgContent = content.find((item: any) => item.svg); + expect(svgContent).toBeDefined(); + expect(svgContent.width).toBeDefined(); + expect(svgContent.height).toBeDefined(); + }); + + test('image text alignment', async () => { + const blobCRUD = new MemoryBlobCRUD(); + const svgBlob = new Blob([''], { + type: 'image/svg+xml', + }); + const blobId = await blobCRUD.set(svgBlob); + const assets = new AssetsManager({ blob: blobCRUD }); + await assets.readFromBlob(blobId); + assets.getAssets().set(blobId, svgBlob); + + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:image', + flavour: 'affine:image', + props: { + sourceId: blobId, + textAlign: 'right', + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined, + assets + ); + + const content = definition.content as any[]; + const svgContent = content.find((item: any) => item.svg); + expect(svgContent).toBeDefined(); + expect(svgContent.alignment).toBe('right'); + }); + }); + + describe('table rendering', () => { + test('table with cells', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:table', + flavour: 'affine:table', + props: { + columns: { + col1: { + columnId: 'col1', + order: 'a0', + }, + col2: { + columnId: 'col2', + order: 'a1', + }, + }, + rows: { + row1: { + rowId: 'row1', + order: 'a0', + }, + row2: { + rowId: 'row2', + order: 'a1', + }, + }, + cells: { + 'row1:col1': { + text: { + delta: [{ insert: 'Cell 1-1' }], + }, + }, + 'row1:col2': { + text: { + delta: [{ insert: 'Cell 1-2' }], + }, + }, + 'row2:col1': { + text: { + delta: [{ insert: 'Cell 2-1' }], + }, + }, + 'row2:col2': { + text: { + delta: [{ insert: 'Cell 2-2' }], + }, + }, + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const table = content.find( + (item: any) => + item.table && item.table.body && Array.isArray(item.table.body) + ); + expect(table).toBeDefined(); + expect(table.table.body.length).toBe(2); + expect(table.table.body[0].length).toBe(2); + }); + + test('empty table', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:table', + flavour: 'affine:table', + props: { + columns: {}, + rows: {}, + cells: {}, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const table = content.find((item: any) => item.table && item.table.body); + expect(table).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + test('empty paragraph', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + expect(content.length).toBeGreaterThan(0); + }); + + test('paragraph with empty delta', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: '' }], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + }); + + test('malformed delta operations', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { insert: 123 as any }, // Invalid type + { insert: 'Valid text' }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + const textContent = content.find((item: any) => item.text); + expect(textContent).toBeDefined(); + }); + + test('text alignment', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:paragraph', + flavour: 'affine:paragraph', + props: { + type: 'text', + textAlign: 'center', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Centered text', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const paragraph = content.find( + (item: any) => + item.text && + (item.text === 'Centered text' || + (Array.isArray(item.text) && + item.text.some((t: any) => + typeof t === 'string' + ? t === 'Centered text' + : t.text === 'Centered text' + ))) + ); + expect(paragraph).toBeDefined(); + expect(paragraph.alignment).toBe('center'); + }); + + test('nested list items', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:list1', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Parent item', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:list2', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Child item', + }, + ], + }, + }, + children: [], + }, + ], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + expect(definition.content).toBeDefined(); + const content = definition.content as any[]; + const listItems = content.filter( + (item: any) => + item.table && item.table.widths && item.table.widths.length === 2 + ); + expect(listItems.length).toBeGreaterThanOrEqual(2); + }); + + test('numbered list with order', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:list1', + flavour: 'affine:list', + props: { + type: 'numbered', + order: 5, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Item 5', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const listItem = content.find( + (item: any) => + item.table && + item.table.body && + item.table.body[0] && + item.table.body[0][0] && + item.table.body[0][0].text + ); + expect(listItem).toBeDefined(); + expect(listItem.table.body[0][0].text).toContain('5.'); + }); + + test('todo list checked', async () => { + const blockSnapshot = createBaseSnapshot([ + { + type: 'block', + id: 'block:todo', + flavour: 'affine:list', + props: { + type: 'todo', + checked: true, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Completed task', + }, + ], + }, + }, + children: [], + }, + ]); + + const pdfAdapter = new PdfAdapter(createJob(), provider); + const definition = await pdfAdapter.getDocDefinition( + [blockSnapshot], + undefined + ); + + const content = definition.content as any[]; + const todoItem = content.find( + (item: any) => + item.table && + item.table.body && + item.table.body[0] && + item.table.body[0][0] && + item.table.body[0][0].svg + ); + expect(todoItem).toBeDefined(); + expect(todoItem.table.body[0][0].svg).toContain('svg'); + }); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/adapters/plain-text.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/plain-text.unit.spec.ts new file mode 100644 index 00000000..a565c8f5 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/adapters/plain-text.unit.spec.ts @@ -0,0 +1,1422 @@ +import { DefaultTheme, NoteDisplayMode } from '@blocksuite/affine-model'; +import { + embedSyncedDocMiddleware, + PlainTextAdapter, +} from '@blocksuite/affine-shared/adapters'; +import type { + BlockSnapshot, + DocSnapshot, + TransformerMiddleware, +} from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { createJob } from '../utils/create-job.js'; +import { getProvider } from '../utils/get-provider.js'; + +const provider = getProvider(); + +describe('snapshot to plain text', () => { + test('paragraph', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + insert: 'ccc', + attributes: { + bold: true, + }, + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + attributes: { + italic: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + attributes: { + bold: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:I0Fmz5Nv02', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ggg', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'aaabbbccc\nddd\neee\nfff\nggg\n'; + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:m5hvdXHXS2', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Y4J-oO9h9d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:1Ll22zT992', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'block:Fd0ZCYB7a4', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:8-GeKDc06x', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:Fd0ZCYB7a5', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'aaa\nbbb\nccc\nddd\neee\n'; + + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toEqual(plainText); + }); + + test('divider', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:divider', + props: {}, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'aaa\n---\n'; + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('code', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'import this\n'; + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('special inline delta', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + attributes: { + link: 'https://affine.pro/', + }, + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'deadbeef', + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + latex: 'E=mc^2', + }, + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }; + const middleware: TransformerMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('title:deadbeef', 'test'); + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const plainTextAdapter = new PlainTextAdapter( + createJob([middleware]), + provider + ); + + const plainText = + 'aaa: https://affine.pro/\ntest: https://example.com/deadbeef?mode=page&blockIds=abc%2C123&elementIds=def%2C456&databaseId=deadbeef&databaseRowId=123\nE=mc^2\n'; + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + describe('embed link block', () => { + const embedTestCases = [ + { + name: 'bookmark', + flavour: 'affine:bookmark', + url: 'https://example.com', + title: 'example', + plainText: '[example](https://example.com)\n', + }, + { + name: 'embed github', + flavour: 'affine:embed-github', + url: 'https://github.com/toeverything/blocksuite/pull/66666', + title: 'example github pr title', + plainText: + '[example github pr title](https://github.com/toeverything/blocksuite/pull/66666)\n', + }, + { + name: 'embed figma', + flavour: 'affine:embed-figma', + url: 'https://www.figma.com/file/1234567890', + title: 'example figma title', + plainText: + '[example figma title](https://www.figma.com/file/1234567890)\n', + }, + { + name: 'embed youtube', + flavour: 'affine:embed-youtube', + url: 'https://www.youtube.com/watch?v=1234567890', + title: 'example youtube title', + plainText: + '[example youtube title](https://www.youtube.com/watch?v=1234567890)\n', + }, + { + name: 'embed loom', + flavour: 'affine:embed-loom', + url: 'https://www.loom.com/share/1234567890', + title: 'example loom title', + plainText: + '[example loom title](https://www.loom.com/share/1234567890)\n', + }, + ]; + + for (const testCase of embedTestCases) { + test(testCase.name, async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: testCase.flavour, + props: { + url: testCase.url, + title: testCase.title, + }, + children: [], + }, + ], + }, + ], + }; + + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(testCase.plainText); + }); + } + + test('linked doc block', async () => { + const blockSnapShot: BlockSnapshot = { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'C0sH2Ee6cz-MysVNLNrBt', + flavour: 'affine:embed-linked-doc', + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: '4T5ObMgEIMII-4Bexyta1', + style: 'horizontal', + caption: null, + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + children: [], + }, + ], + }, + ], + }; + + const middleware: TransformerMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('title:4T5ObMgEIMII-4Bexyta1', 'test'); + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const plainText = + 'test: https://example.com/4T5ObMgEIMII-4Bexyta1?mode=page&blockIds=abc%2C123&elementIds=def%2C456&databaseId=deadbeef&databaseRowId=123\n'; + const plainTextAdapter = new PlainTextAdapter( + createJob([middleware]), + provider + ); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapShot, + }); + expect(target.file).toBe(plainText); + }); + + test('synced doc block', async () => { + // doc -> synced doc block -> deepest synced doc block + // The deepest synced doc block only export it's title + + const deepestSyncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'deepestSyncedDoc', + title: 'Deepest Doc', + createDate: 1715762171116, + tags: [], + }, + blocks: { + type: 'block', + id: '8WdJmN5FTT', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Deepest Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'zVN1EZFuZe', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: '2s9sJlphLH', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'vNp5XrR5yw', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + { + type: 'block', + id: 'JTdfSl1ygZ', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello, This is deepest doc.', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'syncedDoc', + title: 'Synced Doc', + createDate: 1719212435051, + tags: [], + }, + blocks: { + type: 'block', + id: 'AGOahFisBN', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Synced Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'gfVzx5tGpB', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'CzEfaUret4', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'yFlNufsgke', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Heading 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'oMuLcD6XS3', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h2', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'heading 2', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'PQ8FhGV6VM', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'sA9paSrdEN', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'strike', + attributes: { + strike: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'DF26giFpKX', + flavour: 'affine:code', + version: 1, + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello world!', + }, + ], + }, + language: 'cpp', + wrap: false, + caption: '', + }, + children: [], + }, + { + type: 'block', + id: '-3bbVQTvI2', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'deepestSyncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocPlainText = + 'Heading 1\nheading 2\nparagraph\nstrike\nHello world!\n'; + + const docSnapShot: DocSnapshot = { + type: 'page', + meta: { + id: 'y5nsrywQtr', + title: 'Test Doc', + createDate: 1719222172042, + tags: [], + }, + blocks: { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'cWBI4UGTqh', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVxas19', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'syncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + { + type: 'block', + id: 'Db976U9v18', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'World!', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const docPlainText = `Test Doc\n\nHello\n${syncedDocPlainText}Deepest Doc\nWorld!\n`; + const job = createJob([embedSyncedDocMiddleware('content')]); + + // workaround for adding docs to collection + await job.snapshotToDoc(deepestSyncedDocSnapshot); + await job.snapshotToDoc(syncedDocSnapshot); + await job.snapshotToDoc(docSnapShot); + + const mdAdapter = new PlainTextAdapter(job, provider); + const target = await mdAdapter.fromDocSnapshot({ + snapshot: docSnapShot, + }); + expect(target.file).toBe(docPlainText); + }); + }); + + test('latex block', async () => { + 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:latex', + props: { + latex: 'E=mc^2', + }, + children: [], + }, + ], + }; + + const plainText = 'LaTex, with value: E=mc^2\n'; + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('table', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:8Wb7CSJ9Qe', + flavour: 'affine:database', + props: { + cells: { + 'block:P_-Wg7Rg9O': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'TKip9uc7Yx', + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1702598400000, + }, + 'block:8Fa0JQe7WY': { + columnId: 'block:8Fa0JQe7WY', + value: 1, + }, + 'block:5ej6StPuF_': { + columnId: 'block:5ej6StPuF_', + value: 65, + }, + 'block:DPhZ6JBziD': { + columnId: 'block:DPhZ6JBziD', + value: ['-2_QD3GZT1', '73UrEZWaKk'], + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test2', + attributes: { + link: 'https://google.com', + }, + }, + ], + }, + }, + 'block:U8lPD59MkF': { + columnId: 'block:U8lPD59MkF', + value: 'https://google.com', + }, + 'block:-DT7B0TafG': { + columnId: 'block:-DT7B0TafG', + value: true, + }, + }, + 'block:0vhfgcHtPF': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'F2bgsaE3X2', + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test1', + }, + ], + }, + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1703030400000, + }, + }, + 'block:b4_02QXMAM': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'y3O1A2IHHu', + }, + }, + 'block:W_eirvg7EJ': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + }, + }, + }, + columns: [ + { + type: 'title', + name: 'Title', + data: {}, + id: 'block:2VfUaitjf9', + }, + { + type: 'select', + name: 'Status', + data: { + options: [ + { + id: 'TKip9uc7Yx', + color: 'var(--affine-tag-white)', + value: 'TODO', + }, + { + id: 'F2bgsaE3X2', + color: 'var(--affine-tag-green)', + value: 'In Progress', + }, + { + id: 'y3O1A2IHHu', + color: 'var(--affine-tag-gray)', + value: 'Done', + }, + ], + }, + id: 'block:qyo8q9VPWU', + }, + { + type: 'date', + name: 'Date', + data: {}, + id: 'block:5cglrBmAr3', + }, + { + type: 'number', + name: 'Number', + data: { + decimal: 0, + }, + id: 'block:8Fa0JQe7WY', + }, + { + type: 'progress', + name: 'Progress', + data: {}, + id: 'block:5ej6StPuF_', + }, + { + type: 'multi-select', + name: 'MultiSelect', + data: { + options: [ + { + id: '73UrEZWaKk', + value: 'test2', + color: 'var(--affine-tag-purple)', + }, + { + id: '-2_QD3GZT1', + value: 'test1', + color: 'var(--affine-tag-teal)', + }, + ], + }, + id: 'block:DPhZ6JBziD', + }, + { + type: 'rich-text', + name: 'RichText', + data: {}, + id: 'block:O8dpIDiP7-', + }, + { + type: 'link', + name: 'Link', + data: {}, + id: 'block:U8lPD59MkF', + }, + { + type: 'checkbox', + name: 'Checkbox', + data: {}, + id: 'block:-DT7B0TafG', + }, + ], + }, + children: [ + { + type: 'block', + id: 'block:P_-Wg7Rg9O', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:0vhfgcHtPF', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 2', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const plainText = `\ +| Title | Status | Date | Number | Progress | MultiSelect | RichText | Link | Checkbox | +| ------ | ----------- | ---------- | ------ | -------- | ----------- | ------------------------- | ------------------ | -------- | +| Task 1 | TODO | 2023-12-15 | 1 | 65 | test1,test2 | test2: https://google.com | https://google.com | True | +| Task 2 | In Progress | 2023-12-20 | | | | test1 | | | +`; + const plainTextAdapter = new PlainTextAdapter(createJob(), provider); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts b/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts new file mode 100644 index 00000000..8aaf16fd --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts @@ -0,0 +1,236 @@ +import { + addProperty, + copyCellsByProperty, + databaseBlockProperties, + deleteColumn, + getCell, + getProperty, + updateCell, +} from '@blocksuite/affine-block-database'; +import { + type CellDataType, + type ColumnDataType, + type DatabaseBlockModel, + DatabaseBlockSchemaExtension, + NoteBlockSchemaExtension, + ParagraphBlockSchemaExtension, + RootBlockSchemaExtension, +} from '@blocksuite/affine-model'; +import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets'; +import type { BlockModel, Store } from '@blocksuite/store'; +import { Text } from '@blocksuite/store'; +import { + createAutoIncrementIdGenerator, + TestWorkspace, +} from '@blocksuite/store/test'; +import { beforeEach, describe, expect, test } from 'vitest'; + +const extensions = [ + RootBlockSchemaExtension, + NoteBlockSchemaExtension, + ParagraphBlockSchemaExtension, + DatabaseBlockSchemaExtension, +]; + +function createTestOptions() { + const idGenerator = createAutoIncrementIdGenerator(); + return { id: 'test-collection', idGenerator }; +} + +function createTestDoc(docId = 'doc0') { + const options = createTestOptions(); + const collection = new TestWorkspace(options); + collection.meta.initialize(); + const doc = collection.createDoc(docId); + doc.load(); + return doc.getStore({ extensions }); +} + +describe('DatabaseManager', () => { + let doc: Store; + let db: DatabaseBlockModel; + + let rootId: BlockModel['id']; + let noteBlockId: BlockModel['id']; + let databaseBlockId: BlockModel['id']; + let p1: BlockModel['id']; + let p2: BlockModel['id']; + let col1: ColumnDataType['id']; + let col2: ColumnDataType['id']; + let col3: ColumnDataType['id']; + + const selection = [ + { id: '1', value: 'Done', color: 'var(--affine-tag-white)' }, + { id: '2', value: 'TODO', color: 'var(--affine-tag-pink)' }, + { id: '3', value: 'WIP', color: 'var(--affine-tag-blue)' }, + ]; + + beforeEach(() => { + doc = createTestDoc(); + + rootId = doc.addBlock('affine:page', { + title: new Text('database test'), + }); + noteBlockId = doc.addBlock('affine:note', {}, rootId); + + databaseBlockId = doc.addBlock( + 'affine:database', + { + columns: [], + titleColumn: 'Title', + }, + noteBlockId + ); + + const databaseModel = doc.getModelById( + databaseBlockId + ) as DatabaseBlockModel; + db = databaseModel; + + col1 = addProperty( + db, + 'end', + databaseBlockProperties.numberColumnConfig.create('Number') + ); + col2 = addProperty( + db, + 'end', + propertyModelPresets.selectPropertyModelConfig.create('Single Select', { + options: selection, + }) + ); + col3 = addProperty( + db, + 'end', + databaseBlockProperties.richTextColumnConfig.create('Rich Text') + ); + + doc.updateBlock(databaseModel, { + columns: [col1, col2, col3], + }); + + p1 = doc.addBlock( + 'affine:paragraph', + { + text: new Text('text1'), + }, + databaseBlockId + ); + p2 = doc.addBlock( + 'affine:paragraph', + { + text: new Text('text2'), + }, + databaseBlockId + ); + + updateCell(db, p1, { + columnId: col1, + value: 0.1, + }); + updateCell(db, p2, { + columnId: col2, + value: [selection[1]], + }); + }); + + test('getColumn', () => { + const column = { + ...databaseBlockProperties.numberColumnConfig.create('testColumnId'), + id: 'testColumnId', + }; + addProperty(db, 'end', column); + + const result = getProperty(db, column.id); + expect(result).toEqual(column); + }); + + test('addColumn', () => { + const column = + databaseBlockProperties.numberColumnConfig.create('Test Column'); + const id = addProperty(db, 'end', column); + const result = getProperty(db, id); + + expect(result).toMatchObject(column); + expect(result).toHaveProperty('id'); + }); + + test('deleteColumn', () => { + const column = { + ...databaseBlockProperties.numberColumnConfig.create('Test Column'), + id: 'testColumnId', + }; + addProperty(db, 'end', column); + expect(getProperty(db, column.id)).toEqual(column); + + deleteColumn(db, column.id); + expect(getProperty(db, column.id)).toBeUndefined(); + }); + + test('getCell', () => { + const modelId = doc.addBlock( + 'affine:paragraph', + { + text: new Text('paragraph'), + }, + noteBlockId + ); + const column = { + ...databaseBlockProperties.numberColumnConfig.create('Test Column'), + id: 'testColumnId', + }; + const cell: CellDataType = { + columnId: column.id, + value: 42, + }; + + addProperty(db, 'end', column); + updateCell(db, modelId, cell); + + const model = doc.getModelById(modelId); + + expect(model).not.toBeNull(); + + const result = getCell(db, model!.id, column.id); + expect(result).toEqual(cell); + }); + + test('updateCell', () => { + const newRowId = doc.addBlock( + 'affine:paragraph', + { + text: new Text('text3'), + }, + databaseBlockId + ); + + updateCell(db, newRowId, { + columnId: col2, + value: [selection[2]], + }); + + const cell = getCell(db, newRowId, col2); + expect(cell).toEqual({ + columnId: col2, + value: [selection[2]], + }); + }); + + test('copyCellsByColumn', () => { + const newColId = addProperty( + db, + 'end', + propertyModelPresets.selectPropertyModelConfig.create('Copied Select', { + options: selection, + }) + ); + + copyCellsByProperty(db, col2, newColId); + + const cell = getCell(db, p2, newColId); + expect(cell).toEqual({ + columnId: newColId, + value: [selection[1]], + }); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts b/blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts new file mode 100644 index 00000000..abfeb2eb --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/database/paste-url.unit.spec.ts @@ -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 }]); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/database/typesystem.unit.spec.ts b/blocksuite/affine/all/src/__tests__/database/typesystem.unit.spec.ts new file mode 100644 index 00000000..dcab847f --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/database/typesystem.unit.spec.ts @@ -0,0 +1,79 @@ +import { type SelectTag, t, typeSystem } from '@blocksuite/data-view'; +import { describe, expect, test } from 'vitest'; + +describe('subtyping', () => { + test('all type is subtype of unknown', () => { + expect(typeSystem.unify(t.boolean.instance(), t.unknown.instance())).toBe( + true + ); + expect(typeSystem.unify(t.string.instance(), t.unknown.instance())).toBe( + true + ); + expect( + typeSystem.unify( + t.array.instance(t.string.instance()), + t.unknown.instance() + ) + ).toBe(true); + expect(typeSystem.unify(t.tag.instance(), t.unknown.instance())).toBe(true); + }); +}); +describe('function apply', () => { + test('generic type function', () => { + const fn = t.fn.instance( + [t.typeVarReference.create('A'), t.typeVarReference.create('A')], + t.boolean.instance(), + [t.typeVarDefine.create('A', t.unknown.instance())] + ); + const instancedFn = typeSystem.instanceFn( + fn, + [t.boolean.instance()], + t.boolean.instance(), + {} + ); + expect(instancedFn?.args[1]).toStrictEqual(t.boolean.instance()); + }); + test('tags infer', () => { + const fn = t.fn.instance( + [ + t.typeVarReference.create('A'), + t.array.instance(t.typeVarReference.create('A')), + ] as const, + t.boolean.instance(), + [t.typeVarDefine.create('A', t.tag.instance())] + ); + const fnArray = t.fn.instance( + [ + t.array.instance(t.typeVarReference.create('A')), + t.array.instance(t.typeVarReference.create('A')), + ] as const, + t.boolean.instance(), + [t.typeVarDefine.create('A', t.tag.instance())] + ); + const tags: SelectTag[] = [{ id: 'a', value: 'b', color: 'c' }]; + const instancedFn = typeSystem.instanceFn( + fn, + [t.tag.instance(tags)], + t.boolean.instance(), + {} + ); + const instancedFnArray = typeSystem.instanceFn( + fnArray, + [t.array.instance(t.tag.instance(tags))], + t.boolean.instance(), + {} + ); + expect( + typeSystem.unify( + instancedFn?.args[1], + t.array.instance(t.tag.instance(tags)) + ) + ).toBe(true); + expect( + typeSystem.unify( + instancedFnArray?.args[1], + t.array.instance(t.tag.instance(tags)) + ) + ).toBe(true); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/embed-iframe-config.unit.spec.ts b/blocksuite/affine/all/src/__tests__/embed-iframe-config.unit.spec.ts new file mode 100644 index 00000000..58f46dfc --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/embed-iframe-config.unit.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'vitest'; + +import { bilibiliConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/bilibili.js'; +import { excalidrawConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/excalidraw.js'; +import { genericConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/generic.js'; +import { googleDocsConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-docs.js'; +import { googleDriveConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-drive.js'; +import { miroConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/miro.js'; +import { spotifyConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/spotify.js'; + +describe('embed iframe provider config', () => { + test('validates final iframe URLs from oEmbed providers', () => { + expect( + spotifyConfig.validateIframeUrl?.( + 'https://open.spotify.com/embed/track/0TK2YIli7K1leLovkQiNik' + ) + ).toBe(true); + expect( + spotifyConfig.validateIframeUrl?.( + 'https://example.com/embed/track/0TK2YIli7K1leLovkQiNik' + ) + ).toBe(false); + }); + + test('validates provider-specific iframe URL shapes', () => { + expect( + googleDriveConfig.validateIframeUrl?.( + 'https://drive.google.com/file/d/file-id/preview?usp=embed_googleplus' + ) + ).toBe(true); + expect( + googleDriveConfig.validateIframeUrl?.( + 'https://drive.google.com/drive/folders/folder-id?usp=sharing' + ) + ).toBe(false); + + expect( + bilibiliConfig.validateIframeUrl?.( + 'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0' + ) + ).toBe(true); + expect( + bilibiliConfig.match( + 'https://player.bilibili.com/player.html?aid=123&autoplay=0' + ) + ).toBe(true); + expect( + bilibiliConfig.buildOEmbedUrl( + 'https://player.bilibili.com/video/BV1xx411c7mD' + ) + ).toBe( + 'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0' + ); + expect( + bilibiliConfig.validateIframeUrl?.( + 'https://www.bilibili.com/video/BV1xx411c7mD' + ) + ).toBe(false); + + expect( + googleDocsConfig.validateIframeUrl?.( + 'https://docs.google.com/document/d/doc-id/edit?usp=sharing' + ) + ).toBe(true); + expect( + miroConfig.validateIframeUrl?.( + 'https://miro.com/app/live-embed/board-id/' + ) + ).toBe(true); + expect( + excalidrawConfig.validateIframeUrl?.('https://excalidraw.com/#room-id') + ).toBe(true); + }); + + test('generic iframe validation excludes affine and non-https URLs', () => { + expect(genericConfig.validateIframeUrl?.('https://example.com/embed')).toBe( + true + ); + expect(genericConfig.validateIframeUrl?.('http://example.com/embed')).toBe( + false + ); + expect( + genericConfig.validateIframeUrl?.('https://app.affine.pro/embed') + ).toBe(false); + expect(genericConfig.validateIframeUrl?.('https://127.0.0.1/embed')).toBe( + false + ); + expect(genericConfig.validateIframeUrl?.('https://localhost/embed')).toBe( + false + ); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/gfx/canvas-budget.unit.spec.ts b/blocksuite/affine/all/src/__tests__/gfx/canvas-budget.unit.spec.ts new file mode 100644 index 00000000..f907f909 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/gfx/canvas-budget.unit.spec.ts @@ -0,0 +1,770 @@ +import { Bound } from '@blocksuite/global/gfx'; +import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js'; +import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js'; +import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js'; +import { + paintPlaceholder, + syncCanvasSize, +} from '../../../../gfx/turbo-renderer/src/renderer-utils.js'; +import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js'; + +const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM]; +const originalDevicePixelRatio = Object.getOwnPropertyDescriptor( + window, + 'devicePixelRatio' +); + +function setDevicePixelRatio(value: number) { + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value, + }); +} + +function createRect(width: number, height: number): DOMRect { + return { + width, + height, + left: 0, + top: 0, + right: width, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +function createFakeBlockModel( + id: string, + x: number, + y: number, + w = 10, + h = 10 +) { + return { + id, + elementBound: new Bound(x, y, w, h), + }; +} + +type PaintPlaceholderForTest = ( + canvas: HTMLCanvasElement, + layout: ViewportLayoutTree, + viewport: { + zoom: number; + toViewCoord: (x: number, y: number) => [number, number]; + } +) => void; + +afterEach(() => { + viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps]; + + if (originalDevicePixelRatio) { + Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio); + } + + vi.restoreAllMocks(); +}); + +describe('edgeless canvas budget', () => { + test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => { + viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [ + [0.5, 1], + [0.8, 2], + ]; + + expect( + 'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule + ).toBe(true); + + const shouldSyncCanvasBudgetOnViewportUpdate = ( + canvasRendererModule as { + shouldSyncCanvasBudgetOnViewportUpdate: ( + previousZoom: number, + nextZoom: number, + rawDpr?: number + ) => boolean; + } + ).shouldSyncCanvasBudgetOnViewportUpdate; + + expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true); + expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false); + expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false); + expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false); + }); + + test('enables low-zoom survival mode only for active iOS gestures', () => { + expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true); + + const shouldUseLowZoomSurvivalMode = ( + canvasRendererModule as { + shouldUseLowZoomSurvivalMode: ( + isIOS: boolean, + zoom: number, + gestureActive: boolean + ) => boolean; + } + ).shouldUseLowZoomSurvivalMode; + + expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true); + expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false); + expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false); + expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false); + }); + + test('does not enable canvas placeholders for low-zoom panning without zooming', () => { + expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true); + + const shouldRenderCanvasPlaceholders = ( + canvasRendererModule as { + shouldRenderCanvasPlaceholders: (params: { + isIOS: boolean; + zoom: number; + isPanning: boolean; + isZooming: boolean; + skipRefreshDuringGesture: boolean; + turboEnabled: boolean; + }) => boolean; + } + ).shouldRenderCanvasPlaceholders; + + expect( + shouldRenderCanvasPlaceholders({ + isIOS: true, + zoom: 0.4, + isPanning: true, + isZooming: false, + skipRefreshDuringGesture: true, + turboEnabled: true, + }) + ).toBe(false); + + expect( + shouldRenderCanvasPlaceholders({ + isIOS: true, + zoom: 0.4, + isPanning: false, + isZooming: true, + skipRefreshDuringGesture: true, + turboEnabled: true, + }) + ).toBe(true); + }); + + test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => { + expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true); + expect( + 'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule + ).toBe(true); + + const getStackingCanvasBypassState = ( + canvasRendererModule as { + getStackingCanvasBypassState: (params: { + isIOS: boolean; + zoom: number; + gestureActive: boolean; + recoveryActive: boolean; + viewportWidth: number; + viewportHeight: number; + }) => boolean; + } + ).getStackingCanvasBypassState; + const shouldBypassStackingCanvasesDuringLowZoomGesture = ( + canvasRendererModule as { + shouldBypassStackingCanvasesDuringLowZoomGesture: (params: { + isIOS: boolean; + zoom: number; + gestureActive: boolean; + recoveryActive: boolean; + viewportWidth: number; + viewportHeight: number; + }) => boolean; + } + ).shouldBypassStackingCanvasesDuringLowZoomGesture; + + expect( + getStackingCanvasBypassState({ + isIOS: true, + zoom: 0.4, + gestureActive: true, + recoveryActive: false, + viewportWidth: 932, + viewportHeight: 430, + }) + ).toBe(true); + expect( + getStackingCanvasBypassState({ + isIOS: true, + zoom: 0.4, + gestureActive: false, + recoveryActive: true, + viewportWidth: 932, + viewportHeight: 430, + }) + ).toBe(true); + expect( + getStackingCanvasBypassState({ + isIOS: true, + zoom: 0.4, + gestureActive: false, + recoveryActive: false, + viewportWidth: 932, + viewportHeight: 430, + }) + ).toBe(false); + expect( + shouldBypassStackingCanvasesDuringLowZoomGesture({ + isIOS: true, + zoom: 0.4, + gestureActive: false, + recoveryActive: false, + viewportWidth: 932, + viewportHeight: 430, + }) + ).toBe(false); + expect( + getStackingCanvasBypassState({ + isIOS: true, + zoom: 0.4, + gestureActive: true, + recoveryActive: false, + viewportWidth: 430, + viewportHeight: 932, + }) + ).toBe(false); + expect( + getStackingCanvasBypassState({ + isIOS: true, + zoom: 0.6, + gestureActive: true, + recoveryActive: false, + viewportWidth: 932, + viewportHeight: 430, + }) + ).toBe(false); + expect( + getStackingCanvasBypassState({ + isIOS: false, + zoom: 0.4, + gestureActive: true, + recoveryActive: false, + viewportWidth: 932, + viewportHeight: 430, + }) + ).toBe(false); + }); + + test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => { + expect( + 'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule + ).toBe(true); + expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe( + true + ); + + const shouldBypassStackingCanvasesDuringLowZoomGesture = ( + canvasRendererModule as { + shouldBypassStackingCanvasesDuringLowZoomGesture: (params: { + isIOS: boolean; + zoom: number; + gestureActive: boolean; + recoveryActive: boolean; + viewportWidth: number; + viewportHeight: number; + }) => boolean; + } + ).shouldBypassStackingCanvasesDuringLowZoomGesture; + const getStackingCanvasAttachmentDiff = ( + canvasRendererModule as { + getStackingCanvasAttachmentDiff: (params: { + canvases: HTMLCanvasElement[]; + wasAttached: boolean; + shouldAttach: boolean; + }) => { + added: HTMLCanvasElement[]; + removed: HTMLCanvasElement[]; + }; + } + ).getStackingCanvasAttachmentDiff; + + const canvases = [document.createElement('canvas')]; + const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({ + isIOS: true, + zoom: 0.4, + gestureActive: true, + recoveryActive: false, + viewportWidth: 932, + viewportHeight: 430, + }); + + expect(shouldBypass).toBe(true); + expect( + getStackingCanvasAttachmentDiff({ + canvases, + wasAttached: true, + shouldAttach: !shouldBypass, + }) + ).toEqual({ + added: [], + removed: canvases, + }); + }); + + test('uses overscan for main-canvas fallback culling and render origin', () => { + expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true); + + const getMainCanvasFallbackBounds = ( + canvasRendererModule as { + getMainCanvasFallbackBounds: (params: { + viewportBounds: Bound; + overscanViewportBounds: Bound; + }) => { + cullBound: Bound; + renderBound: Bound; + }; + } + ).getMainCanvasFallbackBounds; + + const viewportBounds = new Bound(100, 200, 300, 150); + const overscanViewportBounds = new Bound(40, 170, 420, 210); + + expect( + getMainCanvasFallbackBounds({ + viewportBounds, + overscanViewportBounds, + }) + ).toEqual({ + cullBound: overscanViewportBounds, + renderBound: overscanViewportBounds, + }); + }); + + test('lays out overscan canvases relative to the exact viewport', () => { + expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true); + + const getCanvasViewportLayout = ( + canvasRendererModule as { + getCanvasViewportLayout: (params: { + bound: Bound; + viewportBounds: Bound; + zoom: number; + viewScale: number; + dpr: number; + }) => { + actualHeight: number; + actualWidth: number; + height: number; + transform: string; + width: number; + }; + } + ).getCanvasViewportLayout; + + expect( + getCanvasViewportLayout({ + bound: new Bound(40, 170, 420, 210), + viewportBounds: new Bound(100, 200, 300, 150), + zoom: 1, + viewScale: 1, + dpr: 2, + }) + ).toEqual({ + actualHeight: 420, + actualWidth: 840, + height: 210, + transform: 'translate(-60px, -30px) scale(1)', + width: 420, + }); + }); + + test('computes stacking canvas DOM attachment diffs when bypass toggles', () => { + expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe( + true + ); + + const getStackingCanvasAttachmentDiff = ( + canvasRendererModule as { + getStackingCanvasAttachmentDiff: (params: { + canvases: HTMLCanvasElement[]; + wasAttached: boolean; + shouldAttach: boolean; + }) => { + added: HTMLCanvasElement[]; + removed: HTMLCanvasElement[]; + }; + } + ).getStackingCanvasAttachmentDiff; + + const canvasA = document.createElement('canvas'); + const canvasB = document.createElement('canvas'); + const canvases = [canvasA, canvasB]; + + expect( + getStackingCanvasAttachmentDiff({ + canvases, + wasAttached: true, + shouldAttach: false, + }) + ).toEqual({ + added: [], + removed: canvases, + }); + + expect( + getStackingCanvasAttachmentDiff({ + canvases, + wasAttached: false, + shouldAttach: true, + }) + ).toEqual({ + added: canvases, + removed: [], + }); + + expect( + getStackingCanvasAttachmentDiff({ + canvases, + wasAttached: true, + shouldAttach: true, + }) + ).toEqual({ + added: [], + removed: [], + }); + }); + + test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => { + viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [ + [0.5, 1], + [0.8, 2], + ]; + + const viewport = new Viewport(); + viewport.SKIP_REFRESH_DURING_GESTURE = true; + + const viewportUpdated = vi.fn(); + const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = []; + let lastCanvasBudgetZoom = viewport.zoom; + let budgetSyncCount = 0; + + viewport.viewportUpdated.subscribe(viewportUpdated); + + expect('zoomUpdated' in viewport).toBe(true); + const zoomUpdated = ( + viewport as unknown as { + zoomUpdated: { + subscribe: ( + callback: (update: { previousZoom: number; zoom: number }) => void + ) => void; + }; + } + ).zoomUpdated; + + zoomUpdated.subscribe(update => { + zoomUpdates.push(update); + if ( + ( + canvasRendererModule as { + shouldSyncCanvasBudgetOnViewportUpdate: ( + previousZoom: number, + nextZoom: number, + rawDpr?: number + ) => boolean; + } + ).shouldSyncCanvasBudgetOnViewportUpdate( + lastCanvasBudgetZoom, + update.zoom, + 2 + ) + ) { + budgetSyncCount += 1; + } + lastCanvasBudgetZoom = update.zoom; + }); + + viewport.panning$.next(true); + viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true); + + expect(viewportUpdated).not.toHaveBeenCalled(); + expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]); + expect(budgetSyncCount).toBe(1); + + viewport.dispose(); + }); + + test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => { + const viewport = new Viewport(); + viewport.SKIP_REFRESH_DURING_GESTURE = true; + + const viewportUpdated = vi.fn(); + const zoomUpdated = vi.fn(); + + viewport.viewportUpdated.subscribe(viewportUpdated); + viewport.zoomUpdated.subscribe(zoomUpdated); + + viewport.setZoom(0.4, { x: 0, y: 0 }); + + expect(viewportUpdated).toHaveBeenCalledTimes(1); + expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 }); + expect(viewport.panning$.value).toBe(false); + expect(viewport.zooming$.value).toBe(false); + + viewport.dispose(); + }); + + test('enables low-zoom block survival only while the gesture is still active', () => { + expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe( + true + ); + + const shouldUseLowZoomBlockSurvivalMode = ( + viewportElementModule as { + shouldUseLowZoomBlockSurvivalMode: (params: { + zoom: number; + skipRefreshDuringGesture: boolean; + gestureActive: boolean; + }) => boolean; + } + ).shouldUseLowZoomBlockSurvivalMode; + + expect( + shouldUseLowZoomBlockSurvivalMode({ + zoom: 0.4, + skipRefreshDuringGesture: true, + gestureActive: true, + }) + ).toBe(true); + expect( + shouldUseLowZoomBlockSurvivalMode({ + zoom: 0.4, + skipRefreshDuringGesture: true, + gestureActive: false, + }) + ).toBe(false); + }); + + test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => { + expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true); + + const getLowZoomGestureActiveModels = ( + viewportElementModule as { + getLowZoomGestureActiveModels: (params: { + selectedModels: Set<{ id: string; elementBound: Bound }>; + viewportModels: Set<{ id: string; elementBound: Bound }>; + viewportBounds: Bound; + nearbyActiveBlockLimit: number; + nearbyDistanceRatio: number; + }) => Set<{ id: string; elementBound: Bound }>; + } + ).getLowZoomGestureActiveModels; + + const selected = createFakeBlockModel('selected', 10, 10); + const nearby = createFakeBlockModel('nearby', 28, 12); + const far = createFakeBlockModel('far', 78, 78); + + const activeModels = getLowZoomGestureActiveModels({ + selectedModels: new Set([selected]), + viewportModels: new Set([selected, nearby, far]), + viewportBounds: new Bound(0, 0, 100, 100), + nearbyActiveBlockLimit: 1, + nearbyDistanceRatio: 0.35, + }); + + expect([...activeModels].map(model => model.id).sort()).toEqual([ + 'nearby', + 'selected', + ]); + }); + + test('falls back to the nearest viewport block when nothing is selected', () => { + expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true); + + const getLowZoomGestureActiveModels = ( + viewportElementModule as { + getLowZoomGestureActiveModels: (params: { + selectedModels: Set<{ id: string; elementBound: Bound }>; + viewportModels: Set<{ id: string; elementBound: Bound }>; + viewportBounds: Bound; + nearbyActiveBlockLimit: number; + nearbyDistanceRatio: number; + }) => Set<{ id: string; elementBound: Bound }>; + } + ).getLowZoomGestureActiveModels; + + const nearest = createFakeBlockModel('nearest', 46, 46); + const farther = createFakeBlockModel('farther', 78, 78); + + const activeModels = getLowZoomGestureActiveModels({ + selectedModels: new Set(), + viewportModels: new Set([nearest, farther]), + viewportBounds: new Bound(0, 0, 100, 100), + nearbyActiveBlockLimit: 1, + nearbyDistanceRatio: 0.35, + }); + + expect([...activeModels].map(model => model.id)).toEqual(['nearest']); + }); + + test('starts post-gesture recovery immediately once gesture signals fully settle', () => { + expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true); + + const getPostGestureRecoveryDelay = ( + viewportModule as { + getPostGestureRecoveryDelay: (params: { + isPanning: boolean; + isZooming: boolean; + fallbackDelayMs: number; + }) => number; + } + ).getPostGestureRecoveryDelay; + + expect( + getPostGestureRecoveryDelay({ + isPanning: false, + isZooming: false, + fallbackDelayMs: 220, + }) + ).toBe(0); + }); + + test('keeps fallback post-gesture delay while a gesture signal is still active', () => { + expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true); + + const getPostGestureRecoveryDelay = ( + viewportModule as { + getPostGestureRecoveryDelay: (params: { + isPanning: boolean; + isZooming: boolean; + fallbackDelayMs: number; + }) => number; + } + ).getPostGestureRecoveryDelay; + + expect( + getPostGestureRecoveryDelay({ + isPanning: true, + isZooming: false, + fallbackDelayMs: 220, + }) + ).toBe(220); + expect( + getPostGestureRecoveryDelay({ + isPanning: false, + isZooming: true, + fallbackDelayMs: 220, + }) + ).toBe(220); + }); + + test('sizes turbo renderer canvas with effective dpr at low zoom', () => { + viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [ + [0.5, 1], + [0.8, 2], + ]; + setDevicePixelRatio(2); + + const canvas = document.createElement('canvas'); + const host = document.createElement('div'); + vi.spyOn(host, 'getBoundingClientRect').mockReturnValue( + createRect(200, 100) + ); + + ( + syncCanvasSize as unknown as ( + canvas: HTMLCanvasElement, + host: HTMLElement, + zoom: number + ) => void + )(canvas, host, 0.4); + + expect(canvas.width).toBe(200); + expect(canvas.height).toBe(100); + + ( + syncCanvasSize as unknown as ( + canvas: HTMLCanvasElement, + host: HTMLElement, + zoom: number + ) => void + )(canvas, host, 0.95); + + expect(canvas.width).toBe(400); + expect(canvas.height).toBe(200); + }); + + test('paints turbo placeholders with effective dpr at low zoom', () => { + const previousTheme = document.documentElement.dataset.theme; + document.documentElement.dataset.theme = 'light'; + + try { + viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [ + [0.5, 1], + [0.8, 2], + ]; + setDevicePixelRatio(2); + + const canvas = document.createElement('canvas'); + const fillRect = vi.fn(); + const strokeRect = vi.fn(); + let fillStyle = ''; + let strokeStyle = ''; + vi.spyOn(canvas, 'getContext').mockReturnValue({ + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string) { + fillStyle = value; + }, + get strokeStyle() { + return strokeStyle; + }, + set strokeStyle(value: string) { + strokeStyle = value; + }, + fillRect, + strokeRect, + } as unknown as CanvasRenderingContext2D); + + const layout: ViewportLayoutTree = { + roots: [ + { + blockId: 'root', + type: 'affine:page', + layout: { + blockId: 'root', + type: 'affine:page', + rect: { x: 0, y: 0, w: 50, h: 20 }, + }, + children: [], + }, + ], + overallRect: { x: 0, y: 0, w: 50, h: 20 }, + }; + + const paintPlaceholderForTest = + paintPlaceholder as unknown as PaintPlaceholderForTest; + + paintPlaceholderForTest(canvas, layout, { + zoom: 0.4, + toViewCoord: () => [0, 0], + }); + + expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)'); + expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)'); + expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8); + + paintPlaceholderForTest(canvas, layout, { + zoom: 0.95, + toViewCoord: () => [0, 0], + }); + + expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38); + } finally { + document.documentElement.dataset.theme = previousTheme; + } + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/gfx/placeholder-style.unit.spec.ts b/blocksuite/affine/all/src/__tests__/gfx/placeholder-style.unit.spec.ts new file mode 100644 index 00000000..a34e37e1 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/gfx/placeholder-style.unit.spec.ts @@ -0,0 +1,34 @@ +import { ColorScheme } from '@blocksuite/affine-model'; +import { describe, expect, it } from 'vitest'; + +import { + getAffinePlaceholderFillColor, + getAffinePlaceholderStrokeColor, + inferColorSchemeFromThemeMode, +} from '../../../../shared/src/theme/placeholder-style.js'; + +describe('affine placeholder style', () => { + it('returns subtle light placeholder colors', () => { + expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe( + 'rgba(0, 0, 0, 0.04)' + ); + expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe( + 'rgba(0, 0, 0, 0.02)' + ); + }); + + it('returns subtle dark placeholder colors', () => { + expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe( + 'rgba(255, 255, 255, 0.08)' + ); + expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe( + 'rgba(255, 255, 255, 0.04)' + ); + }); + + it('infers color scheme from theme mode', () => { + expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark); + expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light); + expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/gfx/turbo-renderer.unit.spec.ts b/blocksuite/affine/all/src/__tests__/gfx/turbo-renderer.unit.spec.ts new file mode 100644 index 00000000..328be8a2 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/gfx/turbo-renderer.unit.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest'; + +import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js'; + +describe('viewport turbo renderer policy', () => { + test.each([ + { isIOS: true, zoom: 0.4, hasBitmap: true, expected: true }, + { isIOS: true, zoom: 0.4, hasBitmap: false, expected: false }, + { isIOS: false, zoom: 0.4, hasBitmap: true, expected: false }, + { isIOS: true, zoom: 0.8, hasBitmap: true, expected: false }, + ])( + 'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#', + ({ isIOS, zoom, hasBitmap, expected }) => { + expect( + 'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule + ).toBe(true); + + const shouldPreferBitmapCacheDuringLowZoomGesture = ( + turboRendererModule as { + shouldPreferBitmapCacheDuringLowZoomGesture: (params: { + isIOS: boolean; + zoom: number; + hasBitmap: boolean; + }) => boolean; + } + ).shouldPreferBitmapCacheDuringLowZoomGesture; + + expect( + shouldPreferBitmapCacheDuringLowZoomGesture({ + isIOS, + zoom, + hasBitmap, + }) + ).toBe(expected); + } + ); + + test.each([ + { isIOS: true, zoom: 0.4, expected: false }, + { isIOS: true, zoom: 0.8, expected: true }, + { isIOS: false, zoom: 0.4, expected: true }, + ])( + 'idles turbo blocks outside iOS low-zoom survival mode %#', + ({ isIOS, zoom, expected }) => { + expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe( + true + ); + + const shouldIdleTurboBlocksDuringZooming = ( + turboRendererModule as { + shouldIdleTurboBlocksDuringZooming: (params: { + isIOS: boolean; + zoom: number; + }) => boolean; + } + ).shouldIdleTurboBlocksDuringZooming; + + expect( + shouldIdleTurboBlocksDuringZooming({ + isIOS, + zoom, + }) + ).toBe(expected); + } + ); +}); diff --git a/blocksuite/affine/all/src/__tests__/utils/create-job.ts b/blocksuite/affine/all/src/__tests__/utils/create-job.ts new file mode 100644 index 00000000..ff1ba096 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/utils/create-job.ts @@ -0,0 +1,42 @@ +import { defaultImageProxyMiddleware } from '@blocksuite/affine-shared/adapters'; +import { + Schema, + Transformer, + type TransformerMiddleware, +} from '@blocksuite/store'; +import { TestWorkspace } from '@blocksuite/store/test'; + +import { AffineSchemas } from '../../schemas.js'; +import { testStoreExtensions } from './store.js'; + +declare global { + interface Window { + happyDOM: { + settings: { + fetch: { + disableSameOriginPolicy: boolean; + }; + }; + }; + } +} + +export function createJob(middlewares?: TransformerMiddleware[]) { + window.happyDOM.settings.fetch.disableSameOriginPolicy = true; + const testMiddlewares = middlewares ?? []; + testMiddlewares.push(defaultImageProxyMiddleware); + const schema = new Schema().register(AffineSchemas); + const docCollection = new TestWorkspace(); + docCollection.storeExtensions = testStoreExtensions; + docCollection.meta.initialize(); + return new Transformer({ + schema, + blobCRUD: docCollection.blobSync, + middlewares: testMiddlewares, + docCRUD: { + create: (id: string) => docCollection.createDoc(id).getStore({ id }), + get: (id: string) => docCollection.getDoc(id)?.getStore({ id }) ?? null, + delete: (id: string) => docCollection.removeDoc(id), + }, + }); +} diff --git a/blocksuite/affine/all/src/__tests__/utils/get-provider.ts b/blocksuite/affine/all/src/__tests__/utils/get-provider.ts new file mode 100644 index 00000000..00257f7f --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/utils/get-provider.ts @@ -0,0 +1,12 @@ +import { Container } from '@blocksuite/global/di'; + +import { testStoreExtensions } from './store'; + +export function getProvider() { + const container = new Container(); + const exts = testStoreExtensions; + exts.forEach(ext => { + ext.setup(container); + }); + return container.provider(); +} diff --git a/blocksuite/affine/all/src/__tests__/utils/nanoid-replacement.ts b/blocksuite/affine/all/src/__tests__/utils/nanoid-replacement.ts new file mode 100644 index 00000000..b0a4b6a1 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/utils/nanoid-replacement.ts @@ -0,0 +1,34 @@ +import type { BlockSnapshot, SliceSnapshot } from '@blocksuite/store'; + +export function nanoidReplacement(snapshot: BlockSnapshot | SliceSnapshot) { + return JSON.parse(nanoidReplacementString(JSON.stringify(snapshot))); +} +const escapedSnapshotAttributes = new Set([ + '"attributes"', + '"conditions"', + '"iconColumn"', + '"background"', + '"LinkedPage"', + '"elementIds"', + '"attachment"', +]); + +function nanoidReplacementString(snapshotString: string) { + const re = + /("block:[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{35}")|("[A-Za-z0-9-_]{10}:[A-Za-z0-9-_]{10}")|("var\(--affine-v2-chip-label-[a-z]{3,10}\)")|("[A-Za-z0-9-_=]{44}")/g; + const matches = snapshotString.matchAll(re); + const matchesReplaceMap = new Map(); + let escapedNumber = 0; + Array.from(matches).forEach((match, index) => { + if (escapedSnapshotAttributes.has(match[0])) { + matchesReplaceMap.set(match[0], match[0]); + escapedNumber++; + } else { + matchesReplaceMap.set( + match[0], + `"matchesReplaceMap[${index - escapedNumber}]"` + ); + } + }); + return snapshotString.replace(re, match => matchesReplaceMap.get(match)); +} diff --git a/blocksuite/affine/all/src/__tests__/utils/store.ts b/blocksuite/affine/all/src/__tests__/utils/store.ts new file mode 100644 index 00000000..92e80e8f --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/utils/store.ts @@ -0,0 +1,7 @@ +import { StoreExtensionManager } from '@blocksuite/affine-ext-loader'; + +import { getInternalStoreExtensions } from '../../extensions/store'; + +const manager = new StoreExtensionManager(getInternalStoreExtensions()); + +export const testStoreExtensions = manager.get('store'); diff --git a/blocksuite/affine/all/src/blocks/attachment/index.ts b/blocksuite/affine/all/src/blocks/attachment/index.ts new file mode 100644 index 00000000..8dd9c0a7 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/attachment/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-attachment'; diff --git a/blocksuite/affine/all/src/blocks/attachment/store.ts b/blocksuite/affine/all/src/blocks/attachment/store.ts new file mode 100644 index 00000000..24ade171 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/attachment/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-attachment/store'; diff --git a/blocksuite/affine/all/src/blocks/attachment/view.ts b/blocksuite/affine/all/src/blocks/attachment/view.ts new file mode 100644 index 00000000..771fd4b6 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/attachment/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-attachment/view'; diff --git a/blocksuite/affine/all/src/blocks/bookmark/index.ts b/blocksuite/affine/all/src/blocks/bookmark/index.ts new file mode 100644 index 00000000..bc964dbe --- /dev/null +++ b/blocksuite/affine/all/src/blocks/bookmark/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-bookmark'; diff --git a/blocksuite/affine/all/src/blocks/bookmark/store.ts b/blocksuite/affine/all/src/blocks/bookmark/store.ts new file mode 100644 index 00000000..866d3bba --- /dev/null +++ b/blocksuite/affine/all/src/blocks/bookmark/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-bookmark/store'; diff --git a/blocksuite/affine/all/src/blocks/bookmark/view.ts b/blocksuite/affine/all/src/blocks/bookmark/view.ts new file mode 100644 index 00000000..f2c1c3ae --- /dev/null +++ b/blocksuite/affine/all/src/blocks/bookmark/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-bookmark/view'; diff --git a/blocksuite/affine/all/src/blocks/callout/index.ts b/blocksuite/affine/all/src/blocks/callout/index.ts new file mode 100644 index 00000000..4c281ccb --- /dev/null +++ b/blocksuite/affine/all/src/blocks/callout/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-callout'; diff --git a/blocksuite/affine/all/src/blocks/callout/store.ts b/blocksuite/affine/all/src/blocks/callout/store.ts new file mode 100644 index 00000000..b3002438 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/callout/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-callout/store'; diff --git a/blocksuite/affine/all/src/blocks/callout/view.ts b/blocksuite/affine/all/src/blocks/callout/view.ts new file mode 100644 index 00000000..b7a98453 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/callout/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-callout/view'; diff --git a/blocksuite/affine/all/src/blocks/code/index.ts b/blocksuite/affine/all/src/blocks/code/index.ts new file mode 100644 index 00000000..46225052 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/code/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-code'; diff --git a/blocksuite/affine/all/src/blocks/code/store.ts b/blocksuite/affine/all/src/blocks/code/store.ts new file mode 100644 index 00000000..4ee11fba --- /dev/null +++ b/blocksuite/affine/all/src/blocks/code/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-code/store'; diff --git a/blocksuite/affine/all/src/blocks/code/view.ts b/blocksuite/affine/all/src/blocks/code/view.ts new file mode 100644 index 00000000..4657bad1 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/code/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-code/view'; diff --git a/blocksuite/affine/all/src/blocks/data-view/index.ts b/blocksuite/affine/all/src/blocks/data-view/index.ts new file mode 100644 index 00000000..6fd76101 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/data-view/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-data-view'; diff --git a/blocksuite/affine/all/src/blocks/data-view/store.ts b/blocksuite/affine/all/src/blocks/data-view/store.ts new file mode 100644 index 00000000..bc7a7a39 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/data-view/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-data-view/store'; diff --git a/blocksuite/affine/all/src/blocks/data-view/view.ts b/blocksuite/affine/all/src/blocks/data-view/view.ts new file mode 100644 index 00000000..316296f8 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/data-view/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-data-view/view'; diff --git a/blocksuite/affine/all/src/blocks/database/index.ts b/blocksuite/affine/all/src/blocks/database/index.ts new file mode 100644 index 00000000..ef755b90 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/database/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-database'; diff --git a/blocksuite/affine/all/src/blocks/database/store.ts b/blocksuite/affine/all/src/blocks/database/store.ts new file mode 100644 index 00000000..35a033d5 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/database/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-database/store'; diff --git a/blocksuite/affine/all/src/blocks/database/view.ts b/blocksuite/affine/all/src/blocks/database/view.ts new file mode 100644 index 00000000..9eff72c8 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/database/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-database/view'; diff --git a/blocksuite/affine/all/src/blocks/divider/index.ts b/blocksuite/affine/all/src/blocks/divider/index.ts new file mode 100644 index 00000000..52ad3ede --- /dev/null +++ b/blocksuite/affine/all/src/blocks/divider/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-divider'; diff --git a/blocksuite/affine/all/src/blocks/divider/store.ts b/blocksuite/affine/all/src/blocks/divider/store.ts new file mode 100644 index 00000000..9298e603 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/divider/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-divider/store'; diff --git a/blocksuite/affine/all/src/blocks/divider/view.ts b/blocksuite/affine/all/src/blocks/divider/view.ts new file mode 100644 index 00000000..1e6ca822 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/divider/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-divider/view'; diff --git a/blocksuite/affine/all/src/blocks/edgeless-text/index.ts b/blocksuite/affine/all/src/blocks/edgeless-text/index.ts new file mode 100644 index 00000000..3830edfd --- /dev/null +++ b/blocksuite/affine/all/src/blocks/edgeless-text/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-edgeless-text'; diff --git a/blocksuite/affine/all/src/blocks/edgeless-text/store.ts b/blocksuite/affine/all/src/blocks/edgeless-text/store.ts new file mode 100644 index 00000000..a0bd15dd --- /dev/null +++ b/blocksuite/affine/all/src/blocks/edgeless-text/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-edgeless-text/store'; diff --git a/blocksuite/affine/all/src/blocks/edgeless-text/view.ts b/blocksuite/affine/all/src/blocks/edgeless-text/view.ts new file mode 100644 index 00000000..3c744528 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/edgeless-text/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-edgeless-text/view'; diff --git a/blocksuite/affine/all/src/blocks/embed-doc/index.ts b/blocksuite/affine/all/src/blocks/embed-doc/index.ts new file mode 100644 index 00000000..0e261bbc --- /dev/null +++ b/blocksuite/affine/all/src/blocks/embed-doc/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-embed-doc'; diff --git a/blocksuite/affine/all/src/blocks/embed-doc/store.ts b/blocksuite/affine/all/src/blocks/embed-doc/store.ts new file mode 100644 index 00000000..35ffbaf0 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/embed-doc/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-embed-doc/store'; diff --git a/blocksuite/affine/all/src/blocks/embed-doc/view.ts b/blocksuite/affine/all/src/blocks/embed-doc/view.ts new file mode 100644 index 00000000..6ce8ef0c --- /dev/null +++ b/blocksuite/affine/all/src/blocks/embed-doc/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-embed-doc/view'; diff --git a/blocksuite/affine/all/src/blocks/embed/index.ts b/blocksuite/affine/all/src/blocks/embed/index.ts new file mode 100644 index 00000000..e2c2a6ab --- /dev/null +++ b/blocksuite/affine/all/src/blocks/embed/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-embed'; diff --git a/blocksuite/affine/all/src/blocks/embed/store.ts b/blocksuite/affine/all/src/blocks/embed/store.ts new file mode 100644 index 00000000..ffdd9fe5 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/embed/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-embed/store'; diff --git a/blocksuite/affine/all/src/blocks/embed/view.ts b/blocksuite/affine/all/src/blocks/embed/view.ts new file mode 100644 index 00000000..8d104c27 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/embed/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-embed/view'; diff --git a/blocksuite/affine/all/src/blocks/frame/index.ts b/blocksuite/affine/all/src/blocks/frame/index.ts new file mode 100644 index 00000000..784f1933 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/frame/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-frame'; diff --git a/blocksuite/affine/all/src/blocks/frame/store.ts b/blocksuite/affine/all/src/blocks/frame/store.ts new file mode 100644 index 00000000..f8b99989 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/frame/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-frame/store'; diff --git a/blocksuite/affine/all/src/blocks/frame/view.ts b/blocksuite/affine/all/src/blocks/frame/view.ts new file mode 100644 index 00000000..c987b809 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/frame/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-frame/view'; diff --git a/blocksuite/affine/all/src/blocks/image/index.ts b/blocksuite/affine/all/src/blocks/image/index.ts new file mode 100644 index 00000000..6811a74a --- /dev/null +++ b/blocksuite/affine/all/src/blocks/image/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-image'; diff --git a/blocksuite/affine/all/src/blocks/image/store.ts b/blocksuite/affine/all/src/blocks/image/store.ts new file mode 100644 index 00000000..86f9657e --- /dev/null +++ b/blocksuite/affine/all/src/blocks/image/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-image/store'; diff --git a/blocksuite/affine/all/src/blocks/image/view.ts b/blocksuite/affine/all/src/blocks/image/view.ts new file mode 100644 index 00000000..2f9aca22 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/image/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-image/view'; diff --git a/blocksuite/affine/all/src/blocks/latex/index.ts b/blocksuite/affine/all/src/blocks/latex/index.ts new file mode 100644 index 00000000..67d31882 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/latex/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-latex'; diff --git a/blocksuite/affine/all/src/blocks/latex/store.ts b/blocksuite/affine/all/src/blocks/latex/store.ts new file mode 100644 index 00000000..75ce5078 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/latex/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-latex/store'; diff --git a/blocksuite/affine/all/src/blocks/latex/view.ts b/blocksuite/affine/all/src/blocks/latex/view.ts new file mode 100644 index 00000000..83efca70 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/latex/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-latex/view'; diff --git a/blocksuite/affine/all/src/blocks/list/index.ts b/blocksuite/affine/all/src/blocks/list/index.ts new file mode 100644 index 00000000..8e4a7de6 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/list/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-list'; diff --git a/blocksuite/affine/all/src/blocks/list/store.ts b/blocksuite/affine/all/src/blocks/list/store.ts new file mode 100644 index 00000000..f0a792c5 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/list/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-list/store'; diff --git a/blocksuite/affine/all/src/blocks/list/view.ts b/blocksuite/affine/all/src/blocks/list/view.ts new file mode 100644 index 00000000..7c326913 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/list/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-list/view'; diff --git a/blocksuite/affine/all/src/blocks/note/index.ts b/blocksuite/affine/all/src/blocks/note/index.ts new file mode 100644 index 00000000..e93e4115 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/note/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-note'; diff --git a/blocksuite/affine/all/src/blocks/note/store.ts b/blocksuite/affine/all/src/blocks/note/store.ts new file mode 100644 index 00000000..1da7d674 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/note/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-note/store'; diff --git a/blocksuite/affine/all/src/blocks/note/view.ts b/blocksuite/affine/all/src/blocks/note/view.ts new file mode 100644 index 00000000..3d85f988 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/note/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-note/view'; diff --git a/blocksuite/affine/all/src/blocks/paragraph/index.ts b/blocksuite/affine/all/src/blocks/paragraph/index.ts new file mode 100644 index 00000000..169b4c3e --- /dev/null +++ b/blocksuite/affine/all/src/blocks/paragraph/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-paragraph'; diff --git a/blocksuite/affine/all/src/blocks/paragraph/store.ts b/blocksuite/affine/all/src/blocks/paragraph/store.ts new file mode 100644 index 00000000..dfbe3d03 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/paragraph/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-paragraph/store'; diff --git a/blocksuite/affine/all/src/blocks/paragraph/view.ts b/blocksuite/affine/all/src/blocks/paragraph/view.ts new file mode 100644 index 00000000..b7b20284 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/paragraph/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-paragraph/view'; diff --git a/blocksuite/affine/all/src/blocks/root/index.ts b/blocksuite/affine/all/src/blocks/root/index.ts new file mode 100644 index 00000000..409d27ed --- /dev/null +++ b/blocksuite/affine/all/src/blocks/root/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-root'; diff --git a/blocksuite/affine/all/src/blocks/root/store.ts b/blocksuite/affine/all/src/blocks/root/store.ts new file mode 100644 index 00000000..e199cfa5 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/root/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-root/store'; diff --git a/blocksuite/affine/all/src/blocks/root/view.ts b/blocksuite/affine/all/src/blocks/root/view.ts new file mode 100644 index 00000000..533014ac --- /dev/null +++ b/blocksuite/affine/all/src/blocks/root/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-root/view'; diff --git a/blocksuite/affine/all/src/blocks/surface-ref/index.ts b/blocksuite/affine/all/src/blocks/surface-ref/index.ts new file mode 100644 index 00000000..9fcc7147 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/surface-ref/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-surface-ref'; diff --git a/blocksuite/affine/all/src/blocks/surface-ref/store.ts b/blocksuite/affine/all/src/blocks/surface-ref/store.ts new file mode 100644 index 00000000..651b2b7e --- /dev/null +++ b/blocksuite/affine/all/src/blocks/surface-ref/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-surface-ref/store'; diff --git a/blocksuite/affine/all/src/blocks/surface-ref/view.ts b/blocksuite/affine/all/src/blocks/surface-ref/view.ts new file mode 100644 index 00000000..c66c1099 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/surface-ref/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-surface-ref/view'; diff --git a/blocksuite/affine/all/src/blocks/surface/index.ts b/blocksuite/affine/all/src/blocks/surface/index.ts new file mode 100644 index 00000000..a13c8619 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/surface/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-surface'; diff --git a/blocksuite/affine/all/src/blocks/surface/store.ts b/blocksuite/affine/all/src/blocks/surface/store.ts new file mode 100644 index 00000000..8f6d1148 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/surface/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-surface/store'; diff --git a/blocksuite/affine/all/src/blocks/surface/view.ts b/blocksuite/affine/all/src/blocks/surface/view.ts new file mode 100644 index 00000000..ea1df9ea --- /dev/null +++ b/blocksuite/affine/all/src/blocks/surface/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-surface/view'; diff --git a/blocksuite/affine/all/src/blocks/table/index.ts b/blocksuite/affine/all/src/blocks/table/index.ts new file mode 100644 index 00000000..da648e7f --- /dev/null +++ b/blocksuite/affine/all/src/blocks/table/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-table'; diff --git a/blocksuite/affine/all/src/blocks/table/store.ts b/blocksuite/affine/all/src/blocks/table/store.ts new file mode 100644 index 00000000..53a3e02a --- /dev/null +++ b/blocksuite/affine/all/src/blocks/table/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-table/store'; diff --git a/blocksuite/affine/all/src/blocks/table/view.ts b/blocksuite/affine/all/src/blocks/table/view.ts new file mode 100644 index 00000000..d57f83eb --- /dev/null +++ b/blocksuite/affine/all/src/blocks/table/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-block-table/view'; diff --git a/blocksuite/affine/all/src/components/block-selection.ts b/blocksuite/affine/all/src/components/block-selection.ts new file mode 100644 index 00000000..6f1f30da --- /dev/null +++ b/blocksuite/affine/all/src/components/block-selection.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/block-selection'; diff --git a/blocksuite/affine/all/src/components/block-zero-width.ts b/blocksuite/affine/all/src/components/block-zero-width.ts new file mode 100644 index 00000000..0176cdb2 --- /dev/null +++ b/blocksuite/affine/all/src/components/block-zero-width.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/block-zero-width'; diff --git a/blocksuite/affine/all/src/components/caption.ts b/blocksuite/affine/all/src/components/caption.ts new file mode 100644 index 00000000..454a0435 --- /dev/null +++ b/blocksuite/affine/all/src/components/caption.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/caption'; diff --git a/blocksuite/affine/all/src/components/card-style-dropdown-menu.ts b/blocksuite/affine/all/src/components/card-style-dropdown-menu.ts new file mode 100644 index 00000000..74492d0c --- /dev/null +++ b/blocksuite/affine/all/src/components/card-style-dropdown-menu.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/card-style-dropdown-menu'; diff --git a/blocksuite/affine/all/src/components/citation.ts b/blocksuite/affine/all/src/components/citation.ts new file mode 100644 index 00000000..65b458a9 --- /dev/null +++ b/blocksuite/affine/all/src/components/citation.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/citation'; diff --git a/blocksuite/affine/all/src/components/color-picker.ts b/blocksuite/affine/all/src/components/color-picker.ts new file mode 100644 index 00000000..56dfe752 --- /dev/null +++ b/blocksuite/affine/all/src/components/color-picker.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/color-picker'; diff --git a/blocksuite/affine/all/src/components/context-menu.ts b/blocksuite/affine/all/src/components/context-menu.ts new file mode 100644 index 00000000..2072f43c --- /dev/null +++ b/blocksuite/affine/all/src/components/context-menu.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/context-menu'; diff --git a/blocksuite/affine/all/src/components/date-picker.ts b/blocksuite/affine/all/src/components/date-picker.ts new file mode 100644 index 00000000..2259df8d --- /dev/null +++ b/blocksuite/affine/all/src/components/date-picker.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/date-picker'; diff --git a/blocksuite/affine/all/src/components/drop-indicator.ts b/blocksuite/affine/all/src/components/drop-indicator.ts new file mode 100644 index 00000000..cbab3ce0 --- /dev/null +++ b/blocksuite/affine/all/src/components/drop-indicator.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/drop-indicator'; diff --git a/blocksuite/affine/all/src/components/edgeless-line-styles-panel.ts b/blocksuite/affine/all/src/components/edgeless-line-styles-panel.ts new file mode 100644 index 00000000..a1c6fd0b --- /dev/null +++ b/blocksuite/affine/all/src/components/edgeless-line-styles-panel.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/edgeless-line-styles-panel'; diff --git a/blocksuite/affine/all/src/components/edgeless-line-width-panel.ts b/blocksuite/affine/all/src/components/edgeless-line-width-panel.ts new file mode 100644 index 00000000..40fbc3fb --- /dev/null +++ b/blocksuite/affine/all/src/components/edgeless-line-width-panel.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/edgeless-line-width-panel'; diff --git a/blocksuite/affine/all/src/components/edgeless-shape-color-picker.ts b/blocksuite/affine/all/src/components/edgeless-shape-color-picker.ts new file mode 100644 index 00000000..a1ecdc27 --- /dev/null +++ b/blocksuite/affine/all/src/components/edgeless-shape-color-picker.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/edgeless-shape-color-picker'; diff --git a/blocksuite/affine/all/src/components/embed-card-modal.ts b/blocksuite/affine/all/src/components/embed-card-modal.ts new file mode 100644 index 00000000..52cd10c4 --- /dev/null +++ b/blocksuite/affine/all/src/components/embed-card-modal.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/embed-card-modal'; diff --git a/blocksuite/affine/all/src/components/filterable-list.ts b/blocksuite/affine/all/src/components/filterable-list.ts new file mode 100644 index 00000000..dd28e8b0 --- /dev/null +++ b/blocksuite/affine/all/src/components/filterable-list.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/filterable-list'; diff --git a/blocksuite/affine/all/src/components/highlight-dropdown-menu.ts b/blocksuite/affine/all/src/components/highlight-dropdown-menu.ts new file mode 100644 index 00000000..fdbb9bb8 --- /dev/null +++ b/blocksuite/affine/all/src/components/highlight-dropdown-menu.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/highlight-dropdown-menu'; diff --git a/blocksuite/affine/all/src/components/hover.ts b/blocksuite/affine/all/src/components/hover.ts new file mode 100644 index 00000000..3ba22fc0 --- /dev/null +++ b/blocksuite/affine/all/src/components/hover.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/hover'; diff --git a/blocksuite/affine/all/src/components/icon-button.ts b/blocksuite/affine/all/src/components/icon-button.ts new file mode 100644 index 00000000..396a3bc8 --- /dev/null +++ b/blocksuite/affine/all/src/components/icon-button.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/icon-button'; diff --git a/blocksuite/affine/all/src/components/icons.ts b/blocksuite/affine/all/src/components/icons.ts new file mode 100644 index 00000000..f8696ca4 --- /dev/null +++ b/blocksuite/affine/all/src/components/icons.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/icons'; diff --git a/blocksuite/affine/all/src/components/link-preview.ts b/blocksuite/affine/all/src/components/link-preview.ts new file mode 100644 index 00000000..f059fe07 --- /dev/null +++ b/blocksuite/affine/all/src/components/link-preview.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/link-preview'; diff --git a/blocksuite/affine/all/src/components/linked-doc-title.ts b/blocksuite/affine/all/src/components/linked-doc-title.ts new file mode 100644 index 00000000..d7bf24b2 --- /dev/null +++ b/blocksuite/affine/all/src/components/linked-doc-title.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/linked-doc-title'; diff --git a/blocksuite/affine/all/src/components/notification.ts b/blocksuite/affine/all/src/components/notification.ts new file mode 100644 index 00000000..d14e335d --- /dev/null +++ b/blocksuite/affine/all/src/components/notification.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/notification'; diff --git a/blocksuite/affine/all/src/components/open-doc-dropdown-menu.ts b/blocksuite/affine/all/src/components/open-doc-dropdown-menu.ts new file mode 100644 index 00000000..2513f9c6 --- /dev/null +++ b/blocksuite/affine/all/src/components/open-doc-dropdown-menu.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/open-doc-dropdown-menu'; diff --git a/blocksuite/affine/all/src/components/peek.ts b/blocksuite/affine/all/src/components/peek.ts new file mode 100644 index 00000000..58890158 --- /dev/null +++ b/blocksuite/affine/all/src/components/peek.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/peek'; diff --git a/blocksuite/affine/all/src/components/portal.ts b/blocksuite/affine/all/src/components/portal.ts new file mode 100644 index 00000000..a56596b7 --- /dev/null +++ b/blocksuite/affine/all/src/components/portal.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/portal'; diff --git a/blocksuite/affine/all/src/components/resource.ts b/blocksuite/affine/all/src/components/resource.ts new file mode 100644 index 00000000..e23d2125 --- /dev/null +++ b/blocksuite/affine/all/src/components/resource.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/resource'; diff --git a/blocksuite/affine/all/src/components/size-dropdown-menu.ts b/blocksuite/affine/all/src/components/size-dropdown-menu.ts new file mode 100644 index 00000000..a1180509 --- /dev/null +++ b/blocksuite/affine/all/src/components/size-dropdown-menu.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/size-dropdown-menu'; diff --git a/blocksuite/affine/all/src/components/smooth-corner.ts b/blocksuite/affine/all/src/components/smooth-corner.ts new file mode 100644 index 00000000..8d01174e --- /dev/null +++ b/blocksuite/affine/all/src/components/smooth-corner.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/smooth-corner'; diff --git a/blocksuite/affine/all/src/components/toast.ts b/blocksuite/affine/all/src/components/toast.ts new file mode 100644 index 00000000..ec81a19d --- /dev/null +++ b/blocksuite/affine/all/src/components/toast.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/toast'; diff --git a/blocksuite/affine/all/src/components/toggle-button.ts b/blocksuite/affine/all/src/components/toggle-button.ts new file mode 100644 index 00000000..001034d8 --- /dev/null +++ b/blocksuite/affine/all/src/components/toggle-button.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/toggle-button'; diff --git a/blocksuite/affine/all/src/components/toggle-switch.ts b/blocksuite/affine/all/src/components/toggle-switch.ts new file mode 100644 index 00000000..083697db --- /dev/null +++ b/blocksuite/affine/all/src/components/toggle-switch.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/toggle-switch'; diff --git a/blocksuite/affine/all/src/components/toolbar.ts b/blocksuite/affine/all/src/components/toolbar.ts new file mode 100644 index 00000000..d2e68686 --- /dev/null +++ b/blocksuite/affine/all/src/components/toolbar.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/toolbar'; diff --git a/blocksuite/affine/all/src/components/tooltip-content-with-shortcut.ts b/blocksuite/affine/all/src/components/tooltip-content-with-shortcut.ts new file mode 100644 index 00000000..16ccb446 --- /dev/null +++ b/blocksuite/affine/all/src/components/tooltip-content-with-shortcut.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/tooltip-content-with-shortcut'; diff --git a/blocksuite/affine/all/src/components/tooltip.ts b/blocksuite/affine/all/src/components/tooltip.ts new file mode 100644 index 00000000..0bd9b6b6 --- /dev/null +++ b/blocksuite/affine/all/src/components/tooltip.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/tooltip'; diff --git a/blocksuite/affine/all/src/components/view-dropdown-menu.ts b/blocksuite/affine/all/src/components/view-dropdown-menu.ts new file mode 100644 index 00000000..60ae3d6b --- /dev/null +++ b/blocksuite/affine/all/src/components/view-dropdown-menu.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-components/view-dropdown-menu'; diff --git a/blocksuite/affine/all/src/data-view/effects.ts b/blocksuite/affine/all/src/data-view/effects.ts new file mode 100644 index 00000000..b29ebef0 --- /dev/null +++ b/blocksuite/affine/all/src/data-view/effects.ts @@ -0,0 +1 @@ +export * from '@blocksuite/data-view/effects'; diff --git a/blocksuite/affine/all/src/data-view/index.ts b/blocksuite/affine/all/src/data-view/index.ts new file mode 100644 index 00000000..112e65c1 --- /dev/null +++ b/blocksuite/affine/all/src/data-view/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/data-view'; diff --git a/blocksuite/affine/all/src/effects.ts b/blocksuite/affine/all/src/effects.ts new file mode 100644 index 00000000..0a994c0b --- /dev/null +++ b/blocksuite/affine/all/src/effects.ts @@ -0,0 +1,34 @@ +import { type effects as blockRootEffects } from '@blocksuite/affine-block-root/effects'; +import { type effects as componentCaptionEffects } from '@blocksuite/affine-components/caption'; +import { type effects as componentColorPickerEffects } from '@blocksuite/affine-components/color-picker'; +import { type effects as componentContextMenuEffects } from '@blocksuite/affine-components/context-menu'; +import { type effects as componentDatePickerEffects } from '@blocksuite/affine-components/date-picker'; +import { type effects as componentDropIndicatorEffects } from '@blocksuite/affine-components/drop-indicator'; +import { type effects as componentEmbedCardModalEffects } from '@blocksuite/affine-components/embed-card-modal'; +import { type effects as componentHighlightDropdownMenuEffects } from '@blocksuite/affine-components/highlight-dropdown-menu'; +import { type effects as componentLinkPreviewEffects } from '@blocksuite/affine-components/link-preview'; +import { type effects as componentLinkedDocTitleEffects } from '@blocksuite/affine-components/linked-doc-title'; +import { type effects as componentPortalEffects } from '@blocksuite/affine-components/portal'; +import { type effects as componentToggleButtonEffects } from '@blocksuite/affine-components/toggle-button'; +import { type effects as componentToolbarEffects } from '@blocksuite/affine-components/toolbar'; +import { type effects as componentViewDropdownMenuEffects } from '@blocksuite/affine-components/view-dropdown-menu'; +import { type effects as richTextEffects } from '@blocksuite/affine-rich-text/effects'; +import { type effects as stdEffects } from '@blocksuite/std/effects'; + +export declare const _GLOBAL_: + | typeof stdEffects + | typeof richTextEffects + | typeof blockRootEffects + | typeof componentCaptionEffects + | typeof componentContextMenuEffects + | typeof componentDatePickerEffects + | typeof componentDropIndicatorEffects + | typeof componentEmbedCardModalEffects + | typeof componentHighlightDropdownMenuEffects + | typeof componentLinkPreviewEffects + | typeof componentLinkedDocTitleEffects + | typeof componentPortalEffects + | typeof componentToolbarEffects + | typeof componentToggleButtonEffects + | typeof componentColorPickerEffects + | typeof componentViewDropdownMenuEffects; diff --git a/blocksuite/affine/all/src/ext-loader/index.ts b/blocksuite/affine/all/src/ext-loader/index.ts new file mode 100644 index 00000000..ada0306f --- /dev/null +++ b/blocksuite/affine/all/src/ext-loader/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-ext-loader'; diff --git a/blocksuite/affine/all/src/extensions/store.ts b/blocksuite/affine/all/src/extensions/store.ts new file mode 100644 index 00000000..6fa7e679 --- /dev/null +++ b/blocksuite/affine/all/src/extensions/store.ts @@ -0,0 +1,72 @@ +import { AttachmentStoreExtension } from '@blocksuite/affine-block-attachment/store'; +import { BookmarkStoreExtension } from '@blocksuite/affine-block-bookmark/store'; +import { CalloutStoreExtension } from '@blocksuite/affine-block-callout/store'; +import { CodeStoreExtension } from '@blocksuite/affine-block-code/store'; +import { DataViewStoreExtension } from '@blocksuite/affine-block-data-view/store'; +import { DatabaseStoreExtension } from '@blocksuite/affine-block-database/store'; +import { DividerStoreExtension } from '@blocksuite/affine-block-divider/store'; +import { EdgelessTextStoreExtension } from '@blocksuite/affine-block-edgeless-text/store'; +import { EmbedStoreExtension } from '@blocksuite/affine-block-embed/store'; +import { EmbedDocStoreExtension } from '@blocksuite/affine-block-embed-doc/store'; +import { FrameStoreExtension } from '@blocksuite/affine-block-frame/store'; +import { ImageStoreExtension } from '@blocksuite/affine-block-image/store'; +import { LatexStoreExtension } from '@blocksuite/affine-block-latex/store'; +import { ListStoreExtension } from '@blocksuite/affine-block-list/store'; +import { NoteStoreExtension } from '@blocksuite/affine-block-note/store'; +import { ParagraphStoreExtension } from '@blocksuite/affine-block-paragraph/store'; +import { RootStoreExtension } from '@blocksuite/affine-block-root/store'; +import { SurfaceStoreExtension } from '@blocksuite/affine-block-surface/store'; +import { SurfaceRefStoreExtension } from '@blocksuite/affine-block-surface-ref/store'; +import { TableStoreExtension } from '@blocksuite/affine-block-table/store'; +import { FoundationStoreExtension } from '@blocksuite/affine-foundation/store'; +import { BrushStoreExtension } from '@blocksuite/affine-gfx-brush/store'; +import { ConnectorStoreExtension } from '@blocksuite/affine-gfx-connector/store'; +import { GroupStoreExtension } from '@blocksuite/affine-gfx-group/store'; +import { MindmapStoreExtension } from '@blocksuite/affine-gfx-mindmap/store'; +import { ShapeStoreExtension } from '@blocksuite/affine-gfx-shape/store'; +import { TextStoreExtension } from '@blocksuite/affine-gfx-text/store'; +import { FootnoteStoreExtension } from '@blocksuite/affine-inline-footnote/store'; +import { LatexStoreExtension as InlineLatexStoreExtension } from '@blocksuite/affine-inline-latex/store'; +import { LinkStoreExtension } from '@blocksuite/affine-inline-link/store'; +import { InlinePresetStoreExtension } from '@blocksuite/affine-inline-preset/store'; +import { ReferenceStoreExtension } from '@blocksuite/affine-inline-reference/store'; + +export function getInternalStoreExtensions() { + return [ + FoundationStoreExtension, + + AttachmentStoreExtension, + BookmarkStoreExtension, + CalloutStoreExtension, + CodeStoreExtension, + DataViewStoreExtension, + DatabaseStoreExtension, + DividerStoreExtension, + EdgelessTextStoreExtension, + EmbedStoreExtension, + EmbedDocStoreExtension, + FrameStoreExtension, + ImageStoreExtension, + LatexStoreExtension, + ListStoreExtension, + NoteStoreExtension, + ParagraphStoreExtension, + SurfaceRefStoreExtension, + TableStoreExtension, + SurfaceStoreExtension, + RootStoreExtension, + + FootnoteStoreExtension, + LinkStoreExtension, + ReferenceStoreExtension, + InlineLatexStoreExtension, + InlinePresetStoreExtension, + + BrushStoreExtension, + ShapeStoreExtension, + MindmapStoreExtension, + ConnectorStoreExtension, + GroupStoreExtension, + TextStoreExtension, + ]; +} diff --git a/blocksuite/affine/all/src/extensions/view.ts b/blocksuite/affine/all/src/extensions/view.ts new file mode 100644 index 00000000..5668ad54 --- /dev/null +++ b/blocksuite/affine/all/src/extensions/view.ts @@ -0,0 +1,132 @@ +import { AttachmentViewExtension } from '@blocksuite/affine-block-attachment/view'; +import { BookmarkViewExtension } from '@blocksuite/affine-block-bookmark/view'; +import { CalloutViewExtension } from '@blocksuite/affine-block-callout/view'; +import { CodeBlockViewExtension } from '@blocksuite/affine-block-code/view'; +import { DataViewViewExtension } from '@blocksuite/affine-block-data-view/view'; +import { DatabaseViewExtension } from '@blocksuite/affine-block-database/view'; +import { DividerViewExtension } from '@blocksuite/affine-block-divider/view'; +import { EdgelessTextViewExtension } from '@blocksuite/affine-block-edgeless-text/view'; +import { EmbedViewExtension } from '@blocksuite/affine-block-embed/view'; +import { EmbedDocViewExtension } from '@blocksuite/affine-block-embed-doc/view'; +import { FrameViewExtension } from '@blocksuite/affine-block-frame/view'; +import { ImageViewExtension } from '@blocksuite/affine-block-image/view'; +import { LatexViewExtension } from '@blocksuite/affine-block-latex/view'; +import { ListViewExtension } from '@blocksuite/affine-block-list/view'; +import { NoteViewExtension } from '@blocksuite/affine-block-note/view'; +import { ParagraphViewExtension } from '@blocksuite/affine-block-paragraph/view'; +import { RootViewExtension } from '@blocksuite/affine-block-root/view'; +import { SurfaceViewExtension } from '@blocksuite/affine-block-surface/view'; +import { SurfaceRefViewExtension } from '@blocksuite/affine-block-surface-ref/view'; +import { TableViewExtension } from '@blocksuite/affine-block-table/view'; +import { FoundationViewExtension } from '@blocksuite/affine-foundation/view'; +import { AdapterPanelViewExtension } from '@blocksuite/affine-fragment-adapter-panel/view'; +import { DocTitleViewExtension } from '@blocksuite/affine-fragment-doc-title/view'; +import { FramePanelViewExtension } from '@blocksuite/affine-fragment-frame-panel/view'; +import { OutlineViewExtension } from '@blocksuite/affine-fragment-outline/view'; +import { BrushViewExtension } from '@blocksuite/affine-gfx-brush/view'; +import { ConnectorViewExtension } from '@blocksuite/affine-gfx-connector/view'; +import { GroupViewExtension } from '@blocksuite/affine-gfx-group/view'; +import { LinkViewExtension as GfxLinkViewExtension } from '@blocksuite/affine-gfx-link/view'; +import { MindmapViewExtension } from '@blocksuite/affine-gfx-mindmap/view'; +import { NoteViewExtension as GfxNoteViewExtension } from '@blocksuite/affine-gfx-note/view'; +import { PointerViewExtension } from '@blocksuite/affine-gfx-pointer/view'; +import { ShapeViewExtension } from '@blocksuite/affine-gfx-shape/view'; +import { TemplateViewExtension } from '@blocksuite/affine-gfx-template/view'; +import { TextViewExtension } from '@blocksuite/affine-gfx-text/view'; +import { InlineCommentViewExtension } from '@blocksuite/affine-inline-comment/view'; +import { FootnoteViewExtension } from '@blocksuite/affine-inline-footnote/view'; +import { LatexViewExtension as InlineLatexViewExtension } from '@blocksuite/affine-inline-latex/view'; +import { LinkViewExtension } from '@blocksuite/affine-inline-link/view'; +import { MentionViewExtension } from '@blocksuite/affine-inline-mention/view'; +import { InlinePresetViewExtension } from '@blocksuite/affine-inline-preset/view'; +import { ReferenceViewExtension } from '@blocksuite/affine-inline-reference/view'; +import { DragHandleViewExtension } from '@blocksuite/affine-widget-drag-handle/view'; +import { EdgelessAutoConnectViewExtension } from '@blocksuite/affine-widget-edgeless-auto-connect/view'; +import { EdgelessDraggingAreaViewExtension } from '@blocksuite/affine-widget-edgeless-dragging-area/view'; +import { EdgelessSelectedRectViewExtension } from '@blocksuite/affine-widget-edgeless-selected-rect/view'; +import { EdgelessToolbarViewExtension } from '@blocksuite/affine-widget-edgeless-toolbar/view'; +import { EdgelessZoomToolbarViewExtension } from '@blocksuite/affine-widget-edgeless-zoom-toolbar/view'; +import { FrameTitleViewExtension } from '@blocksuite/affine-widget-frame-title/view'; +import { KeyboardToolbarViewExtension } from '@blocksuite/affine-widget-keyboard-toolbar/view'; +import { LinkedDocViewExtension } from '@blocksuite/affine-widget-linked-doc/view'; +import { NoteSlicerViewExtension } from '@blocksuite/affine-widget-note-slicer/view'; +import { PageDraggingAreaViewExtension } from '@blocksuite/affine-widget-page-dragging-area/view'; +import { RemoteSelectionViewExtension } from '@blocksuite/affine-widget-remote-selection/view'; +import { ScrollAnchoringViewExtension } from '@blocksuite/affine-widget-scroll-anchoring/view'; +import { SlashMenuViewExtension } from '@blocksuite/affine-widget-slash-menu/view'; +import { ToolbarViewExtension } from '@blocksuite/affine-widget-toolbar/view'; +import { ViewportOverlayViewExtension } from '@blocksuite/affine-widget-viewport-overlay/view'; + +export function getInternalViewExtensions() { + return [ + FoundationViewExtension, + + // Gfx + PointerViewExtension, + GfxNoteViewExtension, + BrushViewExtension, + ShapeViewExtension, + MindmapViewExtension, + ConnectorViewExtension, + GroupViewExtension, + TextViewExtension, + TemplateViewExtension, + GfxLinkViewExtension, + + // Block + AttachmentViewExtension, + BookmarkViewExtension, + CalloutViewExtension, + CodeBlockViewExtension, + DataViewViewExtension, + DatabaseViewExtension, + DividerViewExtension, + EdgelessTextViewExtension, + EmbedViewExtension, + EmbedDocViewExtension, + FrameViewExtension, + ImageViewExtension, + LatexViewExtension, + ListViewExtension, + NoteViewExtension, + ParagraphViewExtension, + SurfaceRefViewExtension, + TableViewExtension, + SurfaceViewExtension, + RootViewExtension, + + // Inline + InlineCommentViewExtension, + FootnoteViewExtension, + LinkViewExtension, + ReferenceViewExtension, + InlineLatexViewExtension, + MentionViewExtension, + InlinePresetViewExtension, + + // Widget + // order will affect the z-index of the widget + DragHandleViewExtension, + EdgelessAutoConnectViewExtension, + FrameTitleViewExtension, + KeyboardToolbarViewExtension, + LinkedDocViewExtension, + RemoteSelectionViewExtension, + ScrollAnchoringViewExtension, + SlashMenuViewExtension, + ToolbarViewExtension, + ViewportOverlayViewExtension, + EdgelessZoomToolbarViewExtension, + PageDraggingAreaViewExtension, + EdgelessSelectedRectViewExtension, + EdgelessDraggingAreaViewExtension, + NoteSlicerViewExtension, + EdgelessToolbarViewExtension, + + // Fragment + DocTitleViewExtension, + FramePanelViewExtension, + OutlineViewExtension, + AdapterPanelViewExtension, + ]; +} diff --git a/blocksuite/affine/all/src/foundation/clipboard.ts b/blocksuite/affine/all/src/foundation/clipboard.ts new file mode 100644 index 00000000..ee189e48 --- /dev/null +++ b/blocksuite/affine/all/src/foundation/clipboard.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-foundation/clipboard'; diff --git a/blocksuite/affine/all/src/foundation/store.ts b/blocksuite/affine/all/src/foundation/store.ts new file mode 100644 index 00000000..54ebe27f --- /dev/null +++ b/blocksuite/affine/all/src/foundation/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-foundation/store'; diff --git a/blocksuite/affine/all/src/foundation/view.ts b/blocksuite/affine/all/src/foundation/view.ts new file mode 100644 index 00000000..dbb9444e --- /dev/null +++ b/blocksuite/affine/all/src/foundation/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-foundation/view'; diff --git a/blocksuite/affine/all/src/fragments/adapter-panel/index.ts b/blocksuite/affine/all/src/fragments/adapter-panel/index.ts new file mode 100644 index 00000000..950692ac --- /dev/null +++ b/blocksuite/affine/all/src/fragments/adapter-panel/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-adapter-panel'; diff --git a/blocksuite/affine/all/src/fragments/adapter-panel/view.ts b/blocksuite/affine/all/src/fragments/adapter-panel/view.ts new file mode 100644 index 00000000..df401534 --- /dev/null +++ b/blocksuite/affine/all/src/fragments/adapter-panel/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-adapter-panel/view'; diff --git a/blocksuite/affine/all/src/fragments/doc-title/index.ts b/blocksuite/affine/all/src/fragments/doc-title/index.ts new file mode 100644 index 00000000..24260950 --- /dev/null +++ b/blocksuite/affine/all/src/fragments/doc-title/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-doc-title'; diff --git a/blocksuite/affine/all/src/fragments/doc-title/view.ts b/blocksuite/affine/all/src/fragments/doc-title/view.ts new file mode 100644 index 00000000..8d78fc70 --- /dev/null +++ b/blocksuite/affine/all/src/fragments/doc-title/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-doc-title/view'; diff --git a/blocksuite/affine/all/src/fragments/frame-panel/index.ts b/blocksuite/affine/all/src/fragments/frame-panel/index.ts new file mode 100644 index 00000000..1f1fb52c --- /dev/null +++ b/blocksuite/affine/all/src/fragments/frame-panel/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-frame-panel'; diff --git a/blocksuite/affine/all/src/fragments/frame-panel/view.ts b/blocksuite/affine/all/src/fragments/frame-panel/view.ts new file mode 100644 index 00000000..2d603691 --- /dev/null +++ b/blocksuite/affine/all/src/fragments/frame-panel/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-frame-panel/view'; diff --git a/blocksuite/affine/all/src/fragments/outline/index.ts b/blocksuite/affine/all/src/fragments/outline/index.ts new file mode 100644 index 00000000..695fdd5e --- /dev/null +++ b/blocksuite/affine/all/src/fragments/outline/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-outline'; diff --git a/blocksuite/affine/all/src/fragments/outline/view.ts b/blocksuite/affine/all/src/fragments/outline/view.ts new file mode 100644 index 00000000..09936a73 --- /dev/null +++ b/blocksuite/affine/all/src/fragments/outline/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-fragment-outline/view'; diff --git a/blocksuite/affine/all/src/gfx/brush/index.ts b/blocksuite/affine/all/src/gfx/brush/index.ts new file mode 100644 index 00000000..813d0de3 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/brush/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-brush'; diff --git a/blocksuite/affine/all/src/gfx/brush/store.ts b/blocksuite/affine/all/src/gfx/brush/store.ts new file mode 100644 index 00000000..de2f133d --- /dev/null +++ b/blocksuite/affine/all/src/gfx/brush/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-brush/store'; diff --git a/blocksuite/affine/all/src/gfx/brush/view.ts b/blocksuite/affine/all/src/gfx/brush/view.ts new file mode 100644 index 00000000..06fb434a --- /dev/null +++ b/blocksuite/affine/all/src/gfx/brush/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-brush/view'; diff --git a/blocksuite/affine/all/src/gfx/connector/index.ts b/blocksuite/affine/all/src/gfx/connector/index.ts new file mode 100644 index 00000000..c3a934bb --- /dev/null +++ b/blocksuite/affine/all/src/gfx/connector/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-connector'; diff --git a/blocksuite/affine/all/src/gfx/connector/store.ts b/blocksuite/affine/all/src/gfx/connector/store.ts new file mode 100644 index 00000000..62b8c8c0 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/connector/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-connector/store'; diff --git a/blocksuite/affine/all/src/gfx/connector/view.ts b/blocksuite/affine/all/src/gfx/connector/view.ts new file mode 100644 index 00000000..3ac78be7 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/connector/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-connector/view'; diff --git a/blocksuite/affine/all/src/gfx/group/index.ts b/blocksuite/affine/all/src/gfx/group/index.ts new file mode 100644 index 00000000..6a65959d --- /dev/null +++ b/blocksuite/affine/all/src/gfx/group/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-group'; diff --git a/blocksuite/affine/all/src/gfx/group/store.ts b/blocksuite/affine/all/src/gfx/group/store.ts new file mode 100644 index 00000000..18162630 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/group/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-group/store'; diff --git a/blocksuite/affine/all/src/gfx/group/view.ts b/blocksuite/affine/all/src/gfx/group/view.ts new file mode 100644 index 00000000..2b22344b --- /dev/null +++ b/blocksuite/affine/all/src/gfx/group/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-group/view'; diff --git a/blocksuite/affine/all/src/gfx/link/index.ts b/blocksuite/affine/all/src/gfx/link/index.ts new file mode 100644 index 00000000..1270c60a --- /dev/null +++ b/blocksuite/affine/all/src/gfx/link/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-link'; diff --git a/blocksuite/affine/all/src/gfx/link/view.ts b/blocksuite/affine/all/src/gfx/link/view.ts new file mode 100644 index 00000000..9ebdf3e8 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/link/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-link/view'; diff --git a/blocksuite/affine/all/src/gfx/mindmap/index.ts b/blocksuite/affine/all/src/gfx/mindmap/index.ts new file mode 100644 index 00000000..edfe2f73 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/mindmap/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-mindmap'; diff --git a/blocksuite/affine/all/src/gfx/mindmap/store.ts b/blocksuite/affine/all/src/gfx/mindmap/store.ts new file mode 100644 index 00000000..d3f658cb --- /dev/null +++ b/blocksuite/affine/all/src/gfx/mindmap/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-mindmap/store'; diff --git a/blocksuite/affine/all/src/gfx/mindmap/view.ts b/blocksuite/affine/all/src/gfx/mindmap/view.ts new file mode 100644 index 00000000..db8df0da --- /dev/null +++ b/blocksuite/affine/all/src/gfx/mindmap/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-mindmap/view'; diff --git a/blocksuite/affine/all/src/gfx/note/index.ts b/blocksuite/affine/all/src/gfx/note/index.ts new file mode 100644 index 00000000..daac8c52 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/note/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-note'; diff --git a/blocksuite/affine/all/src/gfx/note/view.ts b/blocksuite/affine/all/src/gfx/note/view.ts new file mode 100644 index 00000000..a271a484 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/note/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-note/view'; diff --git a/blocksuite/affine/all/src/gfx/pointer/index.ts b/blocksuite/affine/all/src/gfx/pointer/index.ts new file mode 100644 index 00000000..d4d77a70 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/pointer/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-pointer'; diff --git a/blocksuite/affine/all/src/gfx/pointer/view.ts b/blocksuite/affine/all/src/gfx/pointer/view.ts new file mode 100644 index 00000000..e6d33432 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/pointer/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-pointer/view'; diff --git a/blocksuite/affine/all/src/gfx/shape/index.ts b/blocksuite/affine/all/src/gfx/shape/index.ts new file mode 100644 index 00000000..27ac482c --- /dev/null +++ b/blocksuite/affine/all/src/gfx/shape/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-shape'; diff --git a/blocksuite/affine/all/src/gfx/shape/store.ts b/blocksuite/affine/all/src/gfx/shape/store.ts new file mode 100644 index 00000000..9ae4fc94 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/shape/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-shape/store'; diff --git a/blocksuite/affine/all/src/gfx/shape/view.ts b/blocksuite/affine/all/src/gfx/shape/view.ts new file mode 100644 index 00000000..4e05b3b7 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/shape/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-shape/view'; diff --git a/blocksuite/affine/all/src/gfx/template/index.ts b/blocksuite/affine/all/src/gfx/template/index.ts new file mode 100644 index 00000000..7270cc39 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/template/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-template'; diff --git a/blocksuite/affine/all/src/gfx/template/view.ts b/blocksuite/affine/all/src/gfx/template/view.ts new file mode 100644 index 00000000..9c040b41 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/template/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-template/view'; diff --git a/blocksuite/affine/all/src/gfx/text/index.ts b/blocksuite/affine/all/src/gfx/text/index.ts new file mode 100644 index 00000000..0505430c --- /dev/null +++ b/blocksuite/affine/all/src/gfx/text/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-text'; diff --git a/blocksuite/affine/all/src/gfx/text/store.ts b/blocksuite/affine/all/src/gfx/text/store.ts new file mode 100644 index 00000000..991754fd --- /dev/null +++ b/blocksuite/affine/all/src/gfx/text/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-text/store'; diff --git a/blocksuite/affine/all/src/gfx/text/view.ts b/blocksuite/affine/all/src/gfx/text/view.ts new file mode 100644 index 00000000..0b438ab8 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/text/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-text/view'; diff --git a/blocksuite/affine/all/src/gfx/turbo-renderer.ts b/blocksuite/affine/all/src/gfx/turbo-renderer.ts new file mode 100644 index 00000000..62e5c475 --- /dev/null +++ b/blocksuite/affine/all/src/gfx/turbo-renderer.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-gfx-turbo-renderer'; diff --git a/blocksuite/affine/all/src/global/di.ts b/blocksuite/affine/all/src/global/di.ts new file mode 100644 index 00000000..9f05e864 --- /dev/null +++ b/blocksuite/affine/all/src/global/di.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/di'; diff --git a/blocksuite/affine/all/src/global/disposable.ts b/blocksuite/affine/all/src/global/disposable.ts new file mode 100644 index 00000000..5abe2b6a --- /dev/null +++ b/blocksuite/affine/all/src/global/disposable.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/disposable'; diff --git a/blocksuite/affine/all/src/global/env.ts b/blocksuite/affine/all/src/global/env.ts new file mode 100644 index 00000000..065a13be --- /dev/null +++ b/blocksuite/affine/all/src/global/env.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/env'; diff --git a/blocksuite/affine/all/src/global/exceptions.ts b/blocksuite/affine/all/src/global/exceptions.ts new file mode 100644 index 00000000..ece97f76 --- /dev/null +++ b/blocksuite/affine/all/src/global/exceptions.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/exceptions'; diff --git a/blocksuite/affine/all/src/global/gfx.ts b/blocksuite/affine/all/src/global/gfx.ts new file mode 100644 index 00000000..656f9c29 --- /dev/null +++ b/blocksuite/affine/all/src/global/gfx.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/gfx'; diff --git a/blocksuite/affine/all/src/global/index.ts b/blocksuite/affine/all/src/global/index.ts new file mode 100644 index 00000000..83a535bd --- /dev/null +++ b/blocksuite/affine/all/src/global/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global'; diff --git a/blocksuite/affine/all/src/global/lit.ts b/blocksuite/affine/all/src/global/lit.ts new file mode 100644 index 00000000..4f768d3f --- /dev/null +++ b/blocksuite/affine/all/src/global/lit.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/lit'; diff --git a/blocksuite/affine/all/src/global/types.ts b/blocksuite/affine/all/src/global/types.ts new file mode 100644 index 00000000..e9960506 --- /dev/null +++ b/blocksuite/affine/all/src/global/types.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/types'; diff --git a/blocksuite/affine/all/src/global/utils.ts b/blocksuite/affine/all/src/global/utils.ts new file mode 100644 index 00000000..522b3543 --- /dev/null +++ b/blocksuite/affine/all/src/global/utils.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/utils'; diff --git a/blocksuite/affine/all/src/index.ts b/blocksuite/affine/all/src/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/blocksuite/affine/all/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/blocksuite/affine/all/src/inlines/comment/index.ts b/blocksuite/affine/all/src/inlines/comment/index.ts new file mode 100644 index 00000000..8e7c6d9f --- /dev/null +++ b/blocksuite/affine/all/src/inlines/comment/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-comment'; diff --git a/blocksuite/affine/all/src/inlines/footnote/index.ts b/blocksuite/affine/all/src/inlines/footnote/index.ts new file mode 100644 index 00000000..97bb84fb --- /dev/null +++ b/blocksuite/affine/all/src/inlines/footnote/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-footnote'; diff --git a/blocksuite/affine/all/src/inlines/footnote/store.ts b/blocksuite/affine/all/src/inlines/footnote/store.ts new file mode 100644 index 00000000..72dfab59 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/footnote/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-footnote/store'; diff --git a/blocksuite/affine/all/src/inlines/footnote/view.ts b/blocksuite/affine/all/src/inlines/footnote/view.ts new file mode 100644 index 00000000..d7293d29 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/footnote/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-footnote/view'; diff --git a/blocksuite/affine/all/src/inlines/latex/index.ts b/blocksuite/affine/all/src/inlines/latex/index.ts new file mode 100644 index 00000000..0b611551 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/latex/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-latex'; diff --git a/blocksuite/affine/all/src/inlines/latex/store.ts b/blocksuite/affine/all/src/inlines/latex/store.ts new file mode 100644 index 00000000..e3341819 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/latex/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-latex/store'; diff --git a/blocksuite/affine/all/src/inlines/latex/view.ts b/blocksuite/affine/all/src/inlines/latex/view.ts new file mode 100644 index 00000000..50eae0e8 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/latex/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-latex/view'; diff --git a/blocksuite/affine/all/src/inlines/link/index.ts b/blocksuite/affine/all/src/inlines/link/index.ts new file mode 100644 index 00000000..27dec5be --- /dev/null +++ b/blocksuite/affine/all/src/inlines/link/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-link'; diff --git a/blocksuite/affine/all/src/inlines/link/store.ts b/blocksuite/affine/all/src/inlines/link/store.ts new file mode 100644 index 00000000..fef24e24 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/link/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-link/store'; diff --git a/blocksuite/affine/all/src/inlines/link/view.ts b/blocksuite/affine/all/src/inlines/link/view.ts new file mode 100644 index 00000000..31932c83 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/link/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-link/view'; diff --git a/blocksuite/affine/all/src/inlines/mention/index.ts b/blocksuite/affine/all/src/inlines/mention/index.ts new file mode 100644 index 00000000..d6a132af --- /dev/null +++ b/blocksuite/affine/all/src/inlines/mention/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-mention'; diff --git a/blocksuite/affine/all/src/inlines/mention/view.ts b/blocksuite/affine/all/src/inlines/mention/view.ts new file mode 100644 index 00000000..38591c50 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/mention/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-mention/view'; diff --git a/blocksuite/affine/all/src/inlines/preset/index.ts b/blocksuite/affine/all/src/inlines/preset/index.ts new file mode 100644 index 00000000..eed44092 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/preset/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-preset'; diff --git a/blocksuite/affine/all/src/inlines/preset/store.ts b/blocksuite/affine/all/src/inlines/preset/store.ts new file mode 100644 index 00000000..0356bfea --- /dev/null +++ b/blocksuite/affine/all/src/inlines/preset/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-preset/store'; diff --git a/blocksuite/affine/all/src/inlines/preset/view.ts b/blocksuite/affine/all/src/inlines/preset/view.ts new file mode 100644 index 00000000..8e57dc1e --- /dev/null +++ b/blocksuite/affine/all/src/inlines/preset/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-preset/view'; diff --git a/blocksuite/affine/all/src/inlines/reference/index.ts b/blocksuite/affine/all/src/inlines/reference/index.ts new file mode 100644 index 00000000..b1ff5031 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/reference/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-reference'; diff --git a/blocksuite/affine/all/src/inlines/reference/store.ts b/blocksuite/affine/all/src/inlines/reference/store.ts new file mode 100644 index 00000000..34ca4d25 --- /dev/null +++ b/blocksuite/affine/all/src/inlines/reference/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-reference/store'; diff --git a/blocksuite/affine/all/src/inlines/reference/view.ts b/blocksuite/affine/all/src/inlines/reference/view.ts new file mode 100644 index 00000000..ffab9a0a --- /dev/null +++ b/blocksuite/affine/all/src/inlines/reference/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-inline-reference/view'; diff --git a/blocksuite/affine/all/src/model/index.ts b/blocksuite/affine/all/src/model/index.ts new file mode 100644 index 00000000..d853fae6 --- /dev/null +++ b/blocksuite/affine/all/src/model/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-model'; diff --git a/blocksuite/affine/all/src/rich-text/effects.ts b/blocksuite/affine/all/src/rich-text/effects.ts new file mode 100644 index 00000000..a0d98574 --- /dev/null +++ b/blocksuite/affine/all/src/rich-text/effects.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-rich-text/effects'; diff --git a/blocksuite/affine/all/src/rich-text/index.ts b/blocksuite/affine/all/src/rich-text/index.ts new file mode 100644 index 00000000..e4cfc09f --- /dev/null +++ b/blocksuite/affine/all/src/rich-text/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-rich-text'; diff --git a/blocksuite/affine/all/src/schemas.ts b/blocksuite/affine/all/src/schemas.ts new file mode 100644 index 00000000..91dc3e33 --- /dev/null +++ b/blocksuite/affine/all/src/schemas.ts @@ -0,0 +1,59 @@ +// Import models only, the bundled file should not include anything else. +import { DataViewBlockSchema } from '@blocksuite/affine-block-data-view'; +import { SurfaceBlockSchema } from '@blocksuite/affine-block-surface'; +import { + AttachmentBlockSchema, + BookmarkBlockSchema, + CalloutBlockSchema, + CodeBlockSchema, + DatabaseBlockSchema, + DividerBlockSchema, + EdgelessTextBlockSchema, + EmbedFigmaBlockSchema, + EmbedGithubBlockSchema, + EmbedHtmlBlockSchema, + EmbedLinkedDocBlockSchema, + EmbedLoomBlockSchema, + EmbedSyncedDocBlockSchema, + EmbedYoutubeBlockSchema, + FrameBlockSchema, + ImageBlockSchema, + LatexBlockSchema, + ListBlockSchema, + NoteBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, + SurfaceRefBlockSchema, + TableBlockSchema, +} from '@blocksuite/affine-model'; +import type { BlockSchema } from '@blocksuite/store'; +import type { z } from 'zod'; + +/** Built-in first party block models built for affine */ +export const AffineSchemas: z.infer[] = [ + CodeBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, + ListBlockSchema, + NoteBlockSchema, + DividerBlockSchema, + ImageBlockSchema, + SurfaceBlockSchema, + BookmarkBlockSchema, + FrameBlockSchema, + DatabaseBlockSchema, + SurfaceRefBlockSchema, + DataViewBlockSchema, + AttachmentBlockSchema, + EmbedYoutubeBlockSchema, + EmbedFigmaBlockSchema, + EmbedGithubBlockSchema, + EmbedHtmlBlockSchema, + EmbedLinkedDocBlockSchema, + EmbedSyncedDocBlockSchema, + EmbedLoomBlockSchema, + EdgelessTextBlockSchema, + LatexBlockSchema, + TableBlockSchema, + CalloutBlockSchema, +]; diff --git a/blocksuite/affine/all/src/shared/adapters.ts b/blocksuite/affine/all/src/shared/adapters.ts new file mode 100644 index 00000000..47e42e0e --- /dev/null +++ b/blocksuite/affine/all/src/shared/adapters.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/adapters'; diff --git a/blocksuite/affine/all/src/shared/commands.ts b/blocksuite/affine/all/src/shared/commands.ts new file mode 100644 index 00000000..9e27afd2 --- /dev/null +++ b/blocksuite/affine/all/src/shared/commands.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/commands'; diff --git a/blocksuite/affine/all/src/shared/consts.ts b/blocksuite/affine/all/src/shared/consts.ts new file mode 100644 index 00000000..67201234 --- /dev/null +++ b/blocksuite/affine/all/src/shared/consts.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/consts'; diff --git a/blocksuite/affine/all/src/shared/selection.ts b/blocksuite/affine/all/src/shared/selection.ts new file mode 100644 index 00000000..3a9636ed --- /dev/null +++ b/blocksuite/affine/all/src/shared/selection.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/selection'; diff --git a/blocksuite/affine/all/src/shared/services.ts b/blocksuite/affine/all/src/shared/services.ts new file mode 100644 index 00000000..c1f17a16 --- /dev/null +++ b/blocksuite/affine/all/src/shared/services.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/services'; diff --git a/blocksuite/affine/all/src/shared/styles.ts b/blocksuite/affine/all/src/shared/styles.ts new file mode 100644 index 00000000..b5078ffc --- /dev/null +++ b/blocksuite/affine/all/src/shared/styles.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/styles'; diff --git a/blocksuite/affine/all/src/shared/theme.ts b/blocksuite/affine/all/src/shared/theme.ts new file mode 100644 index 00000000..d168fd85 --- /dev/null +++ b/blocksuite/affine/all/src/shared/theme.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/theme'; diff --git a/blocksuite/affine/all/src/shared/types.ts b/blocksuite/affine/all/src/shared/types.ts new file mode 100644 index 00000000..2d611c10 --- /dev/null +++ b/blocksuite/affine/all/src/shared/types.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/types'; diff --git a/blocksuite/affine/all/src/shared/utils.ts b/blocksuite/affine/all/src/shared/utils.ts new file mode 100644 index 00000000..39a12eb1 --- /dev/null +++ b/blocksuite/affine/all/src/shared/utils.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/utils'; diff --git a/blocksuite/affine/all/src/std/effects.ts b/blocksuite/affine/all/src/std/effects.ts new file mode 100644 index 00000000..c377143c --- /dev/null +++ b/blocksuite/affine/all/src/std/effects.ts @@ -0,0 +1 @@ +export * from '@blocksuite/std/effects'; diff --git a/blocksuite/affine/all/src/std/gfx.ts b/blocksuite/affine/all/src/std/gfx.ts new file mode 100644 index 00000000..2540014f --- /dev/null +++ b/blocksuite/affine/all/src/std/gfx.ts @@ -0,0 +1 @@ +export * from '@blocksuite/std/gfx'; diff --git a/blocksuite/affine/all/src/std/index.ts b/blocksuite/affine/all/src/std/index.ts new file mode 100644 index 00000000..5ee6fec9 --- /dev/null +++ b/blocksuite/affine/all/src/std/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/std'; diff --git a/blocksuite/affine/all/src/std/inline.ts b/blocksuite/affine/all/src/std/inline.ts new file mode 100644 index 00000000..ddb3252f --- /dev/null +++ b/blocksuite/affine/all/src/std/inline.ts @@ -0,0 +1 @@ +export * from '@blocksuite/std/inline'; diff --git a/blocksuite/affine/all/src/store/index.ts b/blocksuite/affine/all/src/store/index.ts new file mode 100644 index 00000000..1fc8dd5a --- /dev/null +++ b/blocksuite/affine/all/src/store/index.ts @@ -0,0 +1,3 @@ +/* oxlint-disable no-restricted-imports */ + +export * from '@blocksuite/store'; diff --git a/blocksuite/affine/all/src/store/test.ts b/blocksuite/affine/all/src/store/test.ts new file mode 100644 index 00000000..c8cd2daa --- /dev/null +++ b/blocksuite/affine/all/src/store/test.ts @@ -0,0 +1 @@ +export * from '@blocksuite/store/test'; diff --git a/blocksuite/affine/all/src/sync/index.ts b/blocksuite/affine/all/src/sync/index.ts new file mode 100644 index 00000000..415641e2 --- /dev/null +++ b/blocksuite/affine/all/src/sync/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/sync'; diff --git a/blocksuite/affine/all/src/widgets/drag-handle/index.ts b/blocksuite/affine/all/src/widgets/drag-handle/index.ts new file mode 100644 index 00000000..3a0a5fdb --- /dev/null +++ b/blocksuite/affine/all/src/widgets/drag-handle/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-drag-handle'; diff --git a/blocksuite/affine/all/src/widgets/drag-handle/view.ts b/blocksuite/affine/all/src/widgets/drag-handle/view.ts new file mode 100644 index 00000000..2c80afd2 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/drag-handle/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-drag-handle/view'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-auto-connect/index.ts b/blocksuite/affine/all/src/widgets/edgeless-auto-connect/index.ts new file mode 100644 index 00000000..5db8f286 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-auto-connect/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-auto-connect'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-auto-connect/view.ts b/blocksuite/affine/all/src/widgets/edgeless-auto-connect/view.ts new file mode 100644 index 00000000..278bfbaa --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-auto-connect/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-auto-connect/view'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-dragging-area/index.ts b/blocksuite/affine/all/src/widgets/edgeless-dragging-area/index.ts new file mode 100644 index 00000000..f9408d6f --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-dragging-area/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-dragging-area'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-dragging-area/view.ts b/blocksuite/affine/all/src/widgets/edgeless-dragging-area/view.ts new file mode 100644 index 00000000..3c89cdbb --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-dragging-area/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-dragging-area/view'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-selected-rect/index.ts b/blocksuite/affine/all/src/widgets/edgeless-selected-rect/index.ts new file mode 100644 index 00000000..bcbd4209 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-selected-rect/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-selected-rect'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-selected-rect/view.ts b/blocksuite/affine/all/src/widgets/edgeless-selected-rect/view.ts new file mode 100644 index 00000000..0c0d767d --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-selected-rect/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-selected-rect/view'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-toolbar/index.ts b/blocksuite/affine/all/src/widgets/edgeless-toolbar/index.ts new file mode 100644 index 00000000..378d673d --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-toolbar/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-toolbar'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-toolbar/view.ts b/blocksuite/affine/all/src/widgets/edgeless-toolbar/view.ts new file mode 100644 index 00000000..d8ec56d6 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-toolbar/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-toolbar/view'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-zoom-toolbar/index.ts b/blocksuite/affine/all/src/widgets/edgeless-zoom-toolbar/index.ts new file mode 100644 index 00000000..5271b30f --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-zoom-toolbar/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-zoom-toolbar'; diff --git a/blocksuite/affine/all/src/widgets/edgeless-zoom-toolbar/view.ts b/blocksuite/affine/all/src/widgets/edgeless-zoom-toolbar/view.ts new file mode 100644 index 00000000..a8598bdc --- /dev/null +++ b/blocksuite/affine/all/src/widgets/edgeless-zoom-toolbar/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-edgeless-zoom-toolbar/view'; diff --git a/blocksuite/affine/all/src/widgets/frame-title/index.ts b/blocksuite/affine/all/src/widgets/frame-title/index.ts new file mode 100644 index 00000000..39bd009d --- /dev/null +++ b/blocksuite/affine/all/src/widgets/frame-title/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-frame-title'; diff --git a/blocksuite/affine/all/src/widgets/frame-title/view.ts b/blocksuite/affine/all/src/widgets/frame-title/view.ts new file mode 100644 index 00000000..5b205a1c --- /dev/null +++ b/blocksuite/affine/all/src/widgets/frame-title/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-frame-title/view'; diff --git a/blocksuite/affine/all/src/widgets/keyboard-toolbar/index.ts b/blocksuite/affine/all/src/widgets/keyboard-toolbar/index.ts new file mode 100644 index 00000000..65ecf4a6 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/keyboard-toolbar/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-keyboard-toolbar'; diff --git a/blocksuite/affine/all/src/widgets/keyboard-toolbar/view.ts b/blocksuite/affine/all/src/widgets/keyboard-toolbar/view.ts new file mode 100644 index 00000000..95ec485c --- /dev/null +++ b/blocksuite/affine/all/src/widgets/keyboard-toolbar/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-keyboard-toolbar/view'; diff --git a/blocksuite/affine/all/src/widgets/linked-doc/index.ts b/blocksuite/affine/all/src/widgets/linked-doc/index.ts new file mode 100644 index 00000000..92122638 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/linked-doc/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-linked-doc'; diff --git a/blocksuite/affine/all/src/widgets/linked-doc/view.ts b/blocksuite/affine/all/src/widgets/linked-doc/view.ts new file mode 100644 index 00000000..39064f09 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/linked-doc/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-linked-doc/view'; diff --git a/blocksuite/affine/all/src/widgets/note-slicer/index.ts b/blocksuite/affine/all/src/widgets/note-slicer/index.ts new file mode 100644 index 00000000..205dcf57 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/note-slicer/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-note-slicer'; diff --git a/blocksuite/affine/all/src/widgets/note-slicer/view.ts b/blocksuite/affine/all/src/widgets/note-slicer/view.ts new file mode 100644 index 00000000..b9efeb91 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/note-slicer/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-note-slicer/view'; diff --git a/blocksuite/affine/all/src/widgets/page-dragging-area/index.ts b/blocksuite/affine/all/src/widgets/page-dragging-area/index.ts new file mode 100644 index 00000000..25e86b00 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/page-dragging-area/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-page-dragging-area'; diff --git a/blocksuite/affine/all/src/widgets/page-dragging-area/view.ts b/blocksuite/affine/all/src/widgets/page-dragging-area/view.ts new file mode 100644 index 00000000..2b34434a --- /dev/null +++ b/blocksuite/affine/all/src/widgets/page-dragging-area/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-page-dragging-area/view'; diff --git a/blocksuite/affine/all/src/widgets/remote-selection/index.ts b/blocksuite/affine/all/src/widgets/remote-selection/index.ts new file mode 100644 index 00000000..865d4c41 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/remote-selection/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-remote-selection'; diff --git a/blocksuite/affine/all/src/widgets/remote-selection/view.ts b/blocksuite/affine/all/src/widgets/remote-selection/view.ts new file mode 100644 index 00000000..1ccd6fd8 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/remote-selection/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-remote-selection/view'; diff --git a/blocksuite/affine/all/src/widgets/scroll-anchoring/index.ts b/blocksuite/affine/all/src/widgets/scroll-anchoring/index.ts new file mode 100644 index 00000000..25ef1818 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/scroll-anchoring/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-scroll-anchoring'; diff --git a/blocksuite/affine/all/src/widgets/scroll-anchoring/view.ts b/blocksuite/affine/all/src/widgets/scroll-anchoring/view.ts new file mode 100644 index 00000000..3474abf2 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/scroll-anchoring/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-scroll-anchoring/view'; diff --git a/blocksuite/affine/all/src/widgets/slash-menu/index.ts b/blocksuite/affine/all/src/widgets/slash-menu/index.ts new file mode 100644 index 00000000..b36d5d92 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/slash-menu/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-slash-menu'; diff --git a/blocksuite/affine/all/src/widgets/slash-menu/view.ts b/blocksuite/affine/all/src/widgets/slash-menu/view.ts new file mode 100644 index 00000000..00d3f98a --- /dev/null +++ b/blocksuite/affine/all/src/widgets/slash-menu/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-slash-menu/view'; diff --git a/blocksuite/affine/all/src/widgets/toolbar/index.ts b/blocksuite/affine/all/src/widgets/toolbar/index.ts new file mode 100644 index 00000000..2ec4ae15 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/toolbar/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-toolbar'; diff --git a/blocksuite/affine/all/src/widgets/toolbar/view.ts b/blocksuite/affine/all/src/widgets/toolbar/view.ts new file mode 100644 index 00000000..07199d52 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/toolbar/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-toolbar/view'; diff --git a/blocksuite/affine/all/src/widgets/viewport-overlay/index.ts b/blocksuite/affine/all/src/widgets/viewport-overlay/index.ts new file mode 100644 index 00000000..0cab03a6 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/viewport-overlay/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-viewport-overlay'; diff --git a/blocksuite/affine/all/src/widgets/viewport-overlay/view.ts b/blocksuite/affine/all/src/widgets/viewport-overlay/view.ts new file mode 100644 index 00000000..91280b50 --- /dev/null +++ b/blocksuite/affine/all/src/widgets/viewport-overlay/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-widget-viewport-overlay/view'; diff --git a/blocksuite/affine/all/tsconfig.json b/blocksuite/affine/all/tsconfig.json new file mode 100644 index 00000000..0f552f16 --- /dev/null +++ b/blocksuite/affine/all/tsconfig.json @@ -0,0 +1,80 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [ + { "path": "../blocks/attachment" }, + { "path": "../blocks/bookmark" }, + { "path": "../blocks/callout" }, + { "path": "../blocks/code" }, + { "path": "../blocks/data-view" }, + { "path": "../blocks/database" }, + { "path": "../blocks/divider" }, + { "path": "../blocks/edgeless-text" }, + { "path": "../blocks/embed" }, + { "path": "../blocks/embed-doc" }, + { "path": "../blocks/frame" }, + { "path": "../blocks/image" }, + { "path": "../blocks/latex" }, + { "path": "../blocks/list" }, + { "path": "../blocks/note" }, + { "path": "../blocks/paragraph" }, + { "path": "../blocks/root" }, + { "path": "../blocks/surface" }, + { "path": "../blocks/surface-ref" }, + { "path": "../blocks/table" }, + { "path": "../components" }, + { "path": "../ext-loader" }, + { "path": "../foundation" }, + { "path": "../fragments/adapter-panel" }, + { "path": "../fragments/doc-title" }, + { "path": "../fragments/frame-panel" }, + { "path": "../fragments/outline" }, + { "path": "../gfx/brush" }, + { "path": "../gfx/connector" }, + { "path": "../gfx/group" }, + { "path": "../gfx/link" }, + { "path": "../gfx/mindmap" }, + { "path": "../gfx/note" }, + { "path": "../gfx/pointer" }, + { "path": "../gfx/shape" }, + { "path": "../gfx/template" }, + { "path": "../gfx/text" }, + { "path": "../gfx/turbo-renderer" }, + { "path": "../inlines/comment" }, + { "path": "../inlines/footnote" }, + { "path": "../inlines/latex" }, + { "path": "../inlines/link" }, + { "path": "../inlines/mention" }, + { "path": "../inlines/preset" }, + { "path": "../inlines/reference" }, + { "path": "../model" }, + { "path": "../rich-text" }, + { "path": "../shared" }, + { "path": "../widgets/drag-handle" }, + { "path": "../widgets/edgeless-auto-connect" }, + { "path": "../widgets/edgeless-dragging-area" }, + { "path": "../widgets/edgeless-selected-rect" }, + { "path": "../widgets/edgeless-toolbar" }, + { "path": "../widgets/edgeless-zoom-toolbar" }, + { "path": "../widgets/frame-title" }, + { "path": "../widgets/keyboard-toolbar" }, + { "path": "../widgets/linked-doc" }, + { "path": "../widgets/note-slicer" }, + { "path": "../widgets/page-dragging-area" }, + { "path": "../widgets/remote-selection" }, + { "path": "../widgets/scroll-anchoring" }, + { "path": "../widgets/slash-menu" }, + { "path": "../widgets/toolbar" }, + { "path": "../widgets/viewport-overlay" }, + { "path": "../data-view" }, + { "path": "../../framework/global" }, + { "path": "../../framework/std" }, + { "path": "../../framework/store" }, + { "path": "../../framework/sync" } + ] +} diff --git a/blocksuite/affine/all/vitest.config.ts b/blocksuite/affine/all/vitest.config.ts new file mode 100644 index 00000000..c5843f21 --- /dev/null +++ b/blocksuite/affine/all/vitest.config.ts @@ -0,0 +1,41 @@ +import { fileURLToPath } from 'node:url'; + +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + target: 'es2018', + }, + plugins: [vanillaExtractPlugin()], + test: { + globalSetup: fileURLToPath( + new URL('../../../scripts/vitest-global.js', import.meta.url) + ), + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 1000, + coverage: { + provider: 'istanbul', + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/blocksuite-affine', + }, + /** + * Custom handler for console.log in tests. + * + * Return `false` to ignore the log. + */ + onConsoleLog(log, type) { + if ( + log.includes('lit.dev/msg/dev-mode') || + log.includes( + `KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.` + ) + ) { + return false; + } + console.warn(`Unexpected ${type} log`, log); + throw new Error(log); + }, + environment: 'happy-dom', + }, +}); diff --git a/blocksuite/affine/blocks/attachment/package.json b/blocksuite/affine/blocks/attachment/package.json new file mode 100644 index 00000000..e3caf313 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/package.json @@ -0,0 +1,41 @@ +{ + "name": "@blocksuite/affine-block-attachment", + "description": "Attachment block for BlockSuite.", + "type": "module", + "scripts": { + "build": "yarn run -T tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-block-surface": "workspace:*", + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.2.17", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.13", + "@preact/signals-core": "^1.8.0", + "file-type": "^21.0.0", + "lit": "^3.2.0", + "rxjs": "^7.8.2" + }, + "exports": { + ".": "./src/index.ts", + "./store": "./src/store.ts", + "./view": "./src/view.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5" +} diff --git a/blocksuite/affine/blocks/attachment/src/adapters/extension.ts b/blocksuite/affine/blocks/attachment/src/adapters/extension.ts new file mode 100644 index 00000000..d2a1d2c5 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/adapters/extension.ts @@ -0,0 +1,9 @@ +import type { ExtensionType } from '@blocksuite/store'; + +import { AttachmentBlockMarkdownAdapterExtension } from './markdown.js'; +import { AttachmentBlockNotionHtmlAdapterExtension } from './notion-html.js'; + +export const AttachmentBlockAdapterExtensions: ExtensionType[] = [ + AttachmentBlockNotionHtmlAdapterExtension, + AttachmentBlockMarkdownAdapterExtension, +]; diff --git a/blocksuite/affine/blocks/attachment/src/adapters/index.ts b/blocksuite/affine/blocks/attachment/src/adapters/index.ts new file mode 100644 index 00000000..da925cf1 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/adapters/index.ts @@ -0,0 +1,2 @@ +export * from './markdown.js'; +export * from './notion-html.js'; diff --git a/blocksuite/affine/blocks/attachment/src/adapters/markdown.ts b/blocksuite/affine/blocks/attachment/src/adapters/markdown.ts new file mode 100644 index 00000000..4e3b74ac --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/adapters/markdown.ts @@ -0,0 +1,83 @@ +import { + AttachmentBlockSchema, + FootNoteReferenceParamsSchema, +} from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + createAttachmentBlockSnapshot, + FOOTNOTE_DEFINITION_PREFIX, + getFootnoteDefinitionText, + isFootnoteDefinitionNode, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +const isAttachmentFootnoteDefinitionNode = (node: MarkdownAST) => { + if (!isFootnoteDefinitionNode(node)) return false; + const footnoteDefinition = getFootnoteDefinitionText(node); + try { + const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse( + JSON.parse(footnoteDefinition) + ); + return ( + footnoteDefinitionJson.type === 'attachment' && + !!footnoteDefinitionJson.blobId + ); + } catch { + return false; + } +}; + +export const attachmentBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: AttachmentBlockSchema.model.flavour, + toMatch: o => isAttachmentFootnoteDefinitionNode(o.node), + fromMatch: o => o.node.flavour === AttachmentBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!isFootnoteDefinitionNode(o.node)) { + return; + } + + const { walkerContext, configs } = context; + const footnoteIdentifier = o.node.identifier; + const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`; + const footnoteDefinition = configs.get(footnoteDefinitionKey); + if (!footnoteDefinition) { + return; + } + try { + const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse( + JSON.parse(footnoteDefinition) + ); + const { blobId, fileName } = footnoteDefinitionJson; + if (!blobId || !fileName) { + return; + } + walkerContext + .openNode( + createAttachmentBlockSnapshot({ + id: nanoid(), + props: { + name: fileName, + sourceId: blobId, + footnoteIdentifier, + style: 'citation', + }, + }), + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + } catch (err) { + console.warn('Failed to parse attachment footnote definition:', err); + return; + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const AttachmentBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(attachmentBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/blocks/attachment/src/adapters/notion-html.ts b/blocksuite/affine/blocks/attachment/src/adapters/notion-html.ts new file mode 100644 index 00000000..be692cf8 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/adapters/notion-html.ts @@ -0,0 +1,115 @@ +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + FetchUtils, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils'; +import { sha } from '@blocksuite/global/utils'; +import { getAssetName, nanoid } from '@blocksuite/store'; + +export const attachmentBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: AttachmentBlockSchema.model.flavour, + toMatch: o => { + return ( + HastUtils.isElement(o.node) && + o.node.tagName === 'figure' && + !!HastUtils.querySelector(o.node, '.source') + ); + }, + fromMatch: () => false, + toBlockSnapshot: { + enter: async (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { assets, walkerContext } = context; + if (!assets) { + return; + } + + const embededFigureWrapper = HastUtils.querySelector(o.node, '.source'); + let embededURL = ''; + if (embededFigureWrapper) { + const embedA = HastUtils.querySelector(embededFigureWrapper, 'a'); + embededURL = + typeof embedA?.properties.href === 'string' + ? embedA.properties.href + : ''; + } + if (embededURL) { + let blobId = ''; + let name = ''; + let type = ''; + let size = 0; + if (!FetchUtils.fetchable(embededURL)) { + const embededURLSplit = embededURL.split('/'); + while (embededURLSplit.length > 0) { + const key = assets + .getPathBlobIdMap() + .get(decodeURIComponent(embededURLSplit.join('/'))); + if (key) { + blobId = key; + break; + } + embededURLSplit.shift(); + } + const value = assets.getAssets().get(blobId); + if (value) { + name = getAssetName(assets.getAssets(), blobId); + size = value.size; + type = value.type; + } + } else { + const res = await fetch(embededURL).catch(error => { + console.warn('Error fetching embed:', error); + return null; + }); + if (!res) { + return; + } + const resCloned = res.clone(); + name = + getFilenameFromContentDisposition( + res.headers.get('Content-Disposition') ?? '' + ) ?? + (embededURL.split('/').at(-1) ?? 'file') + + '.' + + (res.headers.get('Content-Type')?.split('/').at(-1) ?? 'blob'); + const file = new File([await res.blob()], name, { + type: res.headers.get('Content-Type') ?? '', + }); + size = file.size; + type = file.type; + blobId = await sha(await resCloned.arrayBuffer()); + assets?.getAssets().set(blobId, file); + await assets?.writeToBlob(blobId); + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: AttachmentBlockSchema.model.flavour, + props: { + name, + size, + type, + sourceId: blobId, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const AttachmentBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(attachmentBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/affine/blocks/attachment/src/attachment-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-block.ts new file mode 100644 index 00000000..c9e5365c --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/attachment-block.ts @@ -0,0 +1,533 @@ +import { + CaptionedBlockComponent, + SelectedStyle, +} from '@blocksuite/affine-components/caption'; +import { + getAttachmentFileIcon, + LoadingIcon, +} from '@blocksuite/affine-components/icons'; +import { Peekable } from '@blocksuite/affine-components/peek'; +import { + type ResolvedStateInfo, + ResourceController, +} from '@blocksuite/affine-components/resource'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + type AttachmentBlockModel, + AttachmentBlockStyles, +} from '@blocksuite/affine-model'; +import { + BlockElementCommentManager, + CitationProvider, + DocModeProvider, + FileSizeLimitProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + formatSize, + openSingleFileWith, +} from '@blocksuite/affine-shared/utils'; +import { + AttachmentIcon, + ResetIcon, + UpgradeIcon, + WarningIcon, +} from '@blocksuite/icons/lit'; +import { BlockSelection } from '@blocksuite/std'; +import { nanoid, Slice } from '@blocksuite/store'; +import { batch, computed, signal } from '@preact/signals-core'; +import { html, type TemplateResult } from 'lit'; +import { choose } from 'lit/directives/choose.js'; +import { type ClassInfo, classMap } from 'lit/directives/class-map.js'; +import { guard } from 'lit/directives/guard.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; +import { filter } from 'rxjs/operators'; + +import { AttachmentEmbedProvider } from './embed'; +import { styles } from './styles'; +import { downloadAttachmentBlob, getFileType, refreshData } from './utils'; + +type AttachmentResolvedStateInfo = ResolvedStateInfo & { + kind?: TemplateResult; +}; + +@Peekable({ + enableOn: ({ model }: AttachmentBlockComponent) => { + return model.props.type.endsWith('pdf'); + }, +}) +export class AttachmentBlockComponent extends CaptionedBlockComponent { + static override styles = styles; + + blockDraggable = true; + + resourceController = new ResourceController( + computed(() => this.model.props.sourceId$.value) + ); + + get blobUrl() { + return this.resourceController.blobUrl$.value; + } + + get filetype() { + const name = this.model.props.name$.value; + return name.split('.').pop() ?? ''; + } + + protected containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + margin: '18px 0px', + }); + + private get _maxFileSize() { + return this.std.get(FileSizeLimitProvider).maxFileSize; + } + + get citationService() { + return this.std.get(CitationProvider); + } + + get isCitation() { + return this.citationService.isCitationModel(this.model); + } + + get isCommentHighlighted() { + return ( + this.std + .getOptional(BlockElementCommentManager) + ?.isBlockCommentHighlighted(this.model) ?? false + ); + } + + convertTo = () => { + return this.std + .get(AttachmentEmbedProvider) + .convertTo(this.model, this._maxFileSize); + }; + + copy = () => { + const slice = Slice.fromModels(this.store, [this.model]); + this.std.clipboard.copySlice(slice).catch(console.error); + toast(this.host, 'Copied to clipboard'); + }; + + download = () => { + downloadAttachmentBlob(this); + }; + + embedded = () => { + return ( + Boolean(this.blobUrl) && + this.std + .get(AttachmentEmbedProvider) + .embedded(this.model, this._maxFileSize) + ); + }; + + open = () => { + const blobUrl = this.blobUrl; + if (!blobUrl) return; + window.open(blobUrl, '_blank'); + }; + + // Refreshes data. + refreshData = () => { + refreshData(this).catch(console.error); + }; + + private readonly _refreshKey$ = signal(null); + + // Refreshes the embed component. + reload = () => { + batch(() => { + if (this.model.props.embed$.value) { + this._refreshKey$.value = nanoid(); + return; + } + + this.refreshData(); + }); + }; + + // Replaces the current attachment. + replace = async () => { + const state = this.resourceController.state$.peek(); + if (state.uploading) return; + + const file = await openSingleFileWith(); + if (!file) return; + + const sourceId = await this.std.store.blobSync.set(file); + const type = await getFileType(file); + const { name, size } = file; + + let embed = this.model.props.embed$.value ?? false; + + this.std.store.captureSync(); + this.std.store.transact(() => { + this.std.store.updateBlock(this.blockId, { + name, + size, + type, + sourceId, + embed: false, + }); + + const provider = this.std.get(AttachmentEmbedProvider); + embed &&= provider.embedded(this.model); + + if (embed) { + provider.convertTo(this.model); + } + + // Reloads + this.reload(); + }); + }; + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create(BlockSelection, { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + private readonly _trackCitationDeleteEvent = () => { + // Check citation delete event + this._disposables.add( + this.std.store.slots.blockUpdated + .pipe( + filter(payload => { + if (!payload.isLocal) return false; + + const { flavour, id, type } = payload; + if ( + type !== 'delete' || + flavour !== this.model.flavour || + id !== this.model.id + ) + return false; + + const { model } = payload; + if (!this.citationService.isCitationModel(model)) return false; + + return true; + }) + ) + .subscribe(() => { + this.citationService.trackEvent('Delete'); + }) + ); + }; + + override connectedCallback() { + super.connectedCallback(); + + this.contentEditable = 'false'; + + this.resourceController.setEngine(this.std.store.blobSync); + + this.disposables.add(this.resourceController.subscribe()); + this.disposables.add(this.resourceController); + + this.disposables.add( + this.model.props.sourceId$.subscribe(() => { + this.refreshData(); + }) + ); + + if (!this.model.props.style && !this.store.readonly) { + this.store.withoutTransact(() => { + this.store.updateBlock(this.model, { + style: AttachmentBlockStyles[1], + }); + }); + } + + this._trackCitationDeleteEvent(); + } + + override firstUpdated() { + // lazy bindings + this.disposables.addFromEvent(this, 'click', this.onClick); + } + + protected onClick(event: MouseEvent) { + // the peek view need handle shift + click + if (event.defaultPrevented) return; + + event.stopPropagation(); + + if (!this.selected$.peek()) { + this._selectBlock(); + } + } + + protected renderUpgradeButton = () => { + if (this.std.store.readonly) return null; + + const onOverFileSize = this.std.get(FileSizeLimitProvider).onOverFileSize; + + return when( + onOverFileSize, + () => html` + + ` + ); + }; + + protected renderNormalButton = (needUpload: boolean) => { + const label = needUpload ? 'retry' : 'reload'; + const run = async () => { + if (needUpload) { + await this.resourceController.upload(); + return; + } + + this.refreshData(); + }; + + return html` + + `; + }; + + protected renderWithHorizontal( + classInfo: ClassInfo, + { + icon, + title, + description, + kind, + state, + needUpload, + }: AttachmentResolvedStateInfo + ) { + return html` +
+
+
+
${icon}
+
+ ${title} +
+
+ +
+ + ${choose(state, [ + ['error', () => this.renderNormalButton(needUpload)], + ['error:oversize', this.renderUpgradeButton], + ])} +
+
+ +
${kind}
+
+ `; + } + + protected renderWithVertical( + classInfo: ClassInfo, + { + icon, + title, + description, + kind, + state, + needUpload, + }: AttachmentResolvedStateInfo + ) { + return html` +
+
+
+
${icon}
+
+ ${title} +
+
+ + +
+ +
+ ${kind} + ${choose(state, [ + ['error', () => this.renderNormalButton(needUpload)], + ['error:oversize', this.renderUpgradeButton], + ])} +
+
+ `; + } + + protected resolvedState$ = computed(() => { + const size = this.model.props.size; + const name = this.model.props.name$.value; + const kind = getAttachmentFileIcon(this.filetype); + + const resolvedState = this.resourceController.resolveStateWith({ + loadingIcon: LoadingIcon(), + errorIcon: WarningIcon(), + icon: AttachmentIcon(), + title: name, + description: formatSize(size), + }); + + return { ...resolvedState, kind }; + }); + + protected renderCardView = () => { + const resolvedState = this.resolvedState$.value; + const cardStyle = this.model.props.style$.value ?? AttachmentBlockStyles[1]; + + const classInfo = { + 'affine-attachment-card': true, + [cardStyle]: true, + loading: resolvedState.loading, + error: resolvedState.error, + }; + + return when( + cardStyle === 'cubeThick', + () => this.renderWithVertical(classInfo, resolvedState), + () => this.renderWithHorizontal(classInfo, resolvedState) + ); + }; + + protected renderEmbedView = () => { + const { model, blobUrl } = this; + if (!model.props.embed$.value || !blobUrl) return null; + + const { std, _maxFileSize } = this; + const provider = std.get(AttachmentEmbedProvider); + + const render = provider.getRender(model, _maxFileSize); + if (!render) return null; + + const enabled = provider.shouldShowStatus(model); + + return html` +
+ ${guard([this._refreshKey$.value], () => render(model, blobUrl))} +
+ ${when(enabled, () => { + const resolvedState = this.resolvedState$.value; + if (resolvedState.state !== 'error') return null; + // It should be an error messge. + const message = resolvedState.description; + if (!message) return null; + + const needUpload = resolvedState.needUpload; + const action = () => + needUpload ? this.resourceController.upload() : this.reload(); + + return html` + + `; + })} + `; + }; + + private readonly _renderCitation = () => { + const { name, footnoteIdentifier } = this.model.props; + const icon = getAttachmentFileIcon(this.filetype); + + return html``; + }; + + override renderBlock() { + return html` +
+ ${when( + this.isCitation, + () => this._renderCitation(), + () => this.renderEmbedView() ?? this.renderCardView() + )} +
+ `; + } + + override accessor selectedStyle = SelectedStyle.Border; + + override accessor useCaptionEditor = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-attachment': AttachmentBlockComponent; + } +} diff --git a/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts new file mode 100644 index 00000000..2fd641ca --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts @@ -0,0 +1,72 @@ +import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; +import { + AttachmentBlockSchema, + AttachmentBlockStyles, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { toGfxBlockComponent } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { AttachmentBlockComponent } from './attachment-block.js'; + +export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent( + AttachmentBlockComponent +) { + override blockDraggable = false; + + get slots() { + return this.std.get(EdgelessLegacySlotIdentifier); + } + + override onClick(_: MouseEvent) { + return; + } + + override renderGfxBlock() { + const { style$ } = this.model.props; + const cardStyle = style$.value ?? AttachmentBlockStyles[1]; + const width = EMBED_CARD_WIDTH[cardStyle]; + const height = EMBED_CARD_HEIGHT[cardStyle]; + const bound = this.model.elementBound; + const scaleX = bound.w / width; + const scaleY = bound.h / height; + + this.containerStyleMap = styleMap({ + width: `${width}px`, + height: `${height}px`, + transform: `scale(${scaleX}, ${scaleY})`, + transformOrigin: '0 0', + overflow: 'hidden', + }); + + return this.renderPageContent(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-attachment': AttachmentEdgelessBlockComponent; + } +} + +export const AttachmentBlockInteraction = GfxViewInteractionExtension( + AttachmentBlockSchema.model.flavour, + { + resizeConstraint: { + lockRatio: true, + }, + handleRotate: () => { + return { + beforeRotate: context => { + context.set({ + rotatable: false, + }); + }, + }; + }, + } +); diff --git a/blocksuite/affine/blocks/attachment/src/attachment-service.ts b/blocksuite/affine/blocks/attachment/src/attachment-service.ts new file mode 100644 index 00000000..bbc0d8dc --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/attachment-service.ts @@ -0,0 +1,51 @@ +import { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import { FileDropConfigExtension } from '@blocksuite/affine-components/drop-indicator'; +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { + isInsideEdgelessEditor, + matchModels, +} from '@blocksuite/affine-shared/utils'; +import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; + +import { addAttachments, addSiblingAttachmentBlocks } from './utils.js'; + +export const AttachmentDropOption = FileDropConfigExtension({ + flavour: AttachmentBlockSchema.model.flavour, + onDrop: ({ files, targetModel, placement, point, std }) => { + // generic attachment block for all files except images + const attachmentFiles = files.filter( + file => !file.type.startsWith('image/') + ); + if (!attachmentFiles.length) return false; + + if (targetModel && !matchModels(targetModel, [SurfaceBlockModel])) { + addSiblingAttachmentBlocks( + std, + attachmentFiles, + targetModel, + placement + ).catch(console.error); + + return true; + } + + if (isInsideEdgelessEditor(std.host)) { + const gfx = std.get(GfxControllerIdentifier); + point = gfx.viewport.toViewCoordFromClientCoord(point); + addAttachments(std, attachmentFiles, point).catch(console.error); + + std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:drop', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'attachment', + }); + + return true; + } + + return false; + }, +}); diff --git a/blocksuite/affine/blocks/attachment/src/components/rename-model.ts b/blocksuite/affine/blocks/attachment/src/components/rename-model.ts new file mode 100644 index 00000000..48077a31 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/components/rename-model.ts @@ -0,0 +1,95 @@ +import { ConfirmIcon } from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { AttachmentBlockModel } from '@blocksuite/affine-model'; +import { CitationProvider } from '@blocksuite/affine-shared/services'; +import type { EditorHost } from '@blocksuite/std'; +import { html } from 'lit'; +import { createRef, ref } from 'lit/directives/ref.js'; + +import { renameStyles } from './styles'; + +export const RenameModal = ({ + editorHost, + model, + abortController, +}: { + editorHost: EditorHost; + model: AttachmentBlockModel; + abortController: AbortController; +}) => { + const inputRef = createRef(); + // Fix auto focus + setTimeout(() => inputRef.value?.focus()); + const originalName = model.props.name; + const nameWithoutExtension = originalName.slice( + 0, + originalName.lastIndexOf('.') + ); + const originalExtension = originalName.slice(originalName.lastIndexOf('.')); + const includeExtension = + originalExtension.includes('.') && + originalExtension.length <= 7 && + // including the dot + originalName.length > originalExtension.length; + + let fileName = includeExtension ? nameWithoutExtension : originalName; + const extension = includeExtension ? originalExtension : ''; + const citationService = editorHost.std.get(CitationProvider); + + const abort = () => abortController.abort(); + const onConfirm = () => { + const newFileName = fileName + extension; + if (!newFileName) { + toast(editorHost, 'File name cannot be empty'); + return; + } + model.store.updateBlock(model, { + name: newFileName, + }); + if (citationService.isCitationModel(model)) { + citationService.trackEvent('Edit'); + } + abort(); + }; + const onInput = (e: InputEvent) => { + fileName = (e.target as HTMLInputElement).value; + }; + const onKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape' && !e.isComposing) { + abort(); + return; + } + if (e.key === 'Enter' && !e.isComposing) { + onConfirm(); + return; + } + }; + + return html` + +
+
+
+ + ${extension} +
+ + ${ConfirmIcon} + +
+ `; +}; diff --git a/blocksuite/affine/blocks/attachment/src/components/styles.ts b/blocksuite/affine/blocks/attachment/src/components/styles.ts new file mode 100644 index 00000000..0b6deadf --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/components/styles.ts @@ -0,0 +1,64 @@ +import { fontXSStyle, panelBaseStyle } from '@blocksuite/affine-shared/styles'; +import { css } from 'lit'; + +export const renameStyles = css` + ${panelBaseStyle('.affine-attachment-rename-container')} + .affine-attachment-rename-container { + position: relative; + display: flex; + align-items: center; + width: 320px; + gap: 12px; + padding: 12px; + z-index: var(--affine-z-index-popover); + } + + .affine-attachment-rename-input-wrapper { + display: flex; + min-width: 280px; + height: 30px; + box-sizing: border-box; + padding: 4px 10px; + background: var(--affine-white-10); + border-radius: 4px; + border: 1px solid var(--affine-border-color); + } + + .affine-attachment-rename-input-wrapper:focus-within { + border-color: var(--affine-blue-700); + box-shadow: var(--affine-active-shadow); + } + + .affine-attachment-rename-input-wrapper input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--affine-text-primary-color); + } + ${fontXSStyle('.affine-attachment-rename-input-wrapper input')} + + .affine-attachment-rename-input-wrapper input::placeholder { + color: var(--affine-placeholder-color); + } + + .affine-attachment-rename-extension { + font-size: var(--affine-font-xs); + color: var(--affine-text-secondary-color); + } + + .affine-attachment-rename-overlay-mask { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: var(--affine-z-index-popover); + } +`; + +export const styles = css` + :host { + z-index: 1; + } +`; diff --git a/blocksuite/affine/blocks/attachment/src/configs/slash-menu.ts b/blocksuite/affine/blocks/attachment/src/configs/slash-menu.ts new file mode 100644 index 00000000..b723345d --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/configs/slash-menu.ts @@ -0,0 +1,58 @@ +import { openSingleFileWith } from '@blocksuite/affine-shared/utils'; +import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu'; +import { ExportToPdfIcon, FileIcon } from '@blocksuite/icons/lit'; + +import { addSiblingAttachmentBlocks } from '../utils'; +import { AttachmentTooltip, PDFTooltip } from './tooltips'; + +export const attachmentSlashMenuConfig: SlashMenuConfig = { + items: [ + { + name: 'Attachment', + description: 'Attach a file to document.', + icon: FileIcon(), + tooltip: { + figure: AttachmentTooltip, + caption: 'Attachment', + }, + searchAlias: ['file'], + group: '4_Content & Media@3', + when: ({ model }) => + model.store.schema.flavourSchemaMap.has('affine:attachment'), + action: ({ std, model }) => { + (async () => { + const file = await openSingleFileWith(); + if (!file) return; + + await addSiblingAttachmentBlocks(std, [file], model); + if (model.text?.length === 0) { + std.store.deleteBlock(model); + } + })().catch(console.error); + }, + }, + { + name: 'PDF', + description: 'Upload a PDF to document.', + icon: ExportToPdfIcon(), + tooltip: { + figure: PDFTooltip, + caption: 'PDF', + }, + group: '4_Content & Media@4', + when: ({ model }) => + model.store.schema.flavourSchemaMap.has('affine:attachment'), + action: ({ std, model }) => { + (async () => { + const file = await openSingleFileWith(); + if (!file) return; + + await addSiblingAttachmentBlocks(std, [file], model); + if (model.text?.length === 0) { + std.store.deleteBlock(model); + } + })().catch(console.error); + }, + }, + ], +}; diff --git a/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts new file mode 100644 index 00000000..3476e905 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/configs/toolbar.ts @@ -0,0 +1,406 @@ +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { + AttachmentBlockModel, + defaultAttachmentProps, + type EmbedCardStyle, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, + ToolbarModuleExtension, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { Bound } from '@blocksuite/global/gfx'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DownloadIcon, + DuplicateIcon, + EditIcon, + ReplaceIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { BlockFlavourIdentifier } from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; +import { flip, offset } from '@floating-ui/dom'; +import { computed } from '@preact/signals-core'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; + +import { AttachmentBlockComponent } from '../attachment-block'; +import { RenameModal } from '../components/rename-model'; +import { AttachmentEmbedProvider } from '../embed'; + +const trackBaseProps = { + category: 'attachment', + type: 'card view', +}; + +export const attachmentViewDropdownMenu = { + id: 'b.conversions', + actions: [ + { + id: 'card', + label: 'Card view', + run(ctx) { + const model = ctx.getCurrentModelByType(AttachmentBlockModel); + if (!model) return; + + const style = defaultAttachmentProps.style!; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + const bounds = Bound.deserialize(model.xywh); + bounds.w = width; + bounds.h = height; + + ctx.store.updateBlock(model, { + style, + embed: false, + xywh: bounds.serialize(), + }); + }, + }, + { + id: 'embed', + label: 'Embed view', + disabled: ctx => { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + return block ? !block.embedded() : true; + }, + run(ctx) { + const model = ctx.getCurrentModelByType(AttachmentBlockModel); + if (!model) return; + + const provider = ctx.std.get(AttachmentEmbedProvider); + + // TODO(@fundon): should auto focus image block. + if ( + provider.shouldBeConverted(model) && + !ctx.hasSelectedSurfaceModels + ) { + // Clears + ctx.reset(); + ctx.select('note'); + } + + provider.convertTo(model); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + if (!block) return null; + + const model = block.model; + const embedProvider = ctx.std.get(AttachmentEmbedProvider); + const actions = computed(() => { + const [cardAction, embedAction] = this.actions.map(action => ({ + ...action, + })); + + const ok = block.resourceController.resolvedState$.value.state === 'none'; + const sourceId = Boolean(model.props.sourceId$.value); + const embed = model.props.embed$.value ?? false; + // 1. Check whether `sourceId` exists. + // 2. Check if `embedded` is allowed. + // 3. Check `blobState$` + const allowed = ok && sourceId && embedProvider.embedded(model) && !embed; + + cardAction.disabled = !embed; + embedAction.disabled = !allowed; + + return [cardAction, embedAction]; + }); + const viewType$ = computed(() => { + const [cardAction, embedAction] = actions.value; + const embed = model.props.embed$.value ?? false; + return embed ? embedAction.label : cardAction.label; + }); + const onToggle = (e: CustomEvent) => { + e.stopPropagation(); + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedViewSelector', { + ...trackBaseProps, + control: 'switch view', + }); + }; + + return html``; + }, +} as const satisfies ToolbarActionGroup; + +const replaceAction = { + id: 'c.replace', + tooltip: 'Replace attachment', + icon: ReplaceIcon(), + disabled(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + if (!block) return true; + + const { downloading = false, uploading = false } = + block.resourceController.state$.value; + return downloading || uploading; + }, + run(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + block?.replace().catch(console.error); + }, +} as const satisfies ToolbarAction; + +const downloadAction = { + id: 'd.download', + tooltip: 'Download', + icon: DownloadIcon(), + run(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + block?.download(); + }, + when(ctx) { + const model = ctx.getCurrentModelByType(AttachmentBlockModel); + if (!model) return false; + // Current citation attachment block does not support download + return model.props.style !== 'citation' && !model.props.footnoteIdentifier; + }, +} as const satisfies ToolbarAction; + +const captionAction = { + id: 'e.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + block?.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, +} as const satisfies ToolbarAction; + +const builtinToolbarConfig = { + actions: [ + { + id: 'a.rename', + content(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + if (!block) return null; + + const abortController = new AbortController(); + abortController.signal.onabort = () => ctx.show(); + + return html` + { + ctx.hide(); + + createLitPortal({ + template: RenameModal({ + model: block.model, + editorHost: ctx.host, + abortController, + }), + computePosition: { + referenceElement: block, + placement: 'top-start', + middleware: [flip(), offset(4)], + }, + abortController, + }); + }} + > + ${EditIcon()} + + `; + }, + }, + attachmentViewDropdownMenu, + replaceAction, + downloadAction, + captionAction, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + // TODO(@fundon): unify `clone` method + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + block?.copy(); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentModelByType(AttachmentBlockModel); + if (!model) return; + + // TODO(@fundon): unify `duplicate` method + ctx.store.addSiblingBlocks(model, [ + { + flavour: model.flavour, + ...getBlockProps(model), + }, + ]); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'b.refresh', + label: 'Reload', + icon: ResetIcon(), + run(ctx) { + const block = ctx.getCurrentBlockByType(AttachmentBlockComponent); + block?.reload(); + + ctx.track('AttachmentReloadedEvent', { + ...trackBaseProps, + control: 'reload', + type: block?.model.props.name.split('.').pop() ?? '', + }); + }, + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModel(); + if (!model) return; + + ctx.store.deleteBlock(model.id); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; + +const builtinSurfaceToolbarConfig = { + actions: [ + attachmentViewDropdownMenu, + { + id: 'c.style', + actions: [ + { + id: 'horizontalThin', + label: 'Horizontal style', + }, + { + id: 'cubeThick', + label: 'Vertical style', + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType(AttachmentBlockModel); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + const style = action.id as EmbedCardStyle; + const bounds = Bound.deserialize(model.xywh); + bounds.w = EMBED_CARD_WIDTH[style]; + bounds.h = EMBED_CARD_HEIGHT[style]; + const xywh = bounds.serialize(); + + store.updateBlock(model, { style, xywh }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + page: 'whiteboard editor', + control: 'select card style', + type: style, + }); + }, + })) satisfies ToolbarAction[]; + const style$ = model.props.style$; + const onToggle = (e: CustomEvent) => { + e.stopPropagation(); + const opened = e.detail; + if (!opened) return; + + ctx.track('OpenedCardStyleSelector', { + ...trackBaseProps, + page: 'whiteboard editor', + control: 'switch card style', + }); + }; + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + ...replaceAction, + id: 'd.replace', + }, + { + ...downloadAction, + id: 'e.download', + }, + { + ...captionAction, + id: 'f.caption', + }, + ], + when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1, +} as const satisfies ToolbarModuleConfig; + +export const createBuiltinToolbarConfigExtension = ( + flavour: string +): ExtensionType[] => { + const name = flavour.split(':').pop(); + + return [ + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(`affine:surface:${name}`), + config: builtinSurfaceToolbarConfig, + }), + ]; +}; diff --git a/blocksuite/affine/blocks/attachment/src/configs/tooltips.ts b/blocksuite/affine/blocks/attachment/src/configs/tooltips.ts new file mode 100644 index 00000000..77b4c207 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/configs/tooltips.ts @@ -0,0 +1,77 @@ +import { html } from 'lit'; +// prettier-ignore +export const AttachmentTooltip = html` + + + + + + + + + + + + + + + + + +Rickroll.mp3 + +Attach a file. + + + + + + + + + + + + + + + + + +`; + +// prettier-ignore +export const PDFTooltip = html` + + + + + + + + + + + + + + + +Rickroll.pdf + +Upload a pdf to document. + + + + + + + + + + + + + + +`; diff --git a/blocksuite/affine/blocks/attachment/src/edgeless-clipboard-config.ts b/blocksuite/affine/blocks/attachment/src/edgeless-clipboard-config.ts new file mode 100644 index 00000000..96a18546 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/edgeless-clipboard-config.ts @@ -0,0 +1,34 @@ +import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface'; +import { type BlockSnapshot } from '@blocksuite/store'; + +export class EdgelessClipboardAttachmentConfig extends EdgelessClipboardConfig { + static override readonly key = 'affine:attachment'; + + override async createBlock( + attachment: BlockSnapshot + ): Promise { + if (!this.surface) return null; + + const { xywh, rotate, sourceId, name, size, type, embed, style } = + attachment.props; + + if (!(await this.std.workspace.blobSync.get(sourceId as string))) { + return null; + } + const attachmentId = this.crud.addBlock( + 'affine:attachment', + { + xywh, + rotate, + sourceId, + name, + size, + type, + embed, + style, + }, + this.surface.model.id + ); + return attachmentId; + } +} diff --git a/blocksuite/affine/blocks/attachment/src/effects.ts b/blocksuite/affine/blocks/attachment/src/effects.ts new file mode 100644 index 00000000..dea20059 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/effects.ts @@ -0,0 +1,10 @@ +import { AttachmentBlockComponent } from './attachment-block'; +import { AttachmentEdgelessBlockComponent } from './attachment-edgeless-block'; + +export function effects() { + customElements.define( + 'affine-edgeless-attachment', + AttachmentEdgelessBlockComponent + ); + customElements.define('affine-attachment', AttachmentBlockComponent); +} diff --git a/blocksuite/affine/blocks/attachment/src/embed.ts b/blocksuite/affine/blocks/attachment/src/embed.ts new file mode 100644 index 00000000..fbe44c1c --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/embed.ts @@ -0,0 +1,305 @@ +import { + type AttachmentBlockModel, + type ImageBlockProps, + MAX_IMAGE_WIDTH, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { FileSizeLimitProvider } from '@blocksuite/affine-shared/services'; +import { + readImageSize, + transformModel, + withTempBlobData, +} from '@blocksuite/affine-shared/utils'; +import type { Container } from '@blocksuite/global/di'; +import { createIdentifier } from '@blocksuite/global/di'; +import { Bound } from '@blocksuite/global/gfx'; +import { type BlockStdScope, StdIdentifier } from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; +import { Extension } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getAttachmentBlob } from './utils'; + +export type AttachmentEmbedConfig = { + name: string; + /** + * Check if the attachment can be turned into embed view. + */ + check: (model: AttachmentBlockModel, maxFileSize: number) => boolean; + /** + * The action will be executed when the 「Turn into embed view」 button is clicked. + */ + action?: ( + model: AttachmentBlockModel, + std: BlockStdScope + ) => Promise | void; + /** + * Renders the embed view. + */ + render?: ( + model: AttachmentBlockModel, + blobUrl: string + ) => TemplateResult | null; + + /** + * Should show status when turned on. + */ + shouldShowStatus?: boolean; + + /** + * Should block type conversion be required. + */ + shouldBeConverted?: boolean; +}; + +// Single embed config. +export const AttachmentEmbedConfigIdentifier = + createIdentifier( + 'AffineAttachmentEmbedConfigIdentifier' + ); + +export function AttachmentEmbedConfigExtension( + configs: AttachmentEmbedConfig[] = embedConfig +): ExtensionType { + return { + setup: di => { + configs.forEach(option => { + di.addImpl(AttachmentEmbedConfigIdentifier(option.name), () => option); + }); + }, + }; +} + +// A embed config map. +export const AttachmentEmbedConfigMapIdentifier = createIdentifier< + Map +>('AffineAttachmentEmbedConfigMapIdentifier'); + +export const AttachmentEmbedProvider = createIdentifier( + 'AffineAttachmentEmbedProvider' +); + +export class AttachmentEmbedService extends Extension { + private get _maxFileSize() { + return this.std.get(FileSizeLimitProvider).maxFileSize; + } + + get keys() { + return this.configs.keys(); + } + + get values() { + return this.configs.values(); + } + + get configs(): Map { + return this.std.get(AttachmentEmbedConfigMapIdentifier); + } + + constructor(private readonly std: BlockStdScope) { + super(); + } + + static override setup(di: Container) { + di.addImpl(AttachmentEmbedConfigMapIdentifier, provider => + provider.getAll(AttachmentEmbedConfigIdentifier) + ); + di.addImpl(AttachmentEmbedProvider, this, [StdIdentifier]); + } + + // Converts to embed view. + convertTo(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) { + const config = this.values.find(config => config.check(model, maxFileSize)); + + if (config?.action) { + config.action(model, this.std)?.catch(console.error); + return; + } + + model.store.updateBlock(model, { embed: true }); + } + + embedded(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) { + return this.values.some(config => config.check(model, maxFileSize)); + } + + getRender(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) { + return ( + this.values.find(config => config.check(model, maxFileSize))?.render ?? + null + ); + } + + shouldShowStatus( + model: AttachmentBlockModel, + maxFileSize = this._maxFileSize + ) { + return ( + this.values.find(config => config.check(model, maxFileSize)) + ?.shouldShowStatus ?? false + ); + } + + shouldBeConverted( + model: AttachmentBlockModel, + maxFileSize = this._maxFileSize + ) { + return ( + this.values.find(config => config.check(model, maxFileSize)) + ?.shouldBeConverted ?? false + ); + } +} + +const embedConfig: AttachmentEmbedConfig[] = [ + { + name: 'image', + shouldBeConverted: true, + check: model => + model.store.schema.flavourSchemaMap.has('affine:image') && + model.props.type.startsWith('image/'), + async action(model, std) { + const component = std.view.getBlock(model.id); + if (!component) return; + + await turnIntoImageBlock(model); + }, + }, + { + name: 'pdf', + shouldShowStatus: true, + check: (model, maxFileSize) => + model.props.type === 'application/pdf' && model.props.size <= maxFileSize, + action: model => { + const bound = Bound.deserialize(model.props.xywh); + bound.w = EMBED_CARD_WIDTH.pdf; + bound.h = EMBED_CARD_HEIGHT.pdf; + model.store.updateBlock(model, { + embed: true, + style: 'pdf', + xywh: bound.serialize(), + }); + }, + render: (_, blobUrl) => { + // More options: https://tinytip.co/tips/html-pdf-params/ + // https://chromium.googlesource.com/chromium/src/+/refs/tags/121.0.6153.1/chrome/browser/resources/pdf/open_pdf_params_parser.ts + const parameters = '#toolbar=0'; + return html` + +
+ `; + }, + }, + { + name: 'video', + shouldShowStatus: true, + check: (model, maxFileSize) => + model.props.type.startsWith('video/') && model.props.size <= maxFileSize, + action: model => { + const bound = Bound.deserialize(model.props.xywh); + bound.w = EMBED_CARD_WIDTH.video; + bound.h = EMBED_CARD_HEIGHT.video; + model.store.updateBlock(model, { + embed: true, + style: 'video', + xywh: bound.serialize(), + }); + }, + render: (_, blobUrl) => + html``, + }, + { + name: 'audio', + check: (model, maxFileSize) => + model.props.type.startsWith('audio/') && model.props.size <= maxFileSize, + render: (_, blobUrl) => + html``, + }, +]; + +/** + * Turn the attachment block into an image block. + */ +async function turnIntoImageBlock(model: AttachmentBlockModel) { + if (!model.store.schema.flavourSchemaMap.has('affine:image')) { + console.error('The image flavour is not supported!'); + return; + } + + const sourceId = model.props.sourceId; + if (!sourceId) return; + + const { saveAttachmentData, getImageData } = withTempBlobData(); + saveAttachmentData(sourceId, { name: model.props.name }); + + let imageSize = model.props.sourceId + ? getImageData(model.props.sourceId) + : undefined; + + const bounds = model.xywh + ? Bound.fromXYWH(model.deserializedXYWH) + : undefined; + + if (bounds) { + if (!imageSize?.width || !imageSize?.height) { + const blob = await getAttachmentBlob(model); + if (blob) { + imageSize = await readImageSize(blob); + } + } + + if (imageSize?.width && imageSize?.height) { + const p = imageSize.height / imageSize.width; + imageSize.width = Math.min(imageSize.width, MAX_IMAGE_WIDTH); + imageSize.height = imageSize.width * p; + bounds.w = imageSize.width; + bounds.h = imageSize.height; + } + } + + const others = bounds ? { xywh: bounds.serialize() } : undefined; + + const imageProp: Partial = { + sourceId, + caption: model.props.caption, + size: model.props.size, + ...imageSize, + ...others, + }; + transformModel(model, 'affine:image', imageProp); +} diff --git a/blocksuite/affine/blocks/attachment/src/index.ts b/blocksuite/affine/blocks/attachment/src/index.ts new file mode 100644 index 00000000..b601101a --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/index.ts @@ -0,0 +1,11 @@ +export * from './adapters'; +export * from './attachment-block'; +export * from './attachment-service'; +export { attachmentViewDropdownMenu } from './configs/toolbar'; +export * from './edgeless-clipboard-config'; +export { + type AttachmentEmbedConfig, + AttachmentEmbedConfigIdentifier, + AttachmentEmbedProvider, +} from './embed'; +export { addAttachments, addSiblingAttachmentBlocks } from './utils'; diff --git a/blocksuite/affine/blocks/attachment/src/store.ts b/blocksuite/affine/blocks/attachment/src/store.ts new file mode 100644 index 00000000..56605746 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/store.ts @@ -0,0 +1,17 @@ +import { + type StoreExtensionContext, + StoreExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { AttachmentBlockSchemaExtension } from '@blocksuite/affine-model'; + +import { AttachmentBlockAdapterExtensions } from './adapters/extension'; + +export class AttachmentStoreExtension extends StoreExtensionProvider { + override name = 'affine-attachment-block'; + + override setup(context: StoreExtensionContext) { + super.setup(context); + context.register(AttachmentBlockSchemaExtension); + context.register(AttachmentBlockAdapterExtensions); + } +} diff --git a/blocksuite/affine/blocks/attachment/src/styles.ts b/blocksuite/affine/blocks/attachment/src/styles.ts new file mode 100644 index 00000000..95cc751f --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/styles.ts @@ -0,0 +1,160 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css } from 'lit'; + +export const styles = css` + .affine-attachment-container { + border-radius: 8px; + box-sizing: border-box; + user-select: none; + overflow: hidden; + border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')}; + background: ${unsafeCSSVarV2('layer/background/primary')}; + + &.focused { + border-color: ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')}; + } + } + + .affine-attachment-container.comment-highlighted { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } + + .affine-attachment-card { + display: flex; + gap: 12px; + padding: 12px; + } + + .affine-attachment-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + flex: 1 0 0; + min-width: 0; + } + + .truncate { + align-self: stretch; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .affine-attachment-content-title { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + align-self: stretch; + } + + .affine-attachment-content-title-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--affine-text-primary-color); + font-size: 16px; + } + + .affine-attachment-content-title-text { + color: var(--affine-text-primary-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-attachment-content-description { + display: flex; + align-items: center; + align-self: stretch; + gap: 8px; + } + + .affine-attachment-content-info { + color: var(--affine-text-secondary-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-attachment-content-button { + display: flex; + height: 20px; + align-items: center; + align-self: stretch; + gap: 4px; + white-space: nowrap; + padding: 0 4px; + color: ${unsafeCSSVarV2('button/primary')}; + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + text-transform: capitalize; + line-height: 20px; + + svg { + font-size: 16px; + } + } + + .affine-attachment-banner { + display: flex; + align-items: center; + justify-content: center; + } + + .affine-attachment-card.loading { + .affine-attachment-content-title-text { + color: ${unsafeCSSVarV2('text/placeholder')}; + } + } + + .affine-attachment-card.error { + .affine-attachment-content-title-icon { + color: ${unsafeCSSVarV2('status/error')}; + } + } + + .affine-attachment-card.loading, + .affine-attachment-card.error { + background: ${unsafeCSSVarV2('layer/background/secondary')}; + } + + .affine-attachment-card.cubeThick { + flex-direction: column-reverse; + + .affine-attachment-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + } + + .affine-attachment-banner { + justify-content: space-between; + } + } + + .affine-attachment-embed-container { + position: relative; + width: 100%; + height: 100%; + } + + .affine-attachment-embed-status { + position: absolute; + left: 14px; + bottom: 64px; + } + + .affine-attachment-embed-event-mask { + position: absolute; + inset: 0; + } +`; diff --git a/blocksuite/affine/blocks/attachment/src/utils.ts b/blocksuite/affine/blocks/attachment/src/utils.ts new file mode 100644 index 00000000..65932715 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/utils.ts @@ -0,0 +1,216 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { + type AttachmentBlockModel, + type AttachmentBlockProps, + AttachmentBlockSchema, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + type AttachmentUploadedEvent, + FileSizeLimitProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { formatSize } from '@blocksuite/affine-shared/utils'; +import { Bound, type IVec, Vec } from '@blocksuite/global/gfx'; +import type { BlockStdScope } from '@blocksuite/std'; +import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; +import type { BlockModel } from '@blocksuite/store'; + +import type { AttachmentBlockComponent } from './attachment-block'; + +export async function getAttachmentBlob(model: AttachmentBlockModel) { + const { sourceId$, type$ } = model.props; + const sourceId = sourceId$.peek(); + const type = type$.peek(); + if (!sourceId) return null; + + const doc = model.store; + const blob = await doc.blobSync.get(sourceId); + if (!blob) return null; + + return new Blob([blob], { type }); +} + +/** + * Since the size of the attachment may be very large, + * the download process may take a long time! + */ +export function downloadAttachmentBlob(block: AttachmentBlockComponent) { + const { host, model, blobUrl, resourceController } = block; + + if (resourceController.state$.peek().downloading) { + toast(host, 'Download in progress...'); + return; + } + + const name = model.props.name; + const shortName = name.length < 20 ? name : name.slice(0, 20) + '...'; + + if (!blobUrl) { + toast(host, `Failed to download ${shortName}!`); + return; + } + + resourceController.updateState({ downloading: true }); + + toast(host, `Downloading ${shortName}`); + + const tmpLink = document.createElement('a'); + const event = new MouseEvent('click'); + tmpLink.download = name; + tmpLink.href = blobUrl; + tmpLink.dispatchEvent(event); + tmpLink.remove(); + + resourceController.updateState({ downloading: false }); +} + +export async function refreshData(block: AttachmentBlockComponent) { + const model = block.model; + const type = model.props.type$.peek(); + + await block.resourceController.refreshUrlWith(type); +} + +export async function getFileType(file: File) { + if (file.type) return file.type; + + // If the file type is not available, try to get it from the buffer. + const buffer = await file.arrayBuffer(); + const FileType = await import('file-type'); + const fileType = await FileType.fileTypeFromBuffer(buffer); + return fileType?.mime ?? ''; +} + +function hasExceeded( + std: BlockStdScope, + files: File[], + maxFileSize = std.get(FileSizeLimitProvider).maxFileSize +) { + const exceeded = files.some(file => file.size > maxFileSize); + + if (exceeded) { + const size = formatSize(maxFileSize); + toast(std.host, `You can only upload files less than ${size}`); + } + + return exceeded; +} + +async function buildPropsWith( + std: BlockStdScope, + file: File, + embed?: boolean, + mode: 'doc' | 'whiteboard' = 'doc' +) { + let type = file.type; + let category: AttachmentUploadedEvent['category'] = 'success'; + + try { + const { name, size } = file; + // TODO(@fundon): should re-upload when upload timeout + const sourceId = await std.store.blobSync.set(file); + type = await getFileType(file); + + return { + name, + size, + type, + sourceId, + embed, + } satisfies Partial; + } catch (err) { + category = 'failure'; + throw err; + } finally { + // TODO(@fundon): should change event name because this is just a local operation. + std.getOptional(TelemetryProvider)?.track('AttachmentUploadedEvent', { + page: `${mode} editor`, + module: 'attachment', + segment: mode, + control: 'uploader', + type, + category, + }); + } +} + +/** + * Add a new attachment block before / after the specified block. + */ +export async function addSiblingAttachmentBlocks( + std: BlockStdScope, + files: File[], + targetModel: BlockModel, + placement: 'before' | 'after' = 'after', + embed?: boolean +) { + if (!files.length) return []; + + if (hasExceeded(std, files)) return []; + + const flavour = AttachmentBlockSchema.model.flavour; + + const propsArray = await Promise.all( + files.map(file => buildPropsWith(std, file, embed)) + ); + + const blockIds = std.store.addSiblingBlocks( + targetModel, + propsArray.map(props => ({ ...props, flavour })), + placement + ); + + return blockIds; +} + +export async function addAttachments( + std: BlockStdScope, + files: File[], + point?: IVec, + shouldTransformPoint?: boolean // determines whether we should use `toModelCoord` to convert the point +): Promise { + if (!files.length) return []; + + if (hasExceeded(std, files)) return []; + + const propsArray = await Promise.all( + files.map(file => buildPropsWith(std, file, undefined, 'whiteboard')) + ); + + const gfx = std.get(GfxControllerIdentifier); + let { x, y } = gfx.viewport.center; + if (point) { + shouldTransformPoint = shouldTransformPoint ?? true; + if (shouldTransformPoint) { + [x, y] = gfx.viewport.toModelCoord(...point); + } else { + [x, y] = point; + } + } + + const xy = [x, y]; + const style = 'cubeThick'; + const gap = 32; + const width = EMBED_CARD_WIDTH.cubeThick; + const height = EMBED_CARD_HEIGHT.cubeThick; + const flavour = AttachmentBlockSchema.model.flavour; + + const blocks = propsArray.map((props, index) => { + const center = Vec.addScalar(xy, index * gap); + const xywh = Bound.fromCenter(center, width, height).serialize(); + return { flavour, blockProps: { ...props, style, xywh } }; + }); + + const blockIds = std.store.addBlocks(blocks, gfx.surface); + + gfx.selection.set({ + elements: blockIds, + editing: false, + }); + + return blockIds; +} diff --git a/blocksuite/affine/blocks/attachment/src/view.ts b/blocksuite/affine/blocks/attachment/src/view.ts new file mode 100644 index 00000000..575fcff3 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/src/view.ts @@ -0,0 +1,51 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; +import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; +import { literal } from 'lit/static-html.js'; + +import { AttachmentBlockInteraction } from './attachment-edgeless-block.js'; +import { AttachmentDropOption } from './attachment-service.js'; +import { attachmentSlashMenuConfig } from './configs/slash-menu.js'; +import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EdgelessClipboardAttachmentConfig } from './edgeless-clipboard-config'; +import { effects } from './effects.js'; +import { + AttachmentEmbedConfigExtension, + AttachmentEmbedService, +} from './embed'; + +const flavour = AttachmentBlockSchema.model.flavour; + +export class AttachmentViewExtension extends ViewExtensionProvider { + override name = 'affine-attachment-block'; + + override effect() { + super.effect(); + effects(); + } + + override setup(context: ViewExtensionContext) { + super.setup(context); + context.register([ + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-edgeless-attachment` + : literal`affine-attachment`; + }), + AttachmentDropOption, + AttachmentEmbedConfigExtension(), + AttachmentEmbedService, + SlashMenuConfigExtension(flavour, attachmentSlashMenuConfig), + ...createBuiltinToolbarConfigExtension(flavour), + ]); + if (this.isEdgeless(context.scope)) { + context.register(EdgelessClipboardAttachmentConfig); + context.register(AttachmentBlockInteraction); + } + } +} diff --git a/blocksuite/affine/blocks/attachment/tsconfig.json b/blocksuite/affine/blocks/attachment/tsconfig.json new file mode 100644 index 00000000..f2718e77 --- /dev/null +++ b/blocksuite/affine/blocks/attachment/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [ + { "path": "../surface" }, + { "path": "../../components" }, + { "path": "../../ext-loader" }, + { "path": "../../model" }, + { "path": "../../shared" }, + { "path": "../../widgets/slash-menu" }, + { "path": "../../../framework/global" }, + { "path": "../../../framework/std" }, + { "path": "../../../framework/store" } + ] +} diff --git a/blocksuite/affine/blocks/bookmark/package.json b/blocksuite/affine/blocks/bookmark/package.json new file mode 100644 index 00000000..ab980fa8 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/package.json @@ -0,0 +1,48 @@ +{ + "name": "@blocksuite/affine-block-bookmark", + "description": "Bookmark block for BlockSuite.", + "type": "module", + "scripts": { + "build": "yarn run -T tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-block-embed": "workspace:*", + "@blocksuite/affine-block-embed-doc": "workspace:*", + "@blocksuite/affine-block-surface": "workspace:*", + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.2.17", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.23", + "lit": "^3.2.0", + "rxjs": "^7.8.2", + "yjs": "^13.6.27" + }, + "devDependencies": { + "@vitest/browser-playwright": "^4.1.10", + "playwright": "=1.58.2", + "vitest": "^4.1.8" + }, + "exports": { + ".": "./src/index.ts", + "./store": "./src/store.ts", + "./view": "./src/view.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5" +} diff --git a/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts b/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts new file mode 100644 index 00000000..3d1c8e90 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { footnoteUrlPreprocessor } from '../../adapters/markdown/preprocessor'; + +describe('footnoteUrlPreprocessor', () => { + it('should encode unencoded URLs in footnote definitions', () => { + const input = + '[^ref]: {"type":"url","url":"https://example.com?param=value"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should not encode already encoded URLs', () => { + const input = '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com"}'; + expect(footnoteUrlPreprocessor(input)).toBe(input); + }); + + it('should handle invalid JSON content', () => { + const input = '[^ref]: {"invalid json"}'; + expect(footnoteUrlPreprocessor(input)).toBe(input); + }); + + it('should handle non-object footnote data', () => { + const input = '[^ref]: "not an object"'; + expect(footnoteUrlPreprocessor(input)).toBe(input); + }); + + it('should handle footnote data without url property', () => { + const input = '[^ref]: {"type":"url"}'; + expect(footnoteUrlPreprocessor(input)).toBe(input); + }); + + it('should handle multiple footnote definitions', () => { + const input = ` +[^ref1]: {"type":"url","url":"https://example1.com"} +[^ref2]: {"type":"url","url":"https://example2.com"} + `.trim(); + const expected = ` +[^ref1]: {"type":"url","url":"https%3A%2F%2Fexample1.com"} +[^ref2]: {"type":"url","url":"https%3A%2F%2Fexample2.com"} + `.trim(); + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should handle special characters in URLs', () => { + const input = + '[^ref]: {"type":"url","url":"https://example.com/path with spaces?param=value&another=param"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fparam%3Dvalue%26another%3Dparam"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should encode unencoded favicon URLs', () => { + const input = + '[^ref]: {"type":"url","url":"https://example.com","favicon":"https://example.com/icon.png"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should not encode already encoded favicon URLs', () => { + const input = + '[^ref]: {"type":"url","url":"https://example.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should handle both URL and icon encoding in the same footnote', () => { + const input = + '[^ref]: {"type":"url","url":"https://example.com?param=value","favicon":"https://example.com/icon.png?size=large"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue","favicon":"https%3A%2F%2Fexample.com%2Ficon.png%3Fsize%3Dlarge"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should format footnote definition with space', () => { + const input = + '[^ref]:{"type":"url","url":"https://example.com?param=value"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should format footnote definition with multiple spaces', () => { + const input = + '[^ref]: {"type":"url","url":"https://example.com?param=value"}'; + const expected = + '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should handle special characters in reference tags', () => { + const input = + '[^ref-with-special-chars!@#]: {"type":"url","url":"https://example.com"}'; + const expected = + '[^ref-with-special-chars!@#]: {"type":"url","url":"https%3A%2F%2Fexample.com"}'; + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should handle incomplete footnote definitions', () => { + const input = '[^ref]:'; + expect(footnoteUrlPreprocessor(input)).toBe(input); + }); + + it('should handle mixed content with non-footnote text', () => { + const input = ` +Some regular text +[^ref]: {"type":"url","url":"https://example.com"} +More regular text + `.trim(); + const expected = ` +Some regular text +[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com"} +More regular text + `.trim(); + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should handle empty input', () => { + expect(footnoteUrlPreprocessor('')).toBe(''); + }); + + it('should handle malformed footnote definitions', () => { + const input = '[^ref: {"type":"url","url":"https://example.com"}'; + expect(footnoteUrlPreprocessor(input)).toBe(input); + }); + + it('should handle multiple footnotes with mixed content', () => { + const input = ` +[^ref1]: {"type":"url","url":"https://example1.com"} +Some text in between +[^ref2]: {"type":"url","url":"https://example2.com"} + `.trim(); + const expected = ` +[^ref1]: {"type":"url","url":"https%3A%2F%2Fexample1.com"} +Some text in between +[^ref2]: {"type":"url","url":"https%3A%2F%2Fexample2.com"} + `.trim(); + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); + + it('should encode partial encoded URLs in footnote definitions', () => { + const input = ` +[^ref]: {"type":"url","url":"https://zh.wikipedia.org/zh-hans/%E5%B0%8F%E7%B1%B3SU7"} +[^ref2]: {"type":"url","url":"https://www.dw.com/zh/%E5%B0%8F%E7%B1%B3%E9%A6%96%E6%AC%BE%E6%B1%BD%E8%BD%A6%E5%8F%91%E5%B8%83-su7%E8%B5%B7%E4%BB%B72159%E4%B8%87%E5%85%83/a-68693432"} + `.trim(); + const expected = ` +[^ref]: {"type":"url","url":"https%3A%2F%2Fzh.wikipedia.org%2Fzh-hans%2F%25E5%25B0%258F%25E7%25B1%25B3SU7"} +[^ref2]: {"type":"url","url":"https%3A%2F%2Fwww.dw.com%2Fzh%2F%25E5%25B0%258F%25E7%25B1%25B3%25E9%25A6%2596%25E6%25AC%25BE%25E6%25B1%25BD%25E8%25BD%25A6%25E5%258F%2591%25E5%25B8%2583-su7%25E8%25B5%25B7%25E4%25BB%25B72159%25E4%25B8%2587%25E5%2585%2583%2Fa-68693432"} + `.trim(); + expect(footnoteUrlPreprocessor(input)).toBe(expected); + }); +}); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/extension.ts b/blocksuite/affine/blocks/bookmark/src/adapters/extension.ts new file mode 100644 index 00000000..bee4e441 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/extension.ts @@ -0,0 +1,13 @@ +import type { ExtensionType } from '@blocksuite/store'; + +import { BookmarkBlockHtmlAdapterExtension } from './html.js'; +import { BookmarkBlockMarkdownAdapterExtensions } from './markdown/index.js'; +import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js'; +import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js'; + +export const BookmarkBlockAdapterExtensions: ExtensionType[] = [ + BookmarkBlockHtmlAdapterExtension, + BookmarkBlockMarkdownAdapterExtensions, + BookmarkBlockNotionHtmlAdapterExtension, + BookmarkBlockPlainTextAdapterExtension, +].flat(); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/html.ts b/blocksuite/affine/blocks/bookmark/src/adapters/html.ts new file mode 100644 index 00000000..869b4474 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/html.ts @@ -0,0 +1,10 @@ +import { createEmbedBlockHtmlAdapterMatcher } from '@blocksuite/affine-block-embed'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +export const bookmarkBlockHtmlAdapterMatcher = + createEmbedBlockHtmlAdapterMatcher(BookmarkBlockSchema.model.flavour); + +export const BookmarkBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + bookmarkBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/index.ts b/blocksuite/affine/blocks/bookmark/src/adapters/index.ts new file mode 100644 index 00000000..d9193659 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown/index.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/markdown/index.ts b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/index.ts new file mode 100644 index 00000000..c125bca6 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/index.ts @@ -0,0 +1,12 @@ +import type { ExtensionType } from '@blocksuite/store'; + +import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js'; +import { BookmarkBlockMarkdownPreprocessorExtension } from './preprocessor.js'; + +export * from './markdown.js'; +export * from './preprocessor.js'; + +export const BookmarkBlockMarkdownAdapterExtensions: ExtensionType[] = [ + BookmarkBlockMarkdownPreprocessorExtension, + BookmarkBlockMarkdownAdapterExtension, +]; diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/markdown/markdown.ts b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/markdown.ts new file mode 100644 index 00000000..604a759d --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/markdown.ts @@ -0,0 +1,93 @@ +import { createEmbedBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-embed'; +import { + BookmarkBlockSchema, + FootNoteReferenceParamsSchema, +} from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + FOOTNOTE_DEFINITION_PREFIX, + getFootnoteDefinitionText, + isFootnoteDefinitionNode, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +const isUrlFootnoteDefinitionNode = (node: MarkdownAST) => { + if (!isFootnoteDefinitionNode(node)) return false; + const footnoteDefinition = getFootnoteDefinitionText(node); + try { + const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse( + JSON.parse(footnoteDefinition) + ); + return ( + footnoteDefinitionJson.type === 'url' && !!footnoteDefinitionJson.url + ); + } catch { + return false; + } +}; + +export const bookmarkBlockMarkdownAdapterMatcher = + createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour, { + toMatch: o => isUrlFootnoteDefinitionNode(o.node), + toBlockSnapshot: { + enter: (o, context) => { + if (!isFootnoteDefinitionNode(o.node)) { + return; + } + + const { walkerContext, configs } = context; + const footnoteIdentifier = o.node.identifier; + const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`; + const footnoteDefinition = configs.get(footnoteDefinitionKey); + if (!footnoteDefinition) { + return; + } + let footnoteDefinitionJson; + try { + footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse( + JSON.parse(footnoteDefinition) + ); + // If the footnote definition contains url, decode it + if (footnoteDefinitionJson.url) { + footnoteDefinitionJson.url = decodeURIComponent( + footnoteDefinitionJson.url + ); + } + if (footnoteDefinitionJson.favicon) { + footnoteDefinitionJson.favicon = decodeURIComponent( + footnoteDefinitionJson.favicon + ); + } + } catch (err) { + console.warn('Failed to parse or decode footnote definition:', err); + return; + } + + const { url, favicon, title, description } = footnoteDefinitionJson; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: BookmarkBlockSchema.model.flavour, + props: { + url, + footnoteIdentifier, + icon: favicon, + title, + description, + style: 'citation', + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + }); + +export const BookmarkBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(bookmarkBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts new file mode 100644 index 00000000..5862b756 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts @@ -0,0 +1,263 @@ +import { + type FootNoteReferenceParams, + FootNoteReferenceParamsSchema, +} from '@blocksuite/affine-model'; +import { + type MarkdownAdapterPreprocessor, + MarkdownPreprocessorExtension, +} from '@blocksuite/affine-shared/adapters'; + +// Types for footnote parser tokens +type Token = { + type: + | 'FOOTNOTE_START' + | 'REFERENCE' + | 'SEPARATOR' + | 'JSON_CONTENT' + | 'NEWLINE'; + value: string; +}; + +type FootnoteDefinition = { + reference: string; + content: FootNoteReferenceParams; +}; + +/** + * Check if a URL is already encoded with encodeURIComponent to avoid markdown link parsing + * @example + * https://example.com/path%20with%20spaces should return false + * https://example.com/ should return false + * https%3A%2F%2Fexample.com%2F should return true + */ +function isFullyEncoded(uri: string): boolean { + try { + // Should check if the components of the URI are fully encoded + return uri === encodeURIComponent(decodeURIComponent(uri)); + } catch { + // If decoding fails, the URI contains invalid percent-encoding + return true; + } +} + +// Format footnote definition with consistent spacing +function formatFootnoteDefinition(reference: string, data: object): string { + return `[^${reference}]: ${JSON.stringify(data)}`; +} + +class FootnoteParser { + private pos: number = 0; + private input: string = ''; + private tokens: Token[] = []; + + // Lexer: Convert input string into tokens + private tokenize(input: string): Token[] { + this.input = input; + this.pos = 0; + this.tokens = []; + + while (this.pos < this.input.length) { + const char = this.input[this.pos]; + + // Handle newlines + if (char === '\n') { + this.tokens.push({ type: 'NEWLINE', value: '\n' }); + this.pos++; + continue; + } + + // Match footnote start [^ + if (char === '[' && this.input[this.pos + 1] === '^') { + this.tokens.push({ type: 'FOOTNOTE_START', value: '[^' }); + this.pos += 2; + continue; + } + + // Match reference content until ] + if ( + this.tokens.length > 0 && + this.tokens[this.tokens.length - 1].type === 'FOOTNOTE_START' + ) { + let reference = ''; + while (this.pos < this.input.length && this.input[this.pos] !== ']') { + reference += this.input[this.pos]; + this.pos++; + } + if (reference) { + this.tokens.push({ type: 'REFERENCE', value: reference }); + } + this.pos++; // Skip the closing ] + continue; + } + + // Match separator ': ' + if (char === ':') { + let nextPos = this.pos + 1; + while (nextPos < this.input.length && this.input[nextPos] === ' ') { + nextPos++; + } + this.tokens.push({ + type: 'SEPARATOR', + value: this.input.slice(this.pos, nextPos), + }); + this.pos = nextPos; + continue; + } + + // Match JSON content + if (char === '{') { + let jsonContent = ''; + let braceCount = 1; + let startPos = this.pos; + + this.pos++; // Move past the opening brace + let inString = false; + let escaped = false; + while (this.pos < this.input.length && braceCount > 0) { + const char = this.input[this.pos]; + if (!inString) { + if (char === '{') braceCount++; + else if (char === '}') braceCount--; + } + if (escaped) { + escaped = false; + } else { + if (char === '\\') { + escaped = true; + } else if (char === '"') { + inString = !inString; + } + } + jsonContent += char; + this.pos++; + } + + if (braceCount === 0) { + this.tokens.push({ + type: 'JSON_CONTENT', + value: '{' + jsonContent, + }); + } else { + // Reset position if JSON is malformed + this.pos = startPos; + this.pos++; + } + continue; + } + + // Skip other characters + this.pos++; + } + + return this.tokens; + } + + // Parser: Convert tokens into structured data + private parse(tokens: Token[]): FootnoteDefinition[] { + const footnotes: FootnoteDefinition[] = []; + let currentFootnote: Partial = {}; + let isParsingFootnote = false; + + for (const token of tokens) { + switch (token.type) { + case 'FOOTNOTE_START': + isParsingFootnote = true; + currentFootnote = {}; + break; + + case 'REFERENCE': + if (isParsingFootnote) { + currentFootnote.reference = token.value; + } + break; + + case 'JSON_CONTENT': + if (isParsingFootnote) { + try { + currentFootnote.content = FootNoteReferenceParamsSchema.parse( + JSON.parse(token.value) + ); + footnotes.push(currentFootnote as FootnoteDefinition); + } catch { + // Invalid JSON, ignore this footnote + } + isParsingFootnote = false; + currentFootnote = {}; + } + break; + + case 'NEWLINE': + isParsingFootnote = false; + currentFootnote = {}; + break; + } + } + + return footnotes; + } + + // Process URLs in footnote content + private processUrls(footnote: FootnoteDefinition): FootnoteDefinition { + const content = footnote.content; + if (content.url && !isFullyEncoded(content.url)) { + content.url = encodeURIComponent(content.url); + } + if (content.favicon && !isFullyEncoded(content.favicon)) { + content.favicon = encodeURIComponent(content.favicon); + } + return footnote; + } + + // Main processing function + public process(input: string): string { + // 1. Tokenize the input + const tokens = this.tokenize(input); + + // 2. Parse tokens into footnotes + const footnotes = this.parse(tokens); + + // 3. Process URLs in footnotes + const processedFootnotes = footnotes.map(fn => this.processUrls(fn)); + + // 4. Convert back to original format + const footnoteMap = new Map( + processedFootnotes.map(fn => [ + fn.reference, + formatFootnoteDefinition(fn.reference, fn.content), + ]) + ); + + // 5. Replace in original text + return input + .split('\n') + .map(line => { + const match = line.match(/^\[\^([^\]]+)\]:/); + if (match && footnoteMap.has(match[1])) { + return footnoteMap.get(match[1])!; + } + return line; + }) + .join('\n'); + } +} + +/** + * Preprocessor for footnote url + * We should encode url in footnote definition to avoid markdown link parsing + * + * Example of footnote definition: + * [^ref]: {"type":"url","url":"https://example.com"} + */ +export function footnoteUrlPreprocessor(content: string): string { + const parser = new FootnoteParser(); + return parser.process(content); +} + +const bookmarkBlockPreprocessor: MarkdownAdapterPreprocessor = { + name: 'bookmark-block', + levels: ['block', 'slice', 'doc'], + preprocess: footnoteUrlPreprocessor, +}; + +export const BookmarkBlockMarkdownPreprocessorExtension = + MarkdownPreprocessorExtension(bookmarkBlockPreprocessor); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/notion-html.ts b/blocksuite/affine/blocks/bookmark/src/adapters/notion-html.ts new file mode 100644 index 00000000..9a31a426 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/notion-html.ts @@ -0,0 +1,71 @@ +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const bookmarkBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: BookmarkBlockSchema.model.flavour, + toMatch: o => { + return ( + HastUtils.isElement(o.node) && + o.node.tagName === 'figure' && + !!HastUtils.querySelector(o.node, '.bookmark') + ); + }, + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const bookmark = HastUtils.querySelector(o.node, '.bookmark'); + if (!bookmark) { + return; + } + + const { walkerContext } = context; + const bookmarkURL = bookmark.properties?.href; + const bookmarkTitle = HastUtils.getTextContent( + HastUtils.querySelector(bookmark, '.bookmark-title') + ); + const bookmarkDescription = HastUtils.getTextContent( + HastUtils.querySelector(bookmark, '.bookmark-description') + ); + const bookmarkIcon = HastUtils.querySelector( + bookmark, + '.bookmark-icon' + ); + const bookmarkIconURL = + typeof bookmarkIcon?.properties?.src === 'string' + ? bookmarkIcon.properties.src + : ''; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: BookmarkBlockSchema.model.flavour, + props: { + type: 'card', + url: bookmarkURL ?? '', + title: bookmarkTitle, + description: bookmarkDescription, + icon: bookmarkIconURL, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: {}, + }; + +export const BookmarkBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(bookmarkBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/plain-text.ts b/blocksuite/affine/blocks/bookmark/src/adapters/plain-text.ts new file mode 100644 index 00000000..53652bdb --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/adapters/plain-text.ts @@ -0,0 +1,9 @@ +import { createEmbedBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-embed'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +export const bookmarkBlockPlainTextAdapterMatcher = + createEmbedBlockPlainTextAdapterMatcher(BookmarkBlockSchema.model.flavour); + +export const BookmarkBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(bookmarkBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts new file mode 100644 index 00000000..8d944a10 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts @@ -0,0 +1,293 @@ +import { + CaptionedBlockComponent, + SelectedStyle, +} from '@blocksuite/affine-components/caption'; +import type { + BookmarkBlockModel, + LinkPreviewData, +} from '@blocksuite/affine-model'; +import { ImageProxyService } from '@blocksuite/affine-shared/adapters'; +import { + BlockElementCommentManager, + CitationProvider, + DocModeProvider, + LinkPreviewServiceIdentifier, +} from '@blocksuite/affine-shared/services'; +import { normalizeUrl } from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/std'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { type ClassInfo, classMap } from 'lit/directives/class-map.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { filter } from 'rxjs/operators'; + +import { refreshBookmarkUrlData } from './utils.js'; + +export const BOOKMARK_MIN_WIDTH = 450; + +export class BookmarkBlockComponent extends CaptionedBlockComponent { + selectedStyle$: ReadonlySignal | null = computed( + () => ({ + 'selected-style': this.selected$.value, + }) + ); + + private _fetchAbortController?: AbortController; + + blockDraggable = true; + + protected containerStyleMap!: ReturnType; + + /** + * @description Local link preview data + * When the doc is in readonly mode, and the link preview data are not provided (stored in the block model), + * We will use the local link preview data fetched from the link previewer service to render the block. + */ + private readonly _localLinkPreview$ = signal({ + icon: null, + title: null, + description: null, + image: null, + }); + + /** + * @description Link preview data for actual rendering + * When the doc is not in readonly mode, and the link preview data are provided (stored in the block model), + * We will use the model props to render the block. + * Otherwise, we will use the local link preview data to render the block. + */ + linkPreview$ = computed(() => { + const modelProps = this.model.props; + const local = this._localLinkPreview$.value; + return { + icon: modelProps.icon$.value ?? local.icon ?? null, + title: modelProps.title$.value ?? local.title ?? null, + description: modelProps.description$.value ?? local.description ?? null, + image: modelProps.image$.value ?? local.image ?? null, + }; + }); + + private readonly _updateLocalLinkPreview = () => { + // cancel any inflight request + this._fetchAbortController?.abort(); + this._fetchAbortController = new AbortController(); + + this.loading = true; + this.error = false; + + this.std + .get(LinkPreviewServiceIdentifier) + .query(this.model.props.url, this._fetchAbortController.signal) + .then(data => { + this._localLinkPreview$.value = { + icon: data.icon ?? null, + title: data.title ?? null, + description: data.description ?? null, + image: data.image ?? null, + }; + }) + .catch(() => { + this.error = true; + }) + .finally(() => { + this.loading = false; + }); + }; + + selectBlock = () => { + const selectionManager = this.std.selection; + const blockSelection = selectionManager.create(BlockSelection, { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + }; + + get link() { + return normalizeUrl(this.model.props.url); + } + + open = () => { + const link = this.link; + if (!link) return; + window.open(link, '_blank', 'noopener,noreferrer'); + }; + + refreshData = () => { + refreshBookmarkUrlData(this, this._fetchAbortController?.signal).catch( + console.error + ); + }; + + get citationService() { + return this.std.get(CitationProvider); + } + + get isCitation() { + return this.citationService.isCitationModel(this.model); + } + + get imageProxyService() { + return this.std.get(ImageProxyService); + } + + get isCommentHighlighted() { + return ( + this.std + .getOptional(BlockElementCommentManager) + ?.isBlockCommentHighlighted(this.model) ?? false + ); + } + + handleClick = (event: MouseEvent) => { + event.stopPropagation(); + + if ( + this.model.parent?.flavour !== 'affine:surface' && + !this.store.readonly + ) { + this.selectBlock(); + } + }; + + handleDoubleClick = (event: MouseEvent) => { + event.stopPropagation(); + this.open(); + }; + + private readonly _renderCitationView = () => { + const { url, footnoteIdentifier } = this.model.props; + const { icon, title, description } = this.linkPreview$.value; + const iconSrc = icon ? this.imageProxyService.buildUrl(icon) : undefined; + return html` + + `; + }; + + private readonly _renderCardView = () => { + return html``; + }; + + private readonly _trackCitationDeleteEvent = () => { + // Check citation delete event + this._disposables.add( + this.std.store.slots.blockUpdated + .pipe( + filter(payload => { + if (!payload.isLocal) return false; + const { flavour, id, type } = payload; + if ( + type !== 'delete' || + flavour !== this.model.flavour || + id !== this.model.id + ) + return false; + const { model } = payload; + if (!this.citationService.isCitationModel(model)) return false; + return true; + }) + ) + .subscribe(() => { + this.citationService.trackEvent('Delete'); + }) + ); + }; + + override connectedCallback() { + super.connectedCallback(); + + const mode = this.std.get(DocModeProvider).getEditorMode(); + const miniWidth = `${BOOKMARK_MIN_WIDTH}px`; + + this.containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + ...(mode === 'edgeless' ? { miniWidth } : {}), + }); + + this._fetchAbortController = new AbortController(); + + this.contentEditable = 'false'; + + if ( + (!this.model.props.description && !this.model.props.title) || + (!this.model.props.image && this.model.props.style === 'vertical') + ) { + // When the doc is readonly, and the preview data not provided + // We should fetch the preview data and update the local link preview data + if (this.store.readonly) { + this._updateLocalLinkPreview(); + return; + } + // Otherwise, we should refresh the data to the model props + this.refreshData(); + } + + this.disposables.add( + this.model.propsUpdated.subscribe(({ key }) => { + if (key === 'url') { + this.refreshData(); + } + }) + ); + + this._trackCitationDeleteEvent(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._fetchAbortController?.abort(); + } + + override renderBlock() { + return html` +
+ ${this.isCitation ? this._renderCitationView() : this._renderCardView()} +
+ `; + } + + protected override accessor blockContainerStyles: StyleInfo = { + margin: '18px 0', + }; + + @query('bookmark-card') + accessor bookmarkCard!: HTMLElement; + + @property({ attribute: false }) + accessor error = false; + + @property({ attribute: false }) + accessor loading = false; + + override accessor selectedStyle = SelectedStyle.Border; + + override accessor useCaptionEditor = true; + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-bookmark': BookmarkBlockComponent; + } +} diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts new file mode 100644 index 00000000..eb696c24 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts @@ -0,0 +1,88 @@ +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { toGfxBlockComponent } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { BookmarkBlockComponent } from './bookmark-block.js'; + +export class BookmarkEdgelessBlockComponent extends toGfxBlockComponent( + BookmarkBlockComponent +) { + override selectedStyle$ = null; + + override blockDraggable = false; + + override getRenderingRect() { + const elementBound = this.model.elementBound; + const style = this.model.props.style$.value; + + return { + x: elementBound.x, + y: elementBound.y, + w: EMBED_CARD_WIDTH[style], + h: EMBED_CARD_HEIGHT[style], + zIndex: this.toZIndex(), + }; + } + + override connectedCallback(): void { + super.connectedCallback(); + this.disposables.add( + this.gfx.selection.slots.updated.subscribe(() => { + this.requestUpdate(); + }) + ); + } + + override renderGfxBlock() { + const style = this.model.props.style$.value; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + const bound = this.model.elementBound; + const scaleX = bound.w / width; + const scaleY = bound.h / height; + const isSelected = this.gfx.selection.has(this.model.id); + + this.containerStyleMap = styleMap({ + width: `100%`, + height: `100%`, + transform: `scale(${scaleX}, ${scaleY})`, + transformOrigin: '0 0', + pointerEvents: isSelected ? 'auto' : 'none', + }); + + return this.renderPageContent(); + } + + protected override accessor blockContainerStyles: StyleInfo = { + height: '100%', + }; +} + +export const BookmarkBlockInteraction = GfxViewInteractionExtension( + BookmarkBlockSchema.model.flavour, + { + resizeConstraint: { + lockRatio: true, + }, + handleRotate: () => { + return { + beforeRotate(context) { + context.set({ + rotatable: false, + }); + }, + }; + }, + } +); + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-bookmark': BookmarkEdgelessBlockComponent; + } +} diff --git a/blocksuite/affine/blocks/bookmark/src/commands/index.ts b/blocksuite/affine/blocks/bookmark/src/commands/index.ts new file mode 100644 index 00000000..ccf842c8 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/commands/index.ts @@ -0,0 +1,2 @@ +export { insertBookmarkCommand } from './insert-bookmark.js'; +export { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js'; diff --git a/blocksuite/affine/blocks/bookmark/src/commands/insert-bookmark.ts b/blocksuite/affine/blocks/bookmark/src/commands/insert-bookmark.ts new file mode 100644 index 00000000..34a0e3dc --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/commands/insert-bookmark.ts @@ -0,0 +1,23 @@ +import { insertEmbedCard } from '@blocksuite/affine-block-embed'; +import type { EmbedCardStyle } from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import type { Command } from '@blocksuite/std'; + +export const insertBookmarkCommand: Command< + { url: string }, + { blockId: string; flavour: string } +> = (ctx, next) => { + const { url, std } = ctx; + const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url); + + let flavour = 'affine:bookmark'; + let targetStyle: EmbedCardStyle = 'vertical'; + const props: Record = { url }; + if (embedOptions) { + flavour = embedOptions.flavour; + targetStyle = embedOptions.styles[0]; + } + const blockId = insertEmbedCard(std, { flavour, targetStyle, props }); + if (!blockId) return; + next({ blockId, flavour }); +}; diff --git a/blocksuite/affine/blocks/bookmark/src/commands/insert-link-by-quick-search.ts b/blocksuite/affine/blocks/bookmark/src/commands/insert-link-by-quick-search.ts new file mode 100644 index 00000000..27174bcd --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/commands/insert-link-by-quick-search.ts @@ -0,0 +1,79 @@ +import { insertEmbedIframeWithUrlCommand } from '@blocksuite/affine-block-embed'; +import { + type InsertedLinkType, + insertEmbedLinkedDocCommand, + insertEmbedSyncedDocCommand, + type LinkableFlavour, +} from '@blocksuite/affine-block-embed-doc'; +import { + DocModeProvider, + EditorSettingProvider, + QuickSearchProvider, +} from '@blocksuite/affine-shared/services'; +import type { Command } from '@blocksuite/std'; + +import { insertBookmarkCommand } from './insert-bookmark'; + +export const insertLinkByQuickSearchCommand: Command< + {}, + { insertedLinkType: Promise } +> = (ctx, next) => { + const { std } = ctx; + const quickSearchService = std.getOptional(QuickSearchProvider); + if (!quickSearchService) { + return; + } + + const insertedLinkType: Promise = quickSearchService + .openQuickSearch() + .then(result => { + if (!result) return null; + + // add linked doc + if ('docId' in result) { + const editorMode = std.get(DocModeProvider).getEditorMode(); + const editorSettings = std.get(EditorSettingProvider); + let flavour: LinkableFlavour = 'affine:embed-linked-doc'; + if (editorMode === 'edgeless') { + flavour = + editorSettings.setting$.value.docCanvasPreferView ?? flavour; + } + + const insertCommand = + flavour === 'affine:embed-linked-doc' + ? insertEmbedLinkedDocCommand + : insertEmbedSyncedDocCommand; + + std.command.exec(insertCommand, { + docId: result.docId, + params: result.params, + }); + + return { + flavour, + }; + } + + // add normal link; + if ('externalUrl' in result) { + // try to insert embed iframe block first + const [success, { flavour }] = std.command + .chain() + .try(chain => [ + chain.pipe(insertEmbedIframeWithUrlCommand, { + url: result.externalUrl, + }), + chain.pipe(insertBookmarkCommand, { url: result.externalUrl }), + ]) + .run(); + if (!success || !flavour) return null; + return { + flavour: flavour as LinkableFlavour, + }; + } + + return null; + }); + + next({ insertedLinkType }); +}; diff --git a/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts b/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts new file mode 100644 index 00000000..abb90a5a --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/components/bookmark-card.ts @@ -0,0 +1,139 @@ +import { getEmbedCardIcons } from '@blocksuite/affine-block-embed'; +import { LoadingIcon, WebIcon16 } from '@blocksuite/affine-components/icons'; +import { ImageProxyService } from '@blocksuite/affine-shared/adapters'; +import { + LinkPreviewServiceIdentifier, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { getHostName } from '@blocksuite/affine-shared/utils'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; +import { OpenInNewIcon } from '@blocksuite/icons/lit'; +import { isGfxBlockComponent, ShadowlessElement } from '@blocksuite/std'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import type { BookmarkBlockComponent } from '../bookmark-block.js'; +import { styles } from '../styles.js'; + +export class BookmarkCard extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + override connectedCallback(): void { + super.connectedCallback(); + + this.disposables.add( + this.bookmark.model.propsUpdated.subscribe(() => { + this.requestUpdate(); + }) + ); + + this.disposables.add( + this.bookmark.std + .get(ThemeProvider) + .theme$.subscribe(() => this.requestUpdate()) + ); + } + + override render() { + const { url, style } = this.bookmark.model.props; + const { icon, title, description, image } = + this.bookmark.linkPreview$.value; + + const cardClassMap = classMap({ + loading: this.loading, + error: this.error, + [style]: true, + selected: this.bookmark.selected$.value, + edgeless: isGfxBlockComponent(this.bookmark), + 'comment-highlighted': this.bookmark.isCommentHighlighted, + }); + + const domainName = url.match( + /^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n]+)/im + )?.[1]; + + const titleText = this.loading + ? 'Loading...' + : !title + ? this.error + ? (domainName ?? 'Link card') + : '' + : title; + + const theme = this.bookmark.std.get(ThemeProvider).theme; + const { EmbedCardBannerIcon } = getEmbedCardIcons(theme); + const imageProxyService = this.bookmark.store.get(ImageProxyService); + + const titleIcon = this.loading + ? LoadingIcon() + : icon + ? html`icon` + : WebIcon16; + + const descriptionText = this.loading + ? '' + : !description + ? this.error + ? 'Failed to retrieve link information.' + : url + : (description ?? ''); + + const bannerImage = + !this.loading && image + ? html`banner` + : EmbedCardBannerIcon; + + return html` +
+
+
+
${titleIcon}
+
${titleText}
+
+
+ ${descriptionText} +
+
+
+ ${getHostName(url)} +
+ ${OpenInNewIcon({ width: '12', height: '12' })} +
+
+
+
+
${bannerImage}
+
+ + `; + } + + @property({ attribute: false }) + accessor bookmark!: BookmarkBlockComponent; + + @property({ attribute: false }) + accessor error!: boolean; + + @property({ attribute: false }) + accessor loading!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'bookmark-card': BookmarkCard; + } +} diff --git a/blocksuite/affine/blocks/bookmark/src/components/index.ts b/blocksuite/affine/blocks/bookmark/src/components/index.ts new file mode 100644 index 00000000..774cd990 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/components/index.ts @@ -0,0 +1 @@ +export * from './bookmark-card'; diff --git a/blocksuite/affine/blocks/bookmark/src/configs/slash-menu.ts b/blocksuite/affine/blocks/bookmark/src/configs/slash-menu.ts new file mode 100644 index 00000000..64e80cf8 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/configs/slash-menu.ts @@ -0,0 +1,65 @@ +import { DefaultTool } from '@blocksuite/affine-block-surface'; +import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + type SlashMenuConfig, + SlashMenuConfigIdentifier, +} from '@blocksuite/affine-widget-slash-menu'; +import { LinkIcon } from '@blocksuite/icons/lit'; +import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; +import type { ExtensionType } from '@blocksuite/store'; + +import { LinkTooltip } from './tooltips'; + +const bookmarkSlashMenuConfig: SlashMenuConfig = { + items: [ + { + name: 'Link', + description: 'Add a bookmark for reference.', + icon: LinkIcon(), + tooltip: { + figure: LinkTooltip, + caption: 'Link', + }, + group: '4_Content & Media@2', + when: ({ model }) => + model.store.schema.flavourSchemaMap.has('affine:bookmark'), + action: ({ std, model }) => { + const { host } = std; + const parentModel = host.store.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + toggleEmbedCardCreateModal( + host, + 'Links', + 'The added link will be displayed as a card view.', + { mode: 'page', parentModel, index }, + ({ mode }) => { + if (mode === 'edgeless') { + const gfx = std.get(GfxControllerIdentifier); + gfx.tool.setTool(DefaultTool); + } + } + ) + .then(() => { + if (model.text?.length === 0) { + model.store.deleteBlock(model); + } + }) + .catch(console.error); + }, + }, + ], +}; + +export const BookmarkSlashMenuConfigIdentifier = SlashMenuConfigIdentifier( + BookmarkBlockSchema.model.flavour +); + +export const BookmarkSlashMenuConfigExtension: ExtensionType = { + setup: di => { + di.addImpl(BookmarkSlashMenuConfigIdentifier, bookmarkSlashMenuConfig); + }, +}; diff --git a/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts b/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts new file mode 100644 index 00000000..20739365 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts @@ -0,0 +1,620 @@ +import { + canEmbedAsEmbedBlock, + canEmbedAsIframe, + EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE, + EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE, +} from '@blocksuite/affine-block-embed'; +import { reassociateConnectorsCommand } from '@blocksuite/affine-block-surface'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + BookmarkBlockModel, + BookmarkStyles, + type EmbedCardStyle, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + ActionPlacement, + EmbedIframeService, + EmbedOptionProvider, + type LinkEventType, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarContext, + type ToolbarModuleConfig, + ToolbarModuleExtension, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { Bound } from '@blocksuite/global/gfx'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { BlockFlavourIdentifier, BlockSelection } from '@blocksuite/std'; +import { type ExtensionType, Slice, Text } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { keyed } from 'lit/directives/keyed.js'; +import * as Y from 'yjs'; + +import { BookmarkBlockComponent } from '../bookmark-block'; + +const trackBaseProps = { + category: 'bookmark', + type: 'card view', +}; + +const previewAction = { + id: 'a.preview', + content(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return null; + + const { url } = model.props; + + return html``; + }, +} satisfies ToolbarAction; + +const captionAction = { + id: 'd.caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(ctx) { + const block = ctx.getCurrentBlockByType(BookmarkBlockComponent); + block?.captionEditor?.show(); + + ctx.track('OpenedCaptionEditor', { + ...trackBaseProps, + control: 'add caption', + }); + }, +} satisfies ToolbarAction; + +const createOnToggleFn = + ( + ctx: ToolbarContext, + name: Extract< + LinkEventType, + | 'OpenedViewSelector' + | 'OpenedCardStyleSelector' + | 'OpenedCardScaleSelector' + >, + control: string + ) => + (e: CustomEvent) => { + e.stopPropagation(); + const opened = e.detail; + if (!opened) return; + + ctx.track(name, { + ...trackBaseProps, + control, + }); + }; + +const builtinToolbarConfig = { + actions: [ + previewAction, + { + id: 'b.conversions', + actions: [ + { + id: 'inline', + label: 'Inline view', + run(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return; + + const { title, caption, url } = model.props; + const { parent } = model; + const index = parent?.children.indexOf(model); + + const yText = new Y.Text(); + const insert = title || caption || url; + yText.insert(0, insert); + yText.format(0, insert.length, { link: url }); + + const text = new Text(yText); + + ctx.store.addBlock('affine:paragraph', { text }, parent, index); + + ctx.store.deleteBlock(model); + + // Clears + ctx.reset(); + ctx.select('note'); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'inline view', + }); + }, + }, + { + id: 'card', + label: 'Card view', + disabled: true, + }, + { + id: 'embed', + label: 'Embed view', + disabled(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return true; + + const url = model.props.url; + + return ( + !canEmbedAsIframe(ctx.std, url) && + !canEmbedAsEmbedBlock(ctx.std, url) + ); + }, + run(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return; + + const { caption, url, title, description, style } = model.props; + const { parent } = model; + const index = parent?.children.indexOf(model); + if (!parent) return; + + let blockId: string | undefined; + + // first try to embed as a custom embed block + if (canEmbedAsEmbedBlock(ctx.std, url)) { + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + if (!options) return; + + const { flavour, styles } = options; + + const newStyle = styles.includes(style) + ? style + : styles.find(s => s !== 'vertical' && s !== 'cube'); + + blockId = ctx.store.addBlock( + flavour, + { + url, + caption, + title, + description, + style: newStyle, + }, + parent, + index + ); + } else if (canEmbedAsIframe(ctx.std, url)) { + const embedIframeService = ctx.std.get(EmbedIframeService); + blockId = embedIframeService.addEmbedIframeBlock( + { url, caption, title, description }, + parent.id, + index + ); + } + + if (!blockId) return; + + ctx.store.deleteBlock(model); + + // Selects new block + ctx.select('note', [ + ctx.selection.create(BlockSelection, { blockId }), + ]); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + const viewType$ = signal(actions[1].label); + const onToggle = createOnToggleFn( + ctx, + 'OpenedViewSelector', + 'switch view' + ); + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'c.style', + actions: [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + ], + content(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + store.updateBlock(model, { style: action.id }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + control: 'select card style', + type: action.id, + }); + }, + })) satisfies ToolbarAction[]; + const onToggle = createOnToggleFn( + ctx, + 'OpenedCardStyleSelector', + 'switch card style' + ); + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + captionAction, + { + placement: ActionPlacement.More, + id: 'a.clipboard', + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return; + + const slice = Slice.fromModels(ctx.store, [model]); + ctx.clipboard + .copySlice(slice) + .then(() => toast(ctx.host, 'Copied to clipboard')) + .catch(console.error); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return; + + const { flavour, parent } = model; + const props = getBlockProps(model); + const index = parent?.children.indexOf(model); + + ctx.store.addBlock(flavour, props, parent, index); + }, + }, + ], + }, + { + placement: ActionPlacement.More, + id: 'b.refresh', + label: 'Reload', + icon: ResetIcon(), + run(ctx) { + const block = ctx.getCurrentBlockByType(BookmarkBlockComponent); + block?.refreshData(); + }, + }, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + }, + ], +} as const satisfies ToolbarModuleConfig; + +const builtinSurfaceToolbarConfig = { + actions: [ + previewAction, + { + id: 'b.conversions', + actions: [ + { + id: 'card', + label: 'Card view', + disabled: true, + }, + { + id: 'embed', + label: 'Embed view', + run(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return; + + const { id: oldId, xywh, parent } = model; + const { url, caption, title, description } = model.props; + + let newId: string | undefined; + + // first try to embed as a custom embed block + if (canEmbedAsEmbedBlock(ctx.std, url)) { + const options = ctx.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + if (options?.viewType !== 'embed') return; + + const { flavour, styles } = options; + let style: EmbedCardStyle = model.props.style; + + if (!styles.includes(style)) { + style = styles[0]; + } + + const bound = Bound.deserialize(xywh); + bound.w = EMBED_CARD_WIDTH[style]; + bound.h = EMBED_CARD_HEIGHT[style]; + + newId = ctx.store.addBlock( + flavour, + { + url, + caption, + title, + description, + style, + xywh: bound.serialize(), + }, + parent + ); + } else if (canEmbedAsIframe(ctx.std, url)) { + const embedIframeService = ctx.std.get(EmbedIframeService); + const config = embedIframeService.getConfig(url); + if (!config) { + return; + } + + const bound = Bound.deserialize(xywh); + const options = config.options; + const { widthInSurface, heightInSurface } = options ?? {}; + bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE; + bound.h = + heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE; + + newId = ctx.store.addBlock( + 'affine:embed-iframe', + { url, caption, title, description, xywh: bound.serialize() }, + parent + ); + } + + if (!newId) return; + + ctx.command.exec(reassociateConnectorsCommand, { oldId, newId }); + + ctx.store.deleteBlock(model); + + // Selects new block + ctx.gfx.selection.set({ editing: false, elements: [newId] }); + + ctx.track('SelectedView', { + ...trackBaseProps, + control: 'select view', + type: 'embed view', + }); + }, + }, + ], + when(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return false; + const { url } = model.props; + return ( + canEmbedAsIframe(ctx.std, url) || canEmbedAsEmbedBlock(ctx.std, url) + ); + }, + content(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + const viewType$ = signal('Card view'); + const onToggle = createOnToggleFn( + ctx, + 'OpenedViewSelector', + 'switch view' + ); + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + id: 'b.style', + actions: ( + [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + { + id: 'vertical', + label: 'Large vertical style', + }, + { + id: 'cube', + label: 'Small vertical style', + }, + ] as const + ).filter(action => BookmarkStyles.includes(action.id)), + content(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return null; + + const actions = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + const style = action.id as EmbedCardStyle; + const bounds = Bound.deserialize(model.xywh); + bounds.w = EMBED_CARD_WIDTH[style]; + bounds.h = EMBED_CARD_HEIGHT[style]; + const xywh = bounds.serialize(); + + store.updateBlock(model, { style, xywh }); + + ctx.track('SelectedCardStyle', { + ...trackBaseProps, + control: 'select card style', + type: style, + }); + }, + })) satisfies ToolbarAction[]; + const style$ = model.props.style$; + const onToggle = createOnToggleFn( + ctx, + 'OpenedCardStyleSelector', + 'switch card style' + ); + + return html`${keyed( + model, + html`` + )}`; + }, + } satisfies ToolbarActionGroup, + { + ...captionAction, + id: 'c.caption', + }, + { + id: 'd.scale', + content(ctx) { + const model = ctx.getCurrentModelByType(BookmarkBlockModel); + if (!model) return null; + + const scale$ = computed(() => { + const { + xywh$: { value: xywh }, + } = model; + const { + style$: { value: style }, + } = model.props; + const bounds = Bound.deserialize(xywh); + const height = EMBED_CARD_HEIGHT[style]; + return Math.round(100 * (bounds.h / height)); + }); + const onSelect = (e: CustomEvent) => { + e.stopPropagation(); + + const scale = e.detail / 100; + + const bounds = Bound.deserialize(model.xywh); + const style = model.props.style; + bounds.w = EMBED_CARD_WIDTH[style] * scale; + bounds.h = EMBED_CARD_HEIGHT[style] * scale; + const xywh = bounds.serialize(); + + ctx.store.updateBlock(model, { xywh }); + + ctx.track('SelectedCardScale', { + ...trackBaseProps, + control: 'select card scale', + }); + }; + const onToggle = createOnToggleFn( + ctx, + 'OpenedCardScaleSelector', + 'switch card scale' + ); + const format = (value: number) => `${value}%`; + + return html`${keyed( + model, + html`` + )}`; + }, + }, + ], + + when: ctx => ctx.getSurfaceModelsByType(BookmarkBlockModel).length === 1, +} as const satisfies ToolbarModuleConfig; + +export const createBuiltinToolbarConfigExtension = ( + flavour: string +): ExtensionType[] => { + const name = flavour.split(':').pop(); + + return [ + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(`affine:surface:${name}`), + config: builtinSurfaceToolbarConfig, + }), + ]; +}; diff --git a/blocksuite/affine/blocks/bookmark/src/configs/tooltips.ts b/blocksuite/affine/blocks/bookmark/src/configs/tooltips.ts new file mode 100644 index 00000000..946fbc2e --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/configs/tooltips.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const LinkTooltip = html` + + + + + +In a decentralized system, we can have a kaleidoscopic complexity to our data. Any user may have a different perspective on what data they either have, choose to share, or accept. For example, one user’s edits to a document might be on their laptop on an airplane; when the plane lands and the computer reconnects, those changes are distributed to other users. Other users might choose to accept all, some, or none of those changes to their version of the document. +Learn about AFFiNE + + +`; diff --git a/blocksuite/affine/blocks/bookmark/src/edgeless-clipboard-config.ts b/blocksuite/affine/blocks/bookmark/src/edgeless-clipboard-config.ts new file mode 100644 index 00000000..bd475f56 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/edgeless-clipboard-config.ts @@ -0,0 +1,29 @@ +import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface'; +import { type BlockSnapshot } from '@blocksuite/store'; + +export class EdgelessClipboardBookmarkConfig extends EdgelessClipboardConfig { + static override readonly key = 'affine:bookmark'; + + override createBlock(bookmark: BlockSnapshot): string | null { + if (!this.surface) return null; + + const { xywh, style, url, caption, description, icon, image, title } = + bookmark.props; + + const bookmarkId = this.crud.addBlock( + 'affine:bookmark', + { + xywh, + style, + url, + caption, + description, + icon, + image, + title, + }, + this.surface.model.id + ); + return bookmarkId; + } +} diff --git a/blocksuite/affine/blocks/bookmark/src/effects.ts b/blocksuite/affine/blocks/bookmark/src/effects.ts new file mode 100644 index 00000000..34d5fca9 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/effects.ts @@ -0,0 +1,12 @@ +import { BookmarkBlockComponent } from './bookmark-block'; +import { BookmarkEdgelessBlockComponent } from './bookmark-edgeless-block'; +import { BookmarkCard } from './components/bookmark-card'; + +export function effects() { + customElements.define( + 'affine-edgeless-bookmark', + BookmarkEdgelessBlockComponent + ); + customElements.define('affine-bookmark', BookmarkBlockComponent); + customElements.define('bookmark-card', BookmarkCard); +} diff --git a/blocksuite/affine/blocks/bookmark/src/index.ts b/blocksuite/affine/blocks/bookmark/src/index.ts new file mode 100644 index 00000000..5013ebff --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/index.ts @@ -0,0 +1,6 @@ +export * from './adapters'; +export * from './bookmark-block'; +export * from './commands'; +export * from './components'; +export { BookmarkSlashMenuConfigIdentifier } from './configs/slash-menu'; +export * from './edgeless-clipboard-config'; diff --git a/blocksuite/affine/blocks/bookmark/src/store.ts b/blocksuite/affine/blocks/bookmark/src/store.ts new file mode 100644 index 00000000..24083ecd --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/store.ts @@ -0,0 +1,17 @@ +import { + type StoreExtensionContext, + StoreExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { BookmarkBlockSchemaExtension } from '@blocksuite/affine-model'; + +import { BookmarkBlockAdapterExtensions } from './adapters/extension'; + +export class BookmarkStoreExtension extends StoreExtensionProvider { + override name = 'affine-bookmark-block'; + + override setup(context: StoreExtensionContext) { + super.setup(context); + context.register(BookmarkBlockSchemaExtension); + context.register(BookmarkBlockAdapterExtensions); + } +} diff --git a/blocksuite/affine/blocks/bookmark/src/styles.ts b/blocksuite/affine/blocks/bookmark/src/styles.ts new file mode 100644 index 00000000..4ddfaa4e --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/styles.ts @@ -0,0 +1,283 @@ +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +export const styles = css` + bookmark-card { + position: relative; + display: block; + height: 100%; + width: 100%; + } + + .affine-bookmark-card { + container: affine-bookmark-card / inline-size; + margin: 0 auto; + box-sizing: border-box; + display: flex; + width: 100%; + + border-radius: 8px; + border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')}; + + background: ${unsafeCSSVarV2('layer/background/primary')}; + user-select: none; + } + + .affine-bookmark-content { + width: calc(100% - 204px); + display: flex; + flex-direction: column; + flex-grow: 1; + align-self: stretch; + gap: 4px; + padding: 12px; + } + + .affine-bookmark-content-title { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + } + + .affine-bookmark-content-title-icon { + display: flex; + width: 16px; + height: 16px; + justify-content: center; + align-items: center; + } + + .affine-bookmark-content-title-icon img, + .affine-bookmark-content-title-icon object, + .affine-bookmark-content-title-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-background-primary-color); + } + + .affine-bookmark-content-title-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-bookmark-content-description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + flex-grow: 1; + + white-space: normal; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-bookmark-content-url { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: max-content; + max-width: 100%; + } + + .affine-bookmark-content-url > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .affine-bookmark-content-url:hover > span { + color: var(--affine-link-color); + } + .affine-bookmark-content-url:hover { + fill: var(--affine-link-color); + } + + .affine-bookmark-content-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 20px; + } + .affine-bookmark-content-url-icon { + height: 12px; + width: 12px; + color: ${unsafeCSSVar('iconSecondary')}; + } + + .affine-bookmark-banner { + margin: 12px 12px 0px 0px; + width: 204px; + max-width: 100%; + height: 102px; + } + + .affine-bookmark-banner img, + .affine-bookmark-banner object, + .affine-bookmark-banner svg { + width: 204px; + max-width: 100%; + height: 102px; + object-fit: cover; + border-radius: 4px; + } + + .affine-bookmark-card.comment-highlighted { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } + + .affine-bookmark-card.loading { + .affine-bookmark-content-title-text { + color: var(--affine-placeholder-color); + } + } + + .affine-bookmark-card.error { + .affine-bookmark-content-description { + color: var(--affine-placeholder-color); + } + } + + .affine-bookmark-card.selected { + .affine-bookmark-content-url > span { + color: var(--affine-link-color); + } + .affine-bookmark-content-url .affine-bookmark-content-url-icon { + color: var(--affine-link-color); + } + } + + .affine-bookmark-card.list { + .affine-bookmark-content { + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .affine-bookmark-content-title { + width: calc(100% - 204px); + } + + .affine-bookmark-content-url { + width: 204px; + justify-content: flex-end; + } + + .affine-bookmark-content-description { + display: none; + } + + .affine-bookmark-banner { + display: none; + } + } + + .affine-bookmark-card.vertical { + flex-direction: column-reverse; + height: 100%; + + .affine-bookmark-content { + width: 100%; + } + + .affine-bookmark-content-description { + -webkit-line-clamp: 6; + max-height: 120px; + } + + .affine-bookmark-content-url-wrapper { + max-width: fit-content; + display: flex; + align-items: flex-end; + flex-grow: 1; + cursor: pointer; + } + + .affine-bookmark-banner { + width: 340px; + height: 170px; + margin-left: 12px; + } + + .affine-bookmark-banner img, + .affine-bookmark-banner object, + .affine-bookmark-banner svg { + width: 340px; + height: 170px; + } + } + + .affine-bookmark-card.cube { + .affine-bookmark-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + } + + .affine-bookmark-content-title { + flex-direction: column; + gap: 4px; + align-items: flex-start; + } + + .affine-bookmark-content-title-text { + -webkit-line-clamp: 2; + } + + .affine-bookmark-content-description { + display: none; + } + + .affine-bookmark-banner { + display: none; + } + } + + @container affine-bookmark-card (width < 375px) { + .affine-bookmark-content { + width: 100%; + } + .affine-bookmark-card:not(.edgeless) .affine-bookmark-banner { + display: none; + } + } +`; diff --git a/blocksuite/affine/blocks/bookmark/src/utils.ts b/blocksuite/affine/blocks/bookmark/src/utils.ts new file mode 100644 index 00000000..7e9f309a --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/utils.ts @@ -0,0 +1,47 @@ +import { LinkPreviewServiceIdentifier } from '@blocksuite/affine-shared/services'; +import { isAbortError } from '@blocksuite/affine-shared/utils'; + +import type { BookmarkBlockComponent } from './bookmark-block.js'; + +export async function refreshBookmarkUrlData( + bookmarkElement: BookmarkBlockComponent, + signal?: AbortSignal +) { + let title = null, + description = null, + icon = null, + image = null; + + try { + bookmarkElement.loading = true; + + const linkPreviewer = bookmarkElement.std.get(LinkPreviewServiceIdentifier); + const bookmarkUrlData = await linkPreviewer.query( + bookmarkElement.model.props.url, + signal + ); + + title = bookmarkUrlData.title ?? null; + description = bookmarkUrlData.description ?? null; + icon = bookmarkUrlData.icon ?? null; + image = bookmarkUrlData.image ?? null; + + if (!title && !description && !icon && !image) { + bookmarkElement.error = true; + } + + if (signal?.aborted) return; + + bookmarkElement.store.updateBlock(bookmarkElement.model, { + title, + description, + icon, + image, + }); + } catch (error) { + if (signal?.aborted || isAbortError(error)) return; + throw error; + } finally { + bookmarkElement.loading = false; + } +} diff --git a/blocksuite/affine/blocks/bookmark/src/view.ts b/blocksuite/affine/blocks/bookmark/src/view.ts new file mode 100644 index 00000000..6e3faa17 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/src/view.ts @@ -0,0 +1,43 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; +import { literal } from 'lit/static-html.js'; + +import { BookmarkBlockInteraction } from './bookmark-edgeless-block'; +import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu'; +import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EdgelessClipboardBookmarkConfig } from './edgeless-clipboard-config'; +import { effects } from './effects'; + +const flavour = BookmarkBlockSchema.model.flavour; + +export class BookmarkViewExtension extends ViewExtensionProvider { + override name = 'affine-bookmark-block'; + + override effect() { + super.effect(); + effects(); + } + + override setup(context: ViewExtensionContext) { + super.setup(context); + context.register([ + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-edgeless-bookmark` + : literal`affine-bookmark`; + }), + BookmarkSlashMenuConfigExtension, + ]); + context.register(createBuiltinToolbarConfigExtension(flavour)); + const isEdgeless = this.isEdgeless(context.scope); + if (isEdgeless) { + context.register(EdgelessClipboardBookmarkConfig); + context.register(BookmarkBlockInteraction); + } + } +} diff --git a/blocksuite/affine/blocks/bookmark/tsconfig.json b/blocksuite/affine/blocks/bookmark/tsconfig.json new file mode 100644 index 00000000..569a6f28 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [ + { "path": "../embed" }, + { "path": "../embed-doc" }, + { "path": "../surface" }, + { "path": "../../components" }, + { "path": "../../ext-loader" }, + { "path": "../../model" }, + { "path": "../../shared" }, + { "path": "../../widgets/slash-menu" }, + { "path": "../../../framework/global" }, + { "path": "../../../framework/std" }, + { "path": "../../../framework/store" } + ] +} diff --git a/blocksuite/affine/blocks/bookmark/vitest.config.ts b/blocksuite/affine/blocks/bookmark/vitest.config.ts new file mode 100644 index 00000000..1752e9d3 --- /dev/null +++ b/blocksuite/affine/blocks/bookmark/vitest.config.ts @@ -0,0 +1,25 @@ +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + target: 'es2018', + }, + test: { + browser: { + enabled: true, + headless: true, + instances: [{ browser: 'chromium' }], + provider: playwright(), + isolate: false, + }, + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 500, + coverage: { + provider: 'istanbul', + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/bookmark', + }, + restoreMocks: true, + }, +}); diff --git a/blocksuite/affine/blocks/callout/package.json b/blocksuite/affine/blocks/callout/package.json new file mode 100644 index 00000000..50ae95f9 --- /dev/null +++ b/blocksuite/affine/blocks/callout/package.json @@ -0,0 +1,40 @@ +{ + "name": "@blocksuite/affine-block-callout", + "description": "Callout block for BlockSuite.", + "type": "module", + "scripts": { + "build": "yarn run -T tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-inline-preset": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-rich-text": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/icons": "^2.2.17", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@emotion/css": "^11.13.5", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.23", + "lit": "^3.2.0" + }, + "exports": { + ".": "./src/index.ts", + "./view": "./src/view.ts", + "./store": "./src/store.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5" +} diff --git a/blocksuite/affine/blocks/callout/src/adapters/markdown.ts b/blocksuite/affine/blocks/callout/src/adapters/markdown.ts new file mode 100644 index 00000000..b5439153 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/adapters/markdown.ts @@ -0,0 +1,207 @@ +import { CalloutBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY, + type CalloutAdmonitionType, + CalloutAdmonitionTypeSet, + CalloutExportStyle, + type CalloutMarkdownExportOptions, + calloutMarkdownExportOptionsSchema, + DEFAULT_ADMONITION_TYPE, + getCalloutEmoji, + isCalloutNode, +} from '@blocksuite/affine-shared/adapters'; +import { type DeltaInsert, nanoid } from '@blocksuite/store'; + +// Currently, the callout block children can only be paragraph block or list block +// In mdast, the node types are `paragraph`, `list`, `heading`, `blockquote` +const CALLOUT_BLOCK_CHILDREN_TYPES = new Set([ + 'paragraph', + 'list', + 'heading', + 'blockquote', +]); + +const ADMONITION_SYMBOL = ':::'; +const DEFAULT_OPTIONS: CalloutMarkdownExportOptions = { + style: CalloutExportStyle.GFM, +}; + +/** + * Get the callout export options from the configs + * @param configs - The configs of the callout block + * @returns The callout export options + */ +function getCalloutExportOptions( + configs: Map +): CalloutMarkdownExportOptions { + let exportOptions: CalloutMarkdownExportOptions = DEFAULT_OPTIONS; + try { + const options = configs.get(CALLOUT_MARKDOWN_EXPORT_OPTIONS_KEY); + if (options) { + exportOptions = calloutMarkdownExportOptionsSchema.parse(options); + } + } catch {} + return exportOptions; +} + +export const calloutBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: CalloutBlockSchema.model.flavour, + toMatch: o => isCalloutNode(o.node), + fromMatch: o => o.node.flavour === CalloutBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!o.node.data || !isCalloutNode(o.node)) { + return; + } + + // Currently, the callout block children can only be a paragraph or a list + // So we should filter out the other children + o.node.children = o.node.children.filter(child => + CALLOUT_BLOCK_CHILDREN_TYPES.has(child.type) + ); + + const { walkerContext } = context; + const calloutEmoji = getCalloutEmoji(o.node); + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: CalloutBlockSchema.model.flavour, + props: { + emoji: calloutEmoji, + }, + children: [], + }, + 'children' + ); + }, + leave: (o, context) => { + const { walkerContext } = context; + if (isCalloutNode(o.node)) { + walkerContext.closeNode(); + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const emoji = o.node.props.emoji as string; + const { walkerContext, configs } = context; + + const exportOptions = getCalloutExportOptions(configs); + const { style, admonitionType } = exportOptions; + // If the style is admonitions, we should handle the first child + if (style === CalloutExportStyle.Admonitions) { + let type = admonitionType ?? DEFAULT_ADMONITION_TYPE; + let customTitle = ''; + let restOfText = ''; + + const firstChild = o.node.children[0]; + const isTextNode = !!firstChild.props.text; + // If the first child is a text block, we should get the type and custom title from the first line of the text + // And remove the first child from the children + // Otherwise, we should use the default admonition type as the type + if (isTextNode) { + const textDelta = (firstChild.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + // Get the text of the first child + const text = textDelta.delta.reduce((acc, delta) => { + if (delta.insert) { + acc += delta.insert; + } + return acc; + }, ''); + + // If the text is not empty, we should try to get type and custom title from the text + if (text) { + // Get the first line of the text + const firstLine = text.includes('\n') ? text.split('\n')[0] : text; + // Get the rest of the text besides the first line + restOfText = text.split('\n').slice(1).join('\n'); + // Get the possible type from the first line + const possibleType = firstLine.split(' ')[0].toLowerCase(); + // If the type is a valid admonition type, we should use it as the type + if (CalloutAdmonitionTypeSet.has(possibleType)) { + type = possibleType as CalloutAdmonitionType; + // Get the custom title from the first line + customTitle = firstLine.split(' ').slice(1).join(' ').trim(); + // Remove the first child from the children + o.node.children = o.node.children.slice(1); + } + } + } + + // Add an admonition symbol paragraph to the start of the children + const admonitionSymbol = + `${ADMONITION_SYMBOL} ${type} ${customTitle}`.trim(); + walkerContext + .openNode({ + type: 'paragraph', + children: [ + { + type: 'text', + value: admonitionSymbol, + }, + ], + }) + .closeNode(); + + // Add the rest of the text to the children content + if (restOfText) { + walkerContext + .openNode({ + type: 'paragraph', + children: [{ type: 'text', value: `${restOfText}` }], + }) + .closeNode(); + } + } else { + walkerContext + .openNode( + { + type: 'blockquote', + children: [], + }, + 'children' + ) + .openNode({ + type: 'paragraph', + children: [ + { + type: 'text', + value: `[!${emoji}]`, + }, + ], + }) + .closeNode(); + } + }, + leave: (_, context) => { + const { walkerContext, configs } = context; + const exportOptions = getCalloutExportOptions(configs); + const { style } = exportOptions; + // If the style is admonitions, we should add an admonition symbol paragraph to the end of the children + if (style === CalloutExportStyle.Admonitions) { + walkerContext + .openNode({ + type: 'paragraph', + children: [ + { + type: 'text', + value: ADMONITION_SYMBOL, + }, + ], + }) + .closeNode(); + } else { + // If the style is gfm, we should close the outer blockquote node + walkerContext.closeNode(); + } + }, + }, +}; + +export const CalloutBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(calloutBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/blocks/callout/src/callout-block-styles.ts b/blocksuite/affine/blocks/callout/src/callout-block-styles.ts new file mode 100644 index 00000000..d91eb467 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/callout-block-styles.ts @@ -0,0 +1,56 @@ +import { css } from '@emotion/css'; + +export const calloutHostStyles = css({ + display: 'block', + margin: '8px 0', +}); + +export const calloutBlockContainerStyles = css({ + display: 'flex', + alignItems: 'flex-start', + padding: '5px 10px', + borderRadius: '8px', +}); + +export const calloutEmojiContainerStyles = css({ + userSelect: 'none', + fontSize: '1.2em', + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + // marginTop is dynamically set by JavaScript based on first child's height + marginBottom: '10px', + flexShrink: 0, + position: 'relative', +}); + +export const calloutEmojiStyles = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ':hover': { + cursor: 'pointer', + opacity: 0.7, + }, +}); + +export const calloutChildrenStyles = css({ + flex: 1, + minWidth: 0, + paddingLeft: '10px', +}); + +export const iconPickerContainerStyles = css({ + position: 'absolute', + top: '100%', + left: 0, + zIndex: 1000, + background: 'white', + border: '1px solid #ccc', + borderRadius: '8px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + width: '390px', + height: '400px', +}); diff --git a/blocksuite/affine/blocks/callout/src/callout-block.ts b/blocksuite/affine/blocks/callout/src/callout-block.ts new file mode 100644 index 00000000..76f3415f --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/callout-block.ts @@ -0,0 +1,263 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + createPopup, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset'; +import { + type CalloutBlockModel, + type ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; +import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; +import { + DocModeProvider, + type IconData, + IconPickerServiceIdentifier, + IconType, +} from '@blocksuite/affine-shared/services'; +import type { UniComponent } from '@blocksuite/affine-shared/types'; +import * as icons from '@blocksuite/icons/lit'; +import type { BlockComponent } from '@blocksuite/std'; +import { type Signal } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { + calloutBlockContainerStyles, + calloutChildrenStyles, + calloutEmojiContainerStyles, + calloutEmojiStyles, + calloutHostStyles, +} from './callout-block-styles.js'; +import { IconPickerWrapper } from './icon-picker-wrapper.js'; +// Copy of renderUniLit and UniLit from affine-data-view +export const renderUniLit = >( + uni: UniComponent | undefined, + props?: Props, + options?: { + ref?: Signal; + style?: Readonly; + class?: string; + } +): TemplateResult => { + return html` `; +}; +const getIcon = (icon?: IconData) => { + if (!icon) { + return null; + } + if (icon.type === IconType.Emoji) { + return icon.unicode; + } + if (icon.type === IconType.AffineIcon) { + return ( + icons as Record TemplateResult> + )[`${icon.name}Icon`]?.({ style: `color:${icon.color}` }); + } + return null; +}; +export class CalloutBlockComponent extends CaptionedBlockComponent { + private _popupCloseHandler: (() => void) | null = null; + + override connectedCallback() { + super.connectedCallback(); + this.classList.add(calloutHostStyles); + } + + private _getEmojiMarginTop(): string { + if (this.model.children.length === 0) { + return '10px'; + } + + const firstChild = this.model.children[0]; + const flavour = firstChild.flavour; + + const marginTopMap: Record = { + 'affine:paragraph:h1': '23px', + 'affine:paragraph:h2': '20px', + 'affine:paragraph:h3': '16px', + 'affine:paragraph:h4': '15px', + 'affine:paragraph:h5': '14px', + 'affine:paragraph:h6': '13px', + }; + + // For heading blocks, use the type to determine margin + if (flavour === 'affine:paragraph') { + const paragraph = firstChild as ParagraphBlockModel; + const type = paragraph.props.type$.value; + const key = `${flavour}:${type}`; + return marginTopMap[key] || '10px'; + } + + // Default for all other block types + return '10px'; + } + + private _closeIconPicker() { + if (this._popupCloseHandler) { + this._popupCloseHandler(); + this._popupCloseHandler = null; + } + } + + private _toggleIconPicker(event: MouseEvent) { + // If popup is already open, close it + if (this._popupCloseHandler) { + this._closeIconPicker(); + return; + } + + // Get IconPickerService from the framework + const iconPickerService = this.std.getOptional(IconPickerServiceIdentifier); + if (!iconPickerService) { + console.warn('IconPickerService not found'); + return; + } + + // Get the uni-component from the service + const iconPickerComponent = iconPickerService.iconPickerComponent; + + // Create props for the icon picker + const props = { + onSelect: (iconData?: IconData) => { + this.model.props.icon$.value = iconData; + this._closeIconPicker(); // Close the picker after selection + }, + onClose: () => { + this._closeIconPicker(); + }, + }; + + // Create IconPickerWrapper instance + const wrapper = new IconPickerWrapper(); + wrapper.iconPickerComponent = iconPickerComponent; + wrapper.props = props; + wrapper.style.position = 'absolute'; + wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel; + wrapper.style.boxShadow = 'var(--affine-menu-shadow)'; + wrapper.style.borderRadius = '8px'; + + // Create popup target from the clicked element + const target = popupTargetFromElement(event.currentTarget as HTMLElement); + + // Create popup + this._popupCloseHandler = createPopup(target, wrapper, { + onClose: () => { + this._popupCloseHandler = null; + }, + }); + } + + private readonly _handleBlockClick = (event: MouseEvent) => { + // Check if the click target is emoji related element + const target = event.target as HTMLElement; + if ( + target.closest('.affine-callout-emoji-container') || + target.classList.contains('affine-callout-emoji') + ) { + return; + } + + // If there's no icon, open icon picker on click + const icon = this.model.props.icon$.value; + if (!icon) { + this._toggleIconPicker(event); + return; + } + + // Only handle clicks when there are no children + if (this.model.children.length > 0) { + return; + } + + // Prevent event bubbling + event.stopPropagation(); + + // Create a new paragraph block + const paragraphId = this.store.addBlock('affine:paragraph', {}, this.model); + + // Focus the new paragraph + focusTextModel(this.std, paragraphId); + }; + + get attributeRenderer() { + return this.inlineManager.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager.getSchema(); + } + + get embedChecker() { + return this.inlineManager.embedChecker; + } + + get inlineManager() { + return this.std.get(DefaultInlineManagerExtension.identifier); + } + + override get topContenteditableElement() { + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return this.closest( + EDGELESS_TOP_CONTENTEDITABLE_SELECTOR + ); + } + return this.rootComponent; + } + + override renderBlock() { + const icon = this.model.props.icon$.value; + const backgroundColorName = this.model.props.backgroundColorName$.value; + const normalizedBackgroundName = + backgroundColorName === 'default' || backgroundColorName === '' + ? 'grey' + : backgroundColorName; + const backgroundColor = ( + cssVarV2.block.callout.background as Record + )[normalizedBackgroundName ?? 'grey']; + + const iconContent = getIcon(icon); + + return html` +
+ ${ + iconContent + ? html` +
+ ${iconContent} +
+ ` + : '' + } +
+ ${this.renderChildren(this.model)} +
+
+ `; + } +} diff --git a/blocksuite/affine/blocks/callout/src/callout-keymap.ts b/blocksuite/affine/blocks/callout/src/callout-keymap.ts new file mode 100644 index 00000000..9e7c84fe --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/callout-keymap.ts @@ -0,0 +1,86 @@ +import { + CalloutBlockModel, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { matchModels } from '@blocksuite/affine-shared/utils'; +import { + BlockSelection, + KeymapExtension, + TextSelection, +} from '@blocksuite/std'; + +import { calloutToParagraphCommand } from './commands/callout-to-paragraph.js'; +import { splitCalloutCommand } from './commands/split-callout.js'; + +export const CalloutKeymapExtension = KeymapExtension(std => { + return { + Enter: ctx => { + const text = std.selection.find(TextSelection); + if (!text) return false; + + const currentBlock = std.store.getBlock(text.from.blockId); + if (!currentBlock) return false; + + // Check if current block is a callout block + let calloutBlock = currentBlock; + if (!matchModels(currentBlock.model, [CalloutBlockModel])) { + // If not, check if the parent is a callout block + const parent = std.store.getParent(currentBlock.model); + if (!parent || !matchModels(parent, [CalloutBlockModel])) { + return false; + } + const parentBlock = std.store.getBlock(parent.id); + if (!parentBlock) return false; + calloutBlock = parentBlock; + } + + ctx.get('keyboardState').raw.preventDefault(); + std.command + .chain() + .pipe(splitCalloutCommand, { + blockId: calloutBlock.model.id, + inlineIndex: text.from.index, + currentBlockId: text.from.blockId, + }) + .run(); + return true; + }, + Backspace: ctx => { + const text = std.selection.find(TextSelection); + if (text && text.isCollapsed() && text.from.index === 0) { + const event = ctx.get('defaultState').event; + + const block = std.store.getBlock(text.from.blockId); + if (!block) return false; + const parent = std.store.getParent(block.model); + if (!parent) return false; + if (!matchModels(parent, [CalloutBlockModel])) return false; + + // Check if current block is a paragraph inside callout + if (matchModels(block.model, [ParagraphBlockModel])) { + event.preventDefault(); + + std.command + .chain() + .pipe(calloutToParagraphCommand, { + id: block.model.id, + }) + .run(); + + return true; + } + + // Fallback to selecting the callout block + event.preventDefault(); + std.selection.setGroup('note', [ + std.selection.create(BlockSelection, { + blockId: parent.id, + }), + ]); + + return true; + } + return false; + }, + }; +}); diff --git a/blocksuite/affine/blocks/callout/src/commands/callout-to-paragraph.ts b/blocksuite/affine/blocks/callout/src/commands/callout-to-paragraph.ts new file mode 100644 index 00000000..2c5bde99 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/commands/callout-to-paragraph.ts @@ -0,0 +1,86 @@ +import { + CalloutBlockModel, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; +import { matchModels } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/std'; +import { BlockSelection } from '@blocksuite/std'; +import { Text } from '@blocksuite/store'; + +export const calloutToParagraphCommand: Command< + { + id: string; + stopCapturing?: boolean; + }, + { + success: boolean; + } +> = (ctx, next) => { + const { id, stopCapturing = true } = ctx; + const std = ctx.std; + const doc = std.store; + const model = doc.getBlock(id)?.model; + + if (!model || !matchModels(model, [ParagraphBlockModel])) return false; + + const parent = doc.getParent(model); + if (!parent || !matchModels(parent, [CalloutBlockModel])) return false; + + if (stopCapturing) std.store.captureSync(); + + // Get current block index in callout + const currentIndex = parent.children.indexOf(model); + const hasText = model.text && model.text.length > 0; + + // Find previous paragraph block in callout + let previousBlock = null; + for (let i = currentIndex - 1; i >= 0; i--) { + const sibling = parent.children[i]; + if (matchModels(sibling, [ParagraphBlockModel])) { + previousBlock = sibling; + break; + } + } + + if (previousBlock && hasText) { + // Clone current text content before any operations to prevent data loss + const currentText = model.text || new Text(); + + // Get previous block text and merge index + const previousText = previousBlock.text || new Text(); + const mergeIndex = previousText.length; + + // Apply each delta from cloned current text to previous block to preserve formatting + previousText.join(currentText); + + // Remove current block after text has been merged + doc.deleteBlock(model, { + deleteChildren: false, + }); + + // Focus at merge point in previous block + focusTextModel(std, previousBlock.id, mergeIndex); + } else if (previousBlock && !hasText) { + // Move cursor to end of previous block + doc.deleteBlock(model, { + deleteChildren: false, + }); + + const previousText = previousBlock.text || new Text(); + focusTextModel(std, previousBlock.id, previousText.length); + } else { + // No previous block, select the entire callout + doc.deleteBlock(model, { + deleteChildren: false, + }); + + std.selection.setGroup('note', [ + std.selection.create(BlockSelection, { + blockId: parent.id, + }), + ]); + } + + return next({ success: true }); +}; diff --git a/blocksuite/affine/blocks/callout/src/commands/split-callout.ts b/blocksuite/affine/blocks/callout/src/commands/split-callout.ts new file mode 100644 index 00000000..f60d32f0 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/commands/split-callout.ts @@ -0,0 +1,85 @@ +import { + CalloutBlockModel, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; +import { matchModels } from '@blocksuite/affine-shared/utils'; +import type { Command, EditorHost } from '@blocksuite/std'; + +export const splitCalloutCommand: Command<{ + blockId: string; + inlineIndex: number; + currentBlockId: string; +}> = (ctx, next) => { + const { blockId, inlineIndex, currentBlockId, std } = ctx; + const host = std.host as EditorHost; + const doc = host.store; + + const calloutModel = doc.getBlock(blockId)?.model; + if (!calloutModel || !matchModels(calloutModel, [CalloutBlockModel])) { + console.error(`block ${blockId} is not a callout block`); + return; + } + + const currentModel = doc.getBlock(currentBlockId)?.model; + if (!currentModel) { + console.error(`current block ${currentBlockId} not found`); + return; + } + + doc.captureSync(); + + if (matchModels(currentModel, [ParagraphBlockModel])) { + // User is in a paragraph within the callout's children + const afterText = currentModel.props.text.split(inlineIndex); + + // Update the current paragraph's text to keep only the part before cursor + doc.transact(() => { + currentModel.props.text.delete( + inlineIndex, + currentModel.props.text.length - inlineIndex + ); + }); + + // Create a new paragraph block after the current one + const parent = doc.getParent(currentModel); + if (parent) { + const currentIndex = parent.children.indexOf(currentModel); + const newParagraphId = doc.addBlock( + 'affine:paragraph', + { + text: afterText, + }, + parent, + currentIndex + 1 + ); + + if (newParagraphId) { + host.updateComplete + .then(() => { + focusTextModel(std, newParagraphId); + }) + .catch(console.error); + } + } + } else { + // If current block is not a paragraph, create a new paragraph in callout + const newParagraphId = doc.addBlock( + 'affine:paragraph', + { + text: new Text(), + }, + calloutModel + ); + + if (newParagraphId) { + host.updateComplete + .then(() => { + focusTextModel(std, newParagraphId); + }) + .catch(console.error); + } + } + + next(); +}; diff --git a/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts b/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts new file mode 100644 index 00000000..567ee390 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/configs/slash-menu.ts @@ -0,0 +1,55 @@ +import { focusBlockEnd } from '@blocksuite/affine-shared/commands'; +import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils'; +import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu'; +import { FontIcon } from '@blocksuite/icons/lit'; + +import { calloutTooltip } from './tooltips'; + +export const calloutSlashMenuConfig: SlashMenuConfig = { + items: [ + { + name: 'Callout', + description: 'Let your words stand out.', + icon: FontIcon(), + tooltip: { + figure: calloutTooltip, + caption: 'Callout', + }, + searchAlias: ['callout'], + group: '0_Basic@9', + when: ({ model }) => { + return !isInsideBlockByFlavour( + model.store, + model, + 'affine:edgeless-text' + ); + }, + action: ({ model, std }) => { + const { store } = model; + const parent = store.getParent(model); + if (!parent) return; + + const index = parent.children.indexOf(model); + if (index === -1) return; + const calloutId = store.addBlock( + 'affine:callout', + {}, + parent, + index + 1 + ); + if (!calloutId) return; + const paragraphId = store.addBlock('affine:paragraph', {}, calloutId); + if (!paragraphId) return; + std.host.updateComplete + .then(() => { + const paragraph = std.view.getBlock(paragraphId); + if (!paragraph) return; + std.command.exec(focusBlockEnd, { + focusBlock: paragraph, + }); + }) + .catch(console.error); + }, + }, + ], +}; diff --git a/blocksuite/affine/blocks/callout/src/configs/toolbar.ts b/blocksuite/affine/blocks/callout/src/configs/toolbar.ts new file mode 100644 index 00000000..d021da8f --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/configs/toolbar.ts @@ -0,0 +1,204 @@ +import { + createPopup, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { EditorChevronDown } from '@blocksuite/affine-components/toolbar'; +import { CalloutBlockModel } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type IconData, + IconPickerServiceIdentifier, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, + ToolbarModuleExtension, +} from '@blocksuite/affine-shared/services'; +import { DeleteIcon, PaletteIcon, SmileIcon } from '@blocksuite/icons/lit'; +import { BlockFlavourIdentifier } from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { IconPickerWrapper } from '../icon-picker-wrapper.js'; + +const colors = [ + 'default', + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', +] as const; + +const backgroundColorAction = { + id: 'background-color', + label: 'Background Color', + tooltip: 'Change background color', + icon: PaletteIcon(), + run() { + // This will be handled by the content function + }, + content(ctx) { + const model = ctx.getCurrentModelByType(CalloutBlockModel); + if (!model) return null; + + const updateBackground = (color: string) => { + ctx.store.updateBlock(model, { backgroundColorName: color }); + }; + + return html` + + ${PaletteIcon()} ${EditorChevronDown} + + `} + > +
+
Background
+ ${repeat(colors, color => { + const isDefault = color === 'default'; + const value = isDefault + ? cssVarV2.block.callout.background.grey + : `var(--affine-text-highlight-${color})`; + const displayName = `${color} Background`; + + return html` + updateBackground(isDefault ? 'grey' : color)} + > + + ${displayName} + + `; + })} +
+
+ `; + }, +} satisfies ToolbarAction; + +const iconPickerAction = { + id: 'icon-picker', + label: 'Icon Picker', + tooltip: 'Change icon', + icon: SmileIcon(), + run() { + // This will be handled by the content function + }, + content(ctx) { + const model = ctx.getCurrentModelByType(CalloutBlockModel); + if (!model) return null; + + const handleIconPickerClick = (event: MouseEvent) => { + // Get IconPickerService from the framework + const iconPickerService = ctx.std.getOptional( + IconPickerServiceIdentifier + ); + if (!iconPickerService) { + console.warn('IconPickerService not found'); + return; + } + + // Get the uni-component from the service + const iconPickerComponent = iconPickerService.iconPickerComponent; + + // Create props for the icon picker + const props = { + onSelect: (iconData?: IconData) => { + // When iconData is undefined (delete icon), set icon to undefined + ctx.store.updateBlock(model, { icon: iconData }); + closeHandler(); // Close the picker after selection + }, + onClose: () => { + closeHandler(); + }, + }; + + // Create IconPickerWrapper instance + const wrapper = new IconPickerWrapper(); + wrapper.iconPickerComponent = iconPickerComponent; + wrapper.props = props; + wrapper.style.position = 'absolute'; + wrapper.style.backgroundColor = cssVarV2.layer.background.overlayPanel; + wrapper.style.boxShadow = 'var(--affine-menu-shadow)'; + wrapper.style.borderRadius = '8px'; + + // Create popup target from the clicked element + const target = popupTargetFromElement(event.currentTarget as HTMLElement); + + // Create popup + const closeHandler = createPopup(target, wrapper, { + onClose: () => { + // Cleanup if needed + }, + }); + }; + + return html` + + ${SmileIcon()} ${EditorChevronDown} + + `; + }, +} satisfies ToolbarAction; + +const builtinToolbarConfig = { + actions: [ + { + id: 'style', + actions: [backgroundColorAction], + } satisfies ToolbarActionGroup, + { + id: 'icon', + actions: [iconPickerAction], + } satisfies ToolbarActionGroup, + { + placement: ActionPlacement.More, + id: 'c.delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(ctx) { + const model = ctx.getCurrentModelByType(CalloutBlockModel); + if (!model) return; + + ctx.store.deleteBlock(model); + + // Clears + ctx.select('note'); + ctx.reset(); + }, + } satisfies ToolbarAction, + ], +} as const satisfies ToolbarModuleConfig; + +export const createBuiltinToolbarConfigExtension = ( + flavour: string +): ExtensionType[] => { + return [ + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), + ]; +}; diff --git a/blocksuite/affine/blocks/callout/src/configs/tooltips.ts b/blocksuite/affine/blocks/callout/src/configs/tooltips.ts new file mode 100644 index 00000000..3a1449f7 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/configs/tooltips.ts @@ -0,0 +1,28 @@ +import { html } from 'lit'; + +// prettier-ignore +export const calloutTooltip = html` + + +Highlight important notes. + + + +👋 + + +A storyboard is a visual representation of a story, typically presented in the form of sequential panels. Each panel features illustrations or images accompanied by notes describing key scenes or shots, including details of action and dialogue. + + + + + + + + + + + + + +`; diff --git a/blocksuite/affine/blocks/callout/src/effects.ts b/blocksuite/affine/blocks/callout/src/effects.ts new file mode 100644 index 00000000..92361914 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/effects.ts @@ -0,0 +1,14 @@ +import { CalloutBlockComponent } from './callout-block'; +import { IconPickerWrapper } from './icon-picker-wrapper'; + +export function effects() { + customElements.define('affine-callout', CalloutBlockComponent); + customElements.define('icon-picker-wrapper', IconPickerWrapper); +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-callout': CalloutBlockComponent; + 'icon-picker-wrapper': IconPickerWrapper; + } +} diff --git a/blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts b/blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts new file mode 100644 index 00000000..8132fb12 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/icon-picker-wrapper.ts @@ -0,0 +1,52 @@ +import type { IconData } from '@blocksuite/affine-shared/services'; +import type { UniComponent } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/std'; +import { type Signal } from '@preact/signals-core'; +import { html, type TemplateResult } from 'lit'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +// Copy of renderUniLit from callout-block.ts +const renderUniLit = >( + uni: UniComponent | undefined, + props?: Props, + options?: { + ref?: Signal; + style?: Readonly; + class?: string; + } +): TemplateResult => { + return html` `; +}; + +export interface IconPickerWrapperProps { + onSelect?: (iconData?: IconData) => void; + onClose?: () => void; +} + +export class IconPickerWrapper extends ShadowlessElement { + iconPickerComponent?: UniComponent; + props?: IconPickerWrapperProps; + + constructor() { + super(); + } + + override render() { + if (!this.iconPickerComponent) { + return html``; + } + + return renderUniLit(this.iconPickerComponent, this.props); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'icon-picker-wrapper': IconPickerWrapper; + } +} diff --git a/blocksuite/affine/blocks/callout/src/index.ts b/blocksuite/affine/blocks/callout/src/index.ts new file mode 100644 index 00000000..1c5312ee --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/index.ts @@ -0,0 +1,2 @@ +export * from './callout-block.js'; +export * from './effects.js'; diff --git a/blocksuite/affine/blocks/callout/src/store.ts b/blocksuite/affine/blocks/callout/src/store.ts new file mode 100644 index 00000000..7f65c54d --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/store.ts @@ -0,0 +1,17 @@ +import { + type StoreExtensionContext, + StoreExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { CalloutBlockSchemaExtension } from '@blocksuite/affine-model'; + +import { CalloutBlockMarkdownAdapterExtension } from './adapters/markdown'; + +export class CalloutStoreExtension extends StoreExtensionProvider { + override name = 'affine-callout-block'; + + override setup(context: StoreExtensionContext) { + super.setup(context); + context.register(CalloutBlockSchemaExtension); + context.register(CalloutBlockMarkdownAdapterExtension); + } +} diff --git a/blocksuite/affine/blocks/callout/src/view.ts b/blocksuite/affine/blocks/callout/src/view.ts new file mode 100644 index 00000000..07e47553 --- /dev/null +++ b/blocksuite/affine/blocks/callout/src/view.ts @@ -0,0 +1,32 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; +import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; +import { literal } from 'lit/static-html.js'; + +import { CalloutKeymapExtension } from './callout-keymap'; +import { calloutSlashMenuConfig } from './configs/slash-menu'; +import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { effects } from './effects'; + +export class CalloutViewExtension extends ViewExtensionProvider { + override name = 'affine-callout-block'; + + override effect() { + super.effect(); + effects(); + } + + override setup(context: ViewExtensionContext) { + super.setup(context); + context.register([ + FlavourExtension('affine:callout'), + BlockViewExtension('affine:callout', literal`affine-callout`), + CalloutKeymapExtension, + SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig), + ...createBuiltinToolbarConfigExtension('affine:callout'), + ]); + } +} diff --git a/blocksuite/affine/blocks/callout/tsconfig.json b/blocksuite/affine/blocks/callout/tsconfig.json new file mode 100644 index 00000000..7594de6a --- /dev/null +++ b/blocksuite/affine/blocks/callout/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [ + { "path": "../../components" }, + { "path": "../../ext-loader" }, + { "path": "../../inlines/preset" }, + { "path": "../../model" }, + { "path": "../../rich-text" }, + { "path": "../../shared" }, + { "path": "../../widgets/slash-menu" }, + { "path": "../../../framework/std" }, + { "path": "../../../framework/store" } + ] +} diff --git a/blocksuite/affine/blocks/code/package.json b/blocksuite/affine/blocks/code/package.json new file mode 100644 index 00000000..38473dda --- /dev/null +++ b/blocksuite/affine/blocks/code/package.json @@ -0,0 +1,48 @@ +{ + "name": "@blocksuite/affine-block-code", + "description": "Code block for BlockSuite.", + "type": "module", + "scripts": { + "build": "yarn run -T tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-gfx-turbo-renderer": "workspace:*", + "@blocksuite/affine-inline-comment": "workspace:*", + "@blocksuite/affine-inline-latex": "workspace:*", + "@blocksuite/affine-inline-link": "workspace:*", + "@blocksuite/affine-inline-preset": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-rich-text": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.2.17", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.13", + "@preact/signals-core": "^1.8.0", + "@types/mdast": "^4.0.4", + "lit": "^3.2.0", + "shiki": "^3.19.0", + "zod": "^3.25.76" + }, + "exports": { + ".": "./src/index.ts", + "./turbo-painter": "./src/turbo/code-painter.worker.ts", + "./view": "./src/view.ts", + "./store": "./src/store.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5" +} diff --git a/blocksuite/affine/blocks/code/src/adapters/extension.ts b/blocksuite/affine/blocks/code/src/adapters/extension.ts new file mode 100644 index 00000000..b48c0475 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/extension.ts @@ -0,0 +1,13 @@ +import type { ExtensionType } from '@blocksuite/store'; + +import { CodeBlockHtmlAdapterExtension } from './html.js'; +import { CodeBlockMarkdownAdapterExtensions } from './markdown/index.js'; +import { CodeBlockNotionHtmlAdapterExtension } from './notion-html.js'; +import { CodeBlockPlainTextAdapterExtension } from './plain-text.js'; + +export const CodeBlockAdapterExtensions: ExtensionType[] = [ + CodeBlockHtmlAdapterExtension, + CodeBlockMarkdownAdapterExtensions, + CodeBlockPlainTextAdapterExtension, + CodeBlockNotionHtmlAdapterExtension, +].flat(); diff --git a/blocksuite/affine/blocks/code/src/adapters/html.ts b/blocksuite/affine/blocks/code/src/adapters/html.ts new file mode 100644 index 00000000..19b0075b --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/html.ts @@ -0,0 +1,109 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + CODE_BLOCK_WRAP_KEY, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/store'; +import { nanoid } from '@blocksuite/store'; + +export const codeBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: CodeBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre', + fromMatch: o => o.node.flavour === 'affine:code', + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const code = HastUtils.querySelector(o.node, 'code'); + if (!code) { + return; + } + + const codeText = + code.children.length === 1 && code.children[0].type === 'text' + ? code.children[0] + : { ...code, tagName: 'div' }; + let codeLang = Array.isArray(code.properties?.className) + ? code.properties.className.find( + className => + typeof className === 'string' && className.startsWith('code-') + ) + : undefined; + codeLang = + typeof codeLang === 'string' + ? codeLang.replace('code-', '') + : undefined; + + const { walkerContext, deltaConverter, configs } = context; + const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true'; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:code', + props: { + language: codeLang ?? 'Plain Text', + wrap, + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(codeText, { + trim: false, + pre: true, + }), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: { + enter: async (o, context) => { + const { walkerContext } = context; + const rawLang = o.node.props.language as string | null; + const text = (o.node.props.text as Record) + .delta as DeltaInsert[]; + const code = text.map(delta => delta.insert).join(''); + + walkerContext + .openNode( + { + type: 'element', + tagName: 'pre', + properties: {}, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'code', + properties: { + className: [`code-${rawLang ?? 'text'}`], + }, + children: [ + { + type: 'text', + value: code, + }, + ], + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, +}; + +export const CodeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + codeBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/blocks/code/src/adapters/index.ts b/blocksuite/affine/blocks/code/src/adapters/index.ts new file mode 100644 index 00000000..d9193659 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown/index.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/blocks/code/src/adapters/markdown/index.ts b/blocksuite/affine/blocks/code/src/adapters/markdown/index.ts new file mode 100644 index 00000000..b6570aa0 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/markdown/index.ts @@ -0,0 +1,12 @@ +import type { ExtensionType } from '@blocksuite/store'; + +import { CodeBlockMarkdownAdapterExtension } from './markdown.js'; +import { CodeMarkdownPreprocessorExtension } from './preprocessor.js'; + +export * from './markdown.js'; +export * from './preprocessor.js'; + +export const CodeBlockMarkdownAdapterExtensions: ExtensionType[] = [ + CodeMarkdownPreprocessorExtension, + CodeBlockMarkdownAdapterExtension, +]; diff --git a/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts b/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts new file mode 100644 index 00000000..d24e58b9 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/markdown/markdown.ts @@ -0,0 +1,99 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + CODE_BLOCK_WRAP_KEY, + IN_PARAGRAPH_NODE_CONTEXT_KEY, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/store'; +import { nanoid } from '@blocksuite/store'; +import type { Code, Html } from 'mdast'; + +const isCodeNode = (node: MarkdownAST): node is Code => node.type === 'code'; +const isHtmlNode = (node: MarkdownAST): node is Html => node.type === 'html'; + +const isCodeOrHtmlNode = (node: MarkdownAST): node is Code | Html => + isCodeNode(node) || isHtmlNode(node); + +export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: CodeBlockSchema.model.flavour, + toMatch: o => isCodeOrHtmlNode(o.node), + fromMatch: o => o.node.flavour === 'affine:code', + toBlockSnapshot: { + enter: (o, context) => { + if (!isCodeOrHtmlNode(o.node)) { + return; + } + + const { walkerContext, configs } = context; + const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true'; + let language = 'plain text'; + switch (o.node.type) { + case 'code': { + if (o.node.lang) { + language = o.node.lang; + } + break; + } + case 'html': { + const inParagraphNode = !!walkerContext.getGlobalContext( + IN_PARAGRAPH_NODE_CONTEXT_KEY + ); + // only handle top level html node + if (inParagraphNode) { + return; + } + language = 'html'; + break; + } + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:code', + props: { + language, + wrap, + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: o.node.value, + }, + ], + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'code', + lang: (o.node.props.language as string) ?? null, + meta: null, + value: text.delta.map(delta => delta.insert).join(''), + }, + 'children' + ) + .closeNode(); + }, + }, +}; + +export const CodeBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + codeBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/affine/blocks/code/src/adapters/markdown/preprocessor.ts b/blocksuite/affine/blocks/code/src/adapters/markdown/preprocessor.ts new file mode 100644 index 00000000..47564f9a --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/markdown/preprocessor.ts @@ -0,0 +1,76 @@ +import { + type MarkdownAdapterPreprocessor, + MarkdownPreprocessorExtension, +} from '@blocksuite/affine-shared/adapters'; +import { isValidUrl } from '@blocksuite/affine-shared/utils'; + +const codePreprocessor: MarkdownAdapterPreprocessor = { + name: 'code', + levels: ['slice'], + preprocess: content => { + let codeFence = ''; + const lines = content + .split('\n') + .map(line => { + if (line.trimStart().startsWith('-')) { + return line; + } + let trimmedLine = line.trimStart(); + if (!codeFence && trimmedLine.startsWith('```')) { + codeFence = trimmedLine.substring( + 0, + trimmedLine.lastIndexOf('```') + 3 + ); + if (codeFence.split('').every(c => c === '`')) { + return line; + } + codeFence = ''; + } + if (!codeFence && trimmedLine.startsWith('~~~')) { + codeFence = trimmedLine.substring( + 0, + trimmedLine.lastIndexOf('~~~') + 3 + ); + if (codeFence.split('').every(c => c === '~')) { + return line; + } + codeFence = ''; + } + if ( + !!codeFence && + trimmedLine.startsWith(codeFence) && + trimmedLine.lastIndexOf(codeFence) === 0 + ) { + codeFence = ''; + } + if (codeFence) { + return line; + } + + trimmedLine = trimmedLine.trimEnd(); + if ( + !trimmedLine.startsWith('<') && + !trimmedLine.endsWith('>') && + !trimmedLine.includes(' ') + ) { + // check if it is a url link and wrap it with the angle brackets + // sometimes the url includes emphasis `_` that will break URL parsing + // + // eg. /MuawcBMT1Mzvoar09-_66?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_ + // https://www.markdownguide.org/basic-syntax/#urls-and-email-addresses + const valid = isValidUrl(trimmedLine); + if (valid) { + return `<${trimmedLine}>`; + } + } + + return line.replace(/^ /, ' '); + }) + .join('\n'); + + return lines; + }, +}; + +export const CodeMarkdownPreprocessorExtension = + MarkdownPreprocessorExtension(codePreprocessor); diff --git a/blocksuite/affine/blocks/code/src/adapters/notion-html.ts b/blocksuite/affine/blocks/code/src/adapters/notion-html.ts new file mode 100644 index 00000000..c528e05c --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/notion-html.ts @@ -0,0 +1,59 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + CODE_BLOCK_WRAP_KEY, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const codeBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: CodeBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre', + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const code = HastUtils.querySelector(o.node, 'code'); + if (!code) { + return; + } + const { walkerContext, deltaConverter, configs } = context; + const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true'; + const codeText = + code.children.length === 1 && code.children[0].type === 'text' + ? code.children[0] + : { ...code, tag: 'div' }; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: CodeBlockSchema.model.flavour, + props: { + language: 'Plain Text', + wrap, + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(codeText, { + trim: false, + pre: true, + }), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: {}, + }; + +export const CodeBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(codeBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/affine/blocks/code/src/adapters/plain-text.ts b/blocksuite/affine/blocks/code/src/adapters/plain-text.ts new file mode 100644 index 00000000..75774abd --- /dev/null +++ b/blocksuite/affine/blocks/code/src/adapters/plain-text.ts @@ -0,0 +1,26 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/store'; + +export const codeBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = { + flavour: CodeBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === CodeBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const buffer = text.delta.map(delta => delta.insert).join(''); + context.textBuffer.content += buffer; + context.textBuffer.content += '\n'; + }, + }, +}; + +export const CodeBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(codeBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/blocks/code/src/clipboard/index.ts b/blocksuite/affine/blocks/code/src/clipboard/index.ts new file mode 100644 index 00000000..07bef157 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/clipboard/index.ts @@ -0,0 +1,199 @@ +import { deleteTextCommand } from '@blocksuite/affine-inline-preset'; +import type { RichText } from '@blocksuite/affine-rich-text'; +import { + HtmlAdapter, + pasteMiddleware, + PlainTextAdapter, +} from '@blocksuite/affine-shared/adapters'; +import { + getBlockIndexCommand, + getBlockSelectionsCommand, + getTextSelectionCommand, +} from '@blocksuite/affine-shared/commands'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { DisposableGroup } from '@blocksuite/global/disposable'; +import { + type BlockStdScope, + Clipboard, + type ClipboardAdapterConfig, + LifeCycleWatcher, + LifeCycleWatcherIdentifier, + StdIdentifier, + TextSelection, + type UIEventHandler, +} from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; + +export const CodeClipboardAdapterConfigIdentifier = + createIdentifier('code-clipboard-adapter-config'); + +export function CodeClipboardAdapterConfigExtension( + config: ClipboardAdapterConfig +): ExtensionType { + return { + setup: di => { + di.addImpl( + CodeClipboardAdapterConfigIdentifier(config.mimeType), + () => config + ); + }, + }; +} + +const PlainTextClipboardConfig = CodeClipboardAdapterConfigExtension({ + mimeType: 'text/plain', + adapter: PlainTextAdapter, + priority: 90, +}); + +const HtmlClipboardConfig = CodeClipboardAdapterConfigExtension({ + mimeType: 'text/html', + adapter: HtmlAdapter, + priority: 80, +}); + +export class CodeBlockClipboard extends Clipboard { + static override readonly key = 'code-block-clipboard'; + + override get _adapters() { + const adapterConfigs = this.std.provider.getAll( + CodeClipboardAdapterConfigIdentifier + ); + return Array.from(adapterConfigs.values()); + } +} + +export class CodeBlockClipboardController extends LifeCycleWatcher { + static override key = 'code-block-clipboard-controller'; + + private readonly _disposables = new DisposableGroup(); + + constructor( + std: BlockStdScope, + readonly clipboard: CodeBlockClipboard + ) { + super(std); + } + + static override setup(di: Container) { + di.add( + this as unknown as { + new ( + std: BlockStdScope, + clipboard: CodeBlockClipboard + ): CodeBlockClipboardController; + }, + [StdIdentifier, CodeBlockClipboard] + ); + di.addImpl(LifeCycleWatcherIdentifier(this.key), provider => + provider.get(this) + ); + } + + protected _init = () => { + const paste = pasteMiddleware(this.std); + this.clipboard.use(paste); + + this._disposables.add({ + dispose: () => { + this.clipboard.unuse(paste); + }, + }); + }; + + onPaste: UIEventHandler = ctx => { + const e = ctx.get('clipboardState').raw; + e.preventDefault(); + + const textSelection = this.std.selection.find(TextSelection); + const plainText = e.clipboardData + ?.getData('text/plain') + ?.replace(/\r?\n|\r/g, '\n'); + const selectedBlockId = textSelection?.from.blockId; + const codeBlock = selectedBlockId + ? this.std.store.getBlock(selectedBlockId)?.model + : null; + if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) { + const richText = this.std.view + .getBlock(selectedBlockId) + ?.querySelector('rich-text'); + const inlineEditor = richText?.inlineEditor; + const inlineRange = inlineEditor?.getInlineRange(); + if (inlineEditor && inlineRange) { + inlineEditor.insertText(inlineRange, plainText); + inlineEditor.setInlineRange({ + index: inlineRange.index + plainText.length, + length: 0, + }); + return true; + } + } + + this.std.store.captureSync(); + this.std.command + .chain() + .try(cmd => [ + cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => { + const textSelection = ctx.currentTextSelection; + if (!textSelection) return; + const end = textSelection.to ?? textSelection.from; + next({ currentSelectionPath: end.blockId }); + }), + cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + if (!currentBlockSelections) return; + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) return; + next({ currentSelectionPath: blockSelection.blockId }); + }), + ]) + .pipe(getBlockIndexCommand) + .try(cmd => [cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand)]) + .pipe((ctx, next) => { + if (!ctx.parentBlock) { + return; + } + this.clipboard + .paste( + e, + this.std.store, + ctx.parentBlock.model.id, + ctx.blockIndex ? ctx.blockIndex + 1 : 1 + ) + .catch(console.error); + + return next(); + }) + .run(); + return true; + }; + + override mounted() { + this._init(); + + // add paste event listener for code block + const subscription = this.std.view.viewUpdated.subscribe( + ({ type, method, view }) => { + if (type !== 'block' || view.model.flavour !== 'affine:code') return; + + if (method === 'add') { + view.handleEvent('paste', this.onPaste); + } + } + ); + this._disposables.add(subscription); + } + + override unmounted() { + this._disposables.dispose(); + } +} + +export function getCodeClipboardExtensions(): ExtensionType[] { + return [ + PlainTextClipboardConfig, + HtmlClipboardConfig, + CodeBlockClipboard, + CodeBlockClipboardController, + ]; +} diff --git a/blocksuite/affine/blocks/code/src/code-block-config.ts b/blocksuite/affine/blocks/code/src/code-block-config.ts new file mode 100644 index 00000000..d6c41bc9 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-block-config.ts @@ -0,0 +1,19 @@ +import { ConfigExtensionFactory } from '@blocksuite/std'; +import type { BundledLanguageInfo, ThemeInput } from 'shiki'; + +export interface CodeBlockConfig { + theme?: { + dark?: ThemeInput; + light?: ThemeInput; + }; + langs?: BundledLanguageInfo[]; + + /** + * Whether to show line numbers in the code block. + * @default true + */ + showLineNumbers?: boolean; +} + +export const CodeBlockConfigExtension = + ConfigExtensionFactory('affine:code'); diff --git a/blocksuite/affine/blocks/code/src/code-block-inline.ts b/blocksuite/affine/blocks/code/src/code-block-inline.ts new file mode 100644 index 00000000..b7805f00 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-block-inline.ts @@ -0,0 +1,50 @@ +import { CommentInlineSpecExtension } from '@blocksuite/affine-inline-comment'; +import { LatexInlineSpecExtension } from '@blocksuite/affine-inline-latex'; +import { LinkInlineSpecExtension } from '@blocksuite/affine-inline-link'; +import { + BackgroundInlineSpecExtension, + BoldInlineSpecExtension, + CodeInlineSpecExtension, + ColorInlineSpecExtension, + ItalicInlineSpecExtension, + StrikeInlineSpecExtension, + UnderlineInlineSpecExtension, +} from '@blocksuite/affine-inline-preset'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + InlineManagerExtension, + InlineSpecExtension, +} from '@blocksuite/std/inline'; +import { html } from 'lit'; +import { z } from 'zod'; + +export const CodeBlockUnitSpecExtension = + InlineSpecExtension({ + name: 'code-block-unit', + schema: z.object({ + 'code-block-uint': z.undefined(), + }), + match: () => true, + renderer: ({ delta }) => { + return html``; + }, + }); + +export const CodeBlockInlineManagerExtension = + InlineManagerExtension({ + id: 'CodeBlockInlineManager', + enableMarkdown: false, + specs: [ + BoldInlineSpecExtension.identifier, + ItalicInlineSpecExtension.identifier, + UnderlineInlineSpecExtension.identifier, + StrikeInlineSpecExtension.identifier, + CodeInlineSpecExtension.identifier, + BackgroundInlineSpecExtension.identifier, + ColorInlineSpecExtension.identifier, + LatexInlineSpecExtension.identifier, + LinkInlineSpecExtension.identifier, + CodeBlockUnitSpecExtension.identifier, + CommentInlineSpecExtension.identifier, + ], + }); diff --git a/blocksuite/affine/blocks/code/src/code-block-service.ts b/blocksuite/affine/blocks/code/src/code-block-service.ts new file mode 100644 index 00000000..d348b14c --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-block-service.ts @@ -0,0 +1,115 @@ +import { ColorScheme } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { LifeCycleWatcher } from '@blocksuite/std'; +import { type Signal, signal } from '@preact/signals-core'; +import { + createHighlighterCore, + createOnigurumaEngine, + type HighlighterCore, + type MaybeGetter, +} from 'shiki'; +import getWasm from 'shiki/wasm'; + +import { CodeBlockConfigExtension } from './code-block-config.js'; +import { + CODE_BLOCK_DEFAULT_DARK_THEME, + CODE_BLOCK_DEFAULT_LIGHT_THEME, +} from './highlight/const.js'; + +export class CodeBlockHighlighter extends LifeCycleWatcher { + static override key = 'code-block-highlighter'; + + // Singleton highlighter instance + private static _sharedHighlighter: HighlighterCore | null = null; + private static _highlighterPromise: Promise | null = null; + private static _refCount = 0; + + private _darkThemeKey: string | undefined; + private _lightThemeKey: string | undefined; + + highlighter$: Signal = signal(null); + + get themeKey() { + const theme = this.std.get(ThemeProvider).theme$.value; + return theme === ColorScheme.Dark + ? this._darkThemeKey + : this._lightThemeKey; + } + + private readonly _loadTheme = async ( + highlighter: HighlighterCore + ): Promise => { + if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) { + return; + } + + const config = this.std.getOptional(CodeBlockConfigExtension.identifier); + const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME; + const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME; + this._darkThemeKey = (await normalizeGetter(darkTheme)).name; + this._lightThemeKey = (await normalizeGetter(lightTheme)).name; + + if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) { + return; + } + + await highlighter.loadTheme(darkTheme, lightTheme); + + if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) { + return; + } + + this.highlighter$.value = highlighter; + }; + + private static async _getOrCreateHighlighter(): Promise { + if (CodeBlockHighlighter._sharedHighlighter) { + return CodeBlockHighlighter._sharedHighlighter; + } + + if (!CodeBlockHighlighter._highlighterPromise) { + CodeBlockHighlighter._highlighterPromise = createHighlighterCore({ + engine: createOnigurumaEngine(() => getWasm), + }).then(highlighter => { + CodeBlockHighlighter._sharedHighlighter = highlighter; + return highlighter; + }); + } + + return CodeBlockHighlighter._highlighterPromise; + } + + override mounted(): void { + super.mounted(); + + CodeBlockHighlighter._refCount++; + + CodeBlockHighlighter._getOrCreateHighlighter() + .then(this._loadTheme) + .catch(console.error); + } + + override unmounted(): void { + CodeBlockHighlighter._refCount = Math.max( + 0, + CodeBlockHighlighter._refCount - 1 + ); + this.highlighter$.value = null; + } + + private static _isHighlighterInUse(highlighter: HighlighterCore) { + return ( + CodeBlockHighlighter._refCount > 0 && + CodeBlockHighlighter._sharedHighlighter === highlighter + ); + } +} + +/** + * https://github.com/shikijs/shiki/blob/933415cdc154fe74ccfb6bbb3eb6a7b7bf183e60/packages/core/src/internal.ts#L31 + */ +export async function normalizeGetter(p: MaybeGetter): Promise { + return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then( + r => r.default || r + ); +} diff --git a/blocksuite/affine/blocks/code/src/code-block.ts b/blocksuite/affine/blocks/code/src/code-block.ts new file mode 100644 index 00000000..ed8be4e4 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-block.ts @@ -0,0 +1,543 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import type { CodeBlockModel } from '@blocksuite/affine-model'; +import { focusTextModel, type RichText } from '@blocksuite/affine-rich-text'; +import { + BRACKET_PAIRS, + EDGELESS_TOP_CONTENTEDITABLE_SELECTOR, +} from '@blocksuite/affine-shared/consts'; +import { + BlockElementCommentManager, + DocModeProvider, + EditorSettingProvider, + NotificationProvider, +} from '@blocksuite/affine-shared/services'; +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import { IS_MAC, IS_MOBILE } from '@blocksuite/global/env'; +import { noop } from '@blocksuite/global/utils'; +import type { BlockComponent } from '@blocksuite/std'; +import { BlockSelection, TextSelection } from '@blocksuite/std'; +import { + getInlineRangeProvider, + INLINE_ROOT_ATTR, + type InlineRangeProvider, + type InlineRootElement, + type VLine, +} from '@blocksuite/std/inline'; +import { Slice } from '@blocksuite/store'; +import { computed, effect, type Signal, signal } from '@preact/signals-core'; +import { html, nothing, type TemplateResult } from 'lit'; +import { query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { bundledLanguagesInfo, type ThemedToken } from 'shiki'; + +import { CodeBlockConfigExtension } from './code-block-config.js'; +import { CodeBlockInlineManagerExtension } from './code-block-inline.js'; +import { CodeBlockHighlighter } from './code-block-service.js'; +import { CodeBlockPreviewIdentifier } from './code-preview-extension.js'; +import { codeBlockStyles } from './styles.js'; + +export class CodeBlockComponent extends CaptionedBlockComponent { + static override styles = codeBlockStyles; + + private _inlineRangeProvider: InlineRangeProvider | null = null; + + private readonly _localPreview$ = signal(null); + + preview$: Signal = computed(() => { + const modelPreview = !!this.model.props.preview$.value; + if (this.store.readonly) { + return this._localPreview$.value ?? modelPreview; + } + return modelPreview; + }); + + collapsed$: Signal = computed( + () => !!this.model.props.collapsed$.value + ); + + highlightTokens$: Signal = signal([]); + + languageName$: Signal = computed(() => { + const lang = this.model.props.language$.value; + if (lang === null) { + return 'Plain Text'; + } + + const matchedInfo = this.langs.find(info => info.id === lang); + return matchedInfo ? matchedInfo.name : 'Plain Text'; + }); + + get inlineEditor() { + const inlineRoot = this.querySelector( + `[${INLINE_ROOT_ATTR}]` + ); + return inlineRoot?.inlineEditor; + } + + get inlineManager() { + return this.std.get(CodeBlockInlineManagerExtension.identifier); + } + + get notificationService() { + return this.std.getOptional(NotificationProvider); + } + + get readonly() { + return this.store.readonly; + } + + get langs() { + return ( + this.std.getOptional(CodeBlockConfigExtension.identifier)?.langs ?? + bundledLanguagesInfo + ); + } + + get highlighter() { + return this.std.get(CodeBlockHighlighter); + } + + /** Whether line numbers are currently shown for this block, accounting for the global setting and feature flags. */ + get showLineNumbers(): boolean { + // Feature flag: mobile (or any other consumer) can hard-disable via CodeBlockConfigExtension. + const featureEnabled = + this.std.getOptional(CodeBlockConfigExtension.identifier) + ?.showLineNumbers ?? true; + if (!featureEnabled) return false; + // Per-block prop overrides the global default; global defaults to true. + return this.model.props.lineNumber ?? this._showLineNumbersGlobal$.value; + } + override get topContenteditableElement() { + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return this.closest( + EDGELESS_TOP_CONTENTEDITABLE_SELECTOR + ); + } + return this.rootComponent; + } + + private _updateHighlightTokens() { + const modelLang = this.model.props.language$.value; + if (modelLang === null) { + this.highlightTokens$.value = []; + return; + } + + const matchedInfo = this.langs.find( + info => + info.id === modelLang || + info.name === modelLang || + info.aliases?.includes(modelLang) + ); + + if (matchedInfo) { + this.model.props.language$.value = matchedInfo.id; + const langImport = matchedInfo.import; + const lang = matchedInfo.id; + + const highlighter = this.highlighter.highlighter$.value; + const theme = this.highlighter.themeKey; + if (!theme || !highlighter) { + this.highlightTokens$.value = []; + return; + } + + noop(this.model.props.text.deltas$.value); + const code = this.model.props.text.toString(); + + const loadedLanguages = highlighter.getLoadedLanguages(); + if (!loadedLanguages.includes(lang)) { + highlighter + .loadLanguage(langImport) + .then(() => { + this.highlightTokens$.value = highlighter.codeToTokensBase(code, { + lang, + theme, + }); + }) + .catch(console.error); + } else { + this.highlightTokens$.value = highlighter.codeToTokensBase(code, { + lang, + theme, + }); + } + } else { + this.highlightTokens$.value = []; + // clear language if not found + this.model.props.language$.value = null; + } + } + + /** + * Stable signal: true when the global editor setting enables line numbers. + * Defaults to true. Updated reactively by an effect in connectedCallback. + * Using a writable signal (not reassigned to computed) keeps the reference + * stable so any downstream computed/effect that captures it stays correct. + */ + private readonly _showLineNumbersGlobal$: Signal = signal(true); + + override connectedCallback() { + super.connectedCallback(); + + // Reactively sync the global line-number preference from EditorSettingProvider + // into the stable _showLineNumbersGlobal$ signal. Using effect() keeps the + // signal reference constant (no identity change) while still tracking updates. + const editorSetting = this.std.getOptional(EditorSettingProvider); + if (editorSetting) { + this.disposables.add( + effect(() => { + const val = ( + editorSetting.setting$.value as Record + )?.['codeBlockLineNumbers']; + this._showLineNumbersGlobal$.value = val !== false; + }) + ); + } + + // set highlight options getter used by "exportToHtml" + this.disposables.add( + effect(() => { + this._updateHighlightTokens(); + }) + ); + this.disposables.add( + effect(() => { + noop(this.highlightTokens$.value); + this._richTextElement?.inlineEditor?.render(); + }) + ); + + const selectionManager = this.host.selection; + const INDENT_SYMBOL = ' '; + const LINE_BREAK_SYMBOL = '\n'; + const allIndexOf = ( + text: string, + symbol: string, + start = 0, + end = text.length + ) => { + const indexArr: number[] = []; + let i = start; + + while (i < end) { + const index = text.indexOf(symbol, i); + if (index === -1 || index > end) { + break; + } + indexArr.push(index); + i = index + 1; + } + return indexArr; + }; + + // TODO: move to service for better performance + this.bindHotKey({ + Backspace: ctx => { + const event = ctx.get('defaultState').event; + const textSelection = selectionManager.find(TextSelection); + if (!textSelection) { + event.preventDefault(); + return; + } + + const from = textSelection.from; + + if (from.index === 0 && from.length === 0) { + event.preventDefault(); + selectionManager.setGroup('note', [ + selectionManager.create(BlockSelection, { blockId: this.blockId }), + ]); + return true; + } + + const inlineEditor = this.inlineEditor; + const inlineRange = inlineEditor?.getInlineRange(); + if (!inlineRange || !inlineEditor) return; + const left = inlineEditor.yText.toString()[inlineRange.index - 1]; + const right = inlineEditor.yText.toString()[inlineRange.index]; + const leftBrackets = BRACKET_PAIRS.map(pair => pair.left); + if (BRACKET_PAIRS[leftBrackets.indexOf(left)]?.right === right) { + const index = inlineRange.index - 1; + inlineEditor.deleteText({ + index: index, + length: 2, + }); + inlineEditor.setInlineRange({ + index: index, + length: 0, + }); + event.preventDefault(); + return true; + } + + return; + }, + Tab: ctx => { + if (this.store.readonly) return; + const state = ctx.get('keyboardState'); + const event = state.raw; + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (inlineRange) { + event.stopPropagation(); + event.preventDefault(); + + const text = this.inlineEditor.yText.toString(); + const index = text.lastIndexOf( + LINE_BREAK_SYMBOL, + inlineRange.index - 1 + ); + const indexArr = allIndexOf( + text, + LINE_BREAK_SYMBOL, + inlineRange.index, + inlineRange.index + inlineRange.length + ) + .map(i => i + 1) + .reverse(); + if (index !== -1) { + indexArr.push(index + 1); + } else { + indexArr.push(0); + } + indexArr.forEach(i => { + if (!this.inlineEditor) return; + this.inlineEditor.insertText( + { + index: i, + length: 0, + }, + INDENT_SYMBOL + ); + }); + this.inlineEditor.setInlineRange({ + index: inlineRange.index + 2, + length: + inlineRange.length + (indexArr.length - 1) * INDENT_SYMBOL.length, + }); + + return true; + } + + return; + }, + 'Shift-Tab': ctx => { + const state = ctx.get('keyboardState'); + const event = state.raw; + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (inlineRange) { + event.stopPropagation(); + event.preventDefault(); + + const text = this.inlineEditor.yText.toString(); + const index = text.lastIndexOf( + LINE_BREAK_SYMBOL, + inlineRange.index - 1 + ); + let indexArr = allIndexOf( + text, + LINE_BREAK_SYMBOL, + inlineRange.index, + inlineRange.index + inlineRange.length + ) + .map(i => i + 1) + .reverse(); + if (index !== -1) { + indexArr.push(index + 1); + } else { + indexArr.push(0); + } + indexArr = indexArr.filter( + i => text.slice(i, i + 2) === INDENT_SYMBOL + ); + indexArr.forEach(i => { + if (!this.inlineEditor) return; + this.inlineEditor.deleteText({ + index: i, + length: 2, + }); + }); + if (indexArr.length > 0) { + this.inlineEditor.setInlineRange({ + index: + inlineRange.index - + (indexArr[indexArr.length - 1] < inlineRange.index ? 2 : 0), + length: + inlineRange.length - + (indexArr.length - 1) * INDENT_SYMBOL.length, + }); + } + + return true; + } + + return; + }, + 'Control-d': () => { + if (!IS_MAC) return; + return true; + }, + Delete: () => { + return; + }, + Enter: () => { + this.store.captureSync(); + return true; + }, + 'Mod-Enter': () => { + const { model, std } = this; + if (!model || !std) return; + const inlineEditor = this.inlineEditor; + const inlineRange = inlineEditor?.getInlineRange(); + if (!inlineRange || !inlineEditor) return; + const isEnd = model.props.text.length === inlineRange.index; + if (!isEnd) return; + const parent = this.store.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + if (index === -1) return; + const id = this.store.addBlock( + 'affine:paragraph', + {}, + parent, + index + 1 + ); + focusTextModel(std, id); + return true; + }, + }); + + this._inlineRangeProvider = getInlineRangeProvider(this); + } + + copyCode() { + const model = this.model; + const slice = Slice.fromModels(model.store, [model]); + this.std.clipboard + .copySlice(slice) + .then(() => { + this.notificationService?.toast('Copied to clipboard'); + }) + .catch(e => { + this.notificationService?.toast('Copied failed, something went wrong'); + console.error(e); + }); + } + + get isCommentHighlighted() { + return ( + this.std + .getOptional(BlockElementCommentManager) + ?.isBlockCommentHighlighted(this.model) ?? false + ); + } + + override async getUpdateComplete() { + const result = await super.getUpdateComplete(); + await this._richTextElement?.updateComplete; + return result; + } + + override renderBlock(): TemplateResult<1> { + const showLineNumbers = this.showLineNumbers; + + const preview = this.preview$.value; + const previewContext = this.std.getOptional( + CodeBlockPreviewIdentifier(this.model.props.language ?? '') + ); + const shouldRenderPreview = preview && previewContext; + const collapsed = this.collapsed$.value; + + return html` +
+ getViewportElement(this.host)} + .vLineRenderer=${(vLine: VLine) => { + return html` + ${vLine.index + 1} + ${vLine.renderVElements()} + `; + }} + > + + ${ + collapsed + ? html`` + : nothing + } +
+ ${shouldRenderPreview && previewContext?.renderer(this.model)} +
+ ${this.renderChildren(this.model)} ${Object.values(this.widgets)} +
+ `; + } + + setWrap(wrap: boolean) { + this.store.updateBlock(this.model, { wrap }); + } + + setCollapsed(collapsed: boolean) { + this.store.updateBlock(this.model, { collapsed }); + } + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; + + override accessor blockContainerStyles = { + margin: '18px 0', + }; + + override accessor useCaptionEditor = true; + + override accessor useZeroWidth = true; + + setPreviewState(preview: boolean) { + if (this.store.readonly) { + this._localPreview$.value = preview; + } else { + this.store.updateBlock(this.model, { preview }); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-code': CodeBlockComponent; + } +} diff --git a/blocksuite/affine/blocks/code/src/code-keymap.ts b/blocksuite/affine/blocks/code/src/code-keymap.ts new file mode 100644 index 00000000..6f4cc1fd --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-keymap.ts @@ -0,0 +1,7 @@ +import { textKeymap } from '@blocksuite/affine-inline-preset'; +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { KeymapExtension } from '@blocksuite/std'; + +export const CodeKeymapExtension = KeymapExtension(textKeymap, { + flavour: CodeBlockSchema.model.flavour, +}); diff --git a/blocksuite/affine/blocks/code/src/code-preview-extension.ts b/blocksuite/affine/blocks/code/src/code-preview-extension.ts new file mode 100644 index 00000000..8473059b --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-preview-extension.ts @@ -0,0 +1,27 @@ +import type { CodeBlockModel } from '@blocksuite/affine-model'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { ExtensionType } from '@blocksuite/store'; +import type { HTMLTemplateResult } from 'lit'; + +export type CodeBlockPreviewRenderer = ( + model: CodeBlockModel +) => HTMLTemplateResult | null; + +export type CodeBlockPreviewContext = { + renderer: CodeBlockPreviewRenderer; + lang: string; +}; + +export const CodeBlockPreviewIdentifier = + createIdentifier('CodeBlockPreview'); + +export function CodeBlockPreviewExtension( + lang: string, + renderer: CodeBlockPreviewRenderer +): ExtensionType { + return { + setup: di => { + di.addImpl(CodeBlockPreviewIdentifier(lang), { renderer, lang }); + }, + }; +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/components/code-toolbar.ts b/blocksuite/affine/blocks/code/src/code-toolbar/components/code-toolbar.ts new file mode 100644 index 00000000..113c11a7 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/components/code-toolbar.ts @@ -0,0 +1,165 @@ +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import type { + EditorIconButton, + MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import { renderGroups } from '@blocksuite/affine-components/toolbar'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { WithDisposable } from '@blocksuite/global/lit'; +import { noop } from '@blocksuite/global/utils'; +import { MoreVerticalIcon } from '@blocksuite/icons/lit'; +import { flip, offset } from '@floating-ui/dom'; +import { effect } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import type { CodeBlockToolbarContext } from '../context.js'; + +export class AffineCodeToolbar extends WithDisposable(LitElement) { + static override styles = css` + :host { + position: absolute; + width: 100%; + top: 0; + left: 0; + } + + .code-toolbar-container { + width: auto; + height: 24px; + gap: 4px; + padding: 4px; + margin: 0; + display: flex; + } + + .code-toolbar-button { + color: ${unsafeCSSVarV2('icon/primary')}; + background-color: ${unsafeCSSVarV2('button/secondary')}; + box-shadow: var(--affine-shadow-1); + border-radius: 4px; + } + `; + + private _currentOpenMenu: AbortController | null = null; + + private _popMenuAbortController: AbortController | null = null; + + closeCurrentMenu = () => { + if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) { + this._currentOpenMenu.abort(); + this._currentOpenMenu = null; + } + }; + + private _toggleMoreMenu() { + if ( + this._currentOpenMenu && + !this._currentOpenMenu.signal.aborted && + this._currentOpenMenu === this._popMenuAbortController + ) { + this.closeCurrentMenu(); + this._moreMenuOpen = false; + return; + } + + this.closeCurrentMenu(); + this._popMenuAbortController = new AbortController(); + this._popMenuAbortController.signal.addEventListener('abort', () => { + this._moreMenuOpen = false; + this.onActiveStatusChange(false); + }); + this.onActiveStatusChange(true); + + this._currentOpenMenu = this._popMenuAbortController; + + if (!this._moreButton) { + console.error( + 'Failed to open more menu in code toolbar! Unexpected missing more button' + ); + return; + } + + createLitPortal({ + template: html` + + `, + // should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + container: this.context.host, + computePosition: { + referenceElement: this._moreButton, + placement: 'bottom-start', + middleware: [flip(), offset(4)], + autoUpdate: { animationFrame: true }, + }, + abortController: this._popMenuAbortController, + closeOnClickAway: true, + }); + this._moreMenuOpen = true; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.closeCurrentMenu(); + } + + override connectedCallback() { + super.connectedCallback(); + // Mirror the collapsed$ signal from the block component into local @state + // so this LitElement re-renders when it changes. + this.disposables.add( + effect(() => { + this._collapsed = this.context.blockComponent.collapsed$.value; + }) + ); + } + + override render() { + return html` + + ${renderGroups(this.primaryGroups, this.context)} + this._toggleMoreMenu()} + > + ${MoreVerticalIcon()} + + + `; + } + + @query('.code-toolbar-button.more') + private accessor _moreButton!: EditorIconButton; + + @state() + private accessor _moreMenuOpen = false; + + @state() + private accessor _collapsed = false; + + @property({ attribute: false }) + accessor context!: CodeBlockToolbarContext; + + @property({ attribute: false }) + accessor moreGroups!: MenuItemGroup[]; + + @property({ attribute: false }) + accessor onActiveStatusChange: (active: boolean) => void = noop; + + @property({ attribute: false }) + accessor primaryGroups!: MenuItemGroup[]; +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/components/lang-button.ts b/blocksuite/affine/blocks/code/src/code-toolbar/components/lang-button.ts new file mode 100644 index 00000000..48161439 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/components/lang-button.ts @@ -0,0 +1,168 @@ +import { + type FilterableListItem, + type FilterableListOptions, + showPopFilterableList, +} from '@blocksuite/affine-components/filterable-list'; +import { ArrowDownIcon } from '@blocksuite/affine-components/icons'; +import { + DocModeProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; +import { noop } from '@blocksuite/global/utils'; +import { css, LitElement, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { CodeBlockComponent } from '../..'; + +export class LanguageListButton extends WithDisposable( + SignalWatcher(LitElement) +) { + static override styles = css` + .lang-button { + display: flex; + gap: 4px; + padding: 2px 4px; + height: 28px; + } + + .lang-button:hover { + background: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .lang-button[hover] { + background: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .lang-button-icon { + display: flex; + align-items: center; + color: ${unsafeCSSVarV2('icon/primary')}; + + svg { + height: 16px; + width: 16px; + } + } + `; + + private _abortController?: AbortController; + + private readonly _clickLangBtn = () => { + if (this.blockComponent.store.readonly) return; + if (this._abortController) { + // Close the language list if it's already opened. + this._abortController.abort(); + return; + } + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this.onActiveStatusChange(false); + this._abortController = undefined; + }); + this.onActiveStatusChange(true); + + const options: FilterableListOptions = { + placeholder: 'Search for a language', + onSelect: item => { + const sortedBundledLanguages = this._sortedBundledLanguages; + const index = sortedBundledLanguages.indexOf(item); + if (index !== -1) { + sortedBundledLanguages.splice(index, 1); + sortedBundledLanguages.unshift(item); + } + this.blockComponent.store.transact(() => { + this.blockComponent.model.props.language$.value = item.name; + }); + + const std = this.blockComponent.std; + const mode = + std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page'; + const telemetryService = std.getOptional(TelemetryProvider); + if (!telemetryService) return; + telemetryService.track('codeBlockLanguageSelect', { + page: mode, + segment: 'code block', + module: 'language selector', + control: item.name, + }); + }, + active: item => item.name === this.blockComponent.model.props.language, + items: this._sortedBundledLanguages, + }; + + showPopFilterableList({ + options, + referenceElement: this._langButton, + container: this.blockComponent.host, + abortController: this._abortController, + // stacking-context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + }); + }; + + private _sortedBundledLanguages: FilterableListItem[] = []; + + override connectedCallback(): void { + super.connectedCallback(); + + const langList = localStorage.getItem('blocksuite:code-block:lang-list'); + if (langList) { + this._sortedBundledLanguages = JSON.parse(langList); + } else { + this._sortedBundledLanguages = this.blockComponent.langs.map(lang => ({ + label: lang.name, + name: lang.id, + aliases: lang.aliases, + })); + } + + this.disposables.add(() => { + localStorage.setItem( + 'blocksuite:code-block:lang-list', + JSON.stringify(this._sortedBundledLanguages) + ); + }); + } + + override render() { + const textStyles = styleMap({ + fontFamily: 'Inter', + fontSize: 'var(--affine-font-xs)', + fontStyle: 'normal', + fontWeight: '500', + lineHeight: '20px', + padding: '0 4px', + }); + + return html` + ${this.blockComponent.languageName$.value} + `} + height="24px" + @click=${this._clickLangBtn} + ?disabled=${this.blockComponent.store.readonly} + > + + ${!this.blockComponent.store.readonly ? ArrowDownIcon : nothing} + + `; + } + + @query('.lang-button') + private accessor _langButton!: HTMLElement; + + @property({ attribute: false }) + accessor blockComponent!: CodeBlockComponent; + + @property({ attribute: false }) + accessor onActiveStatusChange: (active: boolean) => void = noop; +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/components/more-menu.ts b/blocksuite/affine/blocks/code/src/code-toolbar/components/more-menu.ts new file mode 100644 index 00000000..a5587ae0 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/components/more-menu.ts @@ -0,0 +1,52 @@ +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { renderGroups } from '@blocksuite/affine-components/toolbar'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; +import { ShadowlessElement } from '@blocksuite/std'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { CodeBlockToolbarContext } from '../context.js'; + +export class AffineCodeMoreMenu extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + override firstUpdated() { + this.disposables.add( + this.context.blockComponent.model.propsUpdated.subscribe(({ key }) => { + if (key === 'wrap' || key === 'lineNumber') { + this.requestUpdate(); + } + }) + ); + } + + override render() { + return html` + +
+ ${renderGroups(this.moreGroups, this.context)} +
+
+ `; + } + + @property({ attribute: false }) + accessor context!: CodeBlockToolbarContext; + + @property({ attribute: false }) + accessor moreGroups!: MenuItemGroup[]; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-code-more-menu': AffineCodeMoreMenu; + } +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/components/preview-button.ts b/blocksuite/affine/blocks/code/src/code-toolbar/components/preview-button.ts new file mode 100644 index 00000000..15387ad5 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/components/preview-button.ts @@ -0,0 +1,112 @@ +import { + DocModeProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import type { CodeBlockComponent } from '../../code-block'; +import { CodeBlockPreviewIdentifier } from '../../code-preview-extension'; + +export class PreviewButton extends WithDisposable(SignalWatcher(LitElement)) { + static override styles = css` + :host { + margin-right: auto; + } + + .preview-toggle-container { + display: flex; + padding: 2px; + align-items: flex-start; + gap: 4px; + border-radius: 4px; + background: ${unsafeCSSVarV2('segment/background')}; + } + + .toggle-button { + display: flex; + padding: 0px 4px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 4px; + color: ${unsafeCSSVarV2('text/primary')}; + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 20px; + } + + .toggle-button:hover { + background: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .toggle-button.active { + background: ${unsafeCSSVarV2('segment/button')}; + box-shadow: + var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px) + var(--Shadow-buttonShadow-1-blur, 1px) 0px + var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)), + var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px) + var(--Shadow-buttonShadow-2-blur, 5px) 0px + var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12)); + } + `; + + private readonly _toggle = (value: boolean) => { + this.blockComponent.setPreviewState(value); + + const std = this.blockComponent.std; + const mode = std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page'; + const telemetryService = std.getOptional(TelemetryProvider); + if (!telemetryService) return; + telemetryService.track('htmlBlockTogglePreview', { + page: mode, + segment: 'code block', + module: 'code toolbar container', + control: 'preview toggle button', + }); + }; + + get preview() { + return this.blockComponent.preview$.value; + } + + override render() { + const lang = this.blockComponent.model.props.language$.value ?? ''; + const previewContext = this.blockComponent.std.getOptional( + CodeBlockPreviewIdentifier(lang) + ); + if (!previewContext) return nothing; + + return html` +
+
this._toggle(false)} + > + Code +
+
this._toggle(true)} + > + Preview +
+
+ `; + } + + @property({ attribute: false }) + accessor blockComponent!: CodeBlockComponent; +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/config.ts b/blocksuite/affine/blocks/code/src/code-toolbar/config.ts new file mode 100644 index 00000000..59953f8b --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/config.ts @@ -0,0 +1,319 @@ +import { + CancelWrapIcon, + CaptionIcon, + CollapseCodeIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ExpandCodeIcon, + WrapIcon, +} from '@blocksuite/affine-components/icons'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { CommentProviderIdentifier } from '@blocksuite/affine-shared/services'; +import { isInsidePageEditor } from '@blocksuite/affine-shared/utils'; +import { noop, sleep } from '@blocksuite/global/utils'; +import { CommentIcon, NumberedListIcon } from '@blocksuite/icons/lit'; +import { BlockSelection } from '@blocksuite/std'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import { CodeBlockConfigExtension } from '../code-block-config.js'; +import type { CodeBlockToolbarContext } from './context.js'; +import { duplicateCodeBlock } from './utils.js'; + +export const PRIMARY_GROUPS: MenuItemGroup[] = [ + { + type: 'primary', + items: [ + { + type: 'change-lang', + generate: ({ blockComponent, setActive }) => { + const state = { active: false }; + return { + action: noop, + render: () => + html` { + state.active = active; + if (!active) { + await sleep(1000); + if (state.active) return; + } + setActive(active); + }} + > + `, + }; + }, + }, + { + type: 'preview', + generate: ({ blockComponent }) => { + return { + action: noop, + render: () => html` + + + `, + }; + }, + }, + { + type: 'copy-code', + label: 'Copy code', + icon: CopyIcon, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.copyCode(); + }, + render: item => html` + { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + + `, + }; + }, + }, + { + type: 'collapse', + when: ({ doc }) => !doc.readonly, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.setCollapsed(!blockComponent.collapsed$.value); + }, + render: item => { + const collapsed = blockComponent.collapsed$.value; + const icon = collapsed ? ExpandCodeIcon : CollapseCodeIcon; + const label = collapsed ? 'Expand code' : 'Collapse code'; + return html` + { + e.stopPropagation(); + item.action(); + }} + > + ${icon} + + `; + }, + }; + }, + }, + { + type: 'caption', + label: 'Caption', + icon: CaptionIcon, + when: ({ doc }) => !doc.readonly, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.captionEditor?.show(); + }, + render: item => html` + { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + + `, + }; + }, + }, + { + type: 'comment', + label: 'Comment', + tooltip: 'Comment', + icon: CommentIcon({ + width: '20', + height: '20', + }), + when: ({ std }) => !!std.getOptional(CommentProviderIdentifier), + generate: ({ blockComponent }) => { + return { + action: () => { + const commentProvider = blockComponent.std.getOptional( + CommentProviderIdentifier + ); + if (!commentProvider) return; + + commentProvider.addComment([ + new BlockSelection({ + blockId: blockComponent.model.id, + }), + ]); + }, + render: item => + html` { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + `, + }; + }, + }, + ], + }, +]; + +export const toggleGroup: MenuItemGroup = { + type: 'toggle', + items: [ + { + type: 'wrap', + generate: ({ blockComponent }) => { + return { + action: () => {}, + render: () => { + const wrapped = blockComponent.model.props.wrap; + const label = wrapped ? 'Cancel wrap' : 'Wrap'; + const icon = wrapped ? CancelWrapIcon : WrapIcon; + return html` + { + const currentWrap = blockComponent.model.props.wrap; + blockComponent.setWrap(!currentWrap); + }} + aria-label=${label} + > + ${icon} + ${label} + + + `; + }, + }; + }, + }, + { + type: 'line-number', + when: ({ std }) => + std.getOptional(CodeBlockConfigExtension.identifier)?.showLineNumbers ?? + true, + generate: ({ blockComponent }) => { + return { + action: () => {}, + render: () => { + const lineNumber = blockComponent.showLineNumbers; + const label = lineNumber ? 'Cancel line number' : 'Line number'; + return html` + { + blockComponent.store.updateBlock(blockComponent.model, { + lineNumber: !blockComponent.showLineNumbers, + }); + }} + aria-label=${label} + > + ${NumberedListIcon()} + ${label} + + + `; + }, + }; + }, + }, + ], +}; + +// Clipboard Group +export const clipboardGroup: MenuItemGroup = { + type: 'clipboard', + items: [ + { + type: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon, + when: ({ doc }) => !doc.readonly, + action: ({ host, blockComponent, close }) => { + const codeId = duplicateCodeBlock(blockComponent.model); + + host.updateComplete + .then(() => { + host.selection.setGroup('note', [ + host.selection.create(BlockSelection, { + blockId: codeId, + }), + ]); + + if (isInsidePageEditor(host)) { + const duplicateElement = host.view.getBlock(codeId); + if (duplicateElement) { + duplicateElement.scrollIntoView({ block: 'nearest' }); + } + } + }) + .catch(console.error); + + close(); + }, + }, + ], +}; + +// Delete Group +export const deleteGroup: MenuItemGroup = { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + when: ({ doc }) => !doc.readonly, + action: ({ doc, blockComponent, close }) => { + doc.deleteBlock(blockComponent.model); + close(); + }, + }, + ], +}; + +export const MORE_GROUPS: MenuItemGroup[] = [ + toggleGroup, + clipboardGroup, + deleteGroup, +]; diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/context.ts b/blocksuite/affine/blocks/code/src/code-toolbar/context.ts new file mode 100644 index 00000000..e831d096 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/context.ts @@ -0,0 +1,46 @@ +import { MenuContext } from '@blocksuite/affine-components/toolbar'; + +import type { CodeBlockComponent } from '../code-block'; + +export class CodeBlockToolbarContext extends MenuContext { + override close = () => { + this.abortController.abort(); + }; + + get doc() { + return this.blockComponent.store; + } + + get host() { + return this.blockComponent.host; + } + + get selectedBlockModels() { + if (this.blockComponent.model) return [this.blockComponent.model]; + return []; + } + + get std() { + return this.blockComponent.std; + } + + constructor( + public blockComponent: CodeBlockComponent, + public abortController: AbortController, + public setActive: (active: boolean) => void + ) { + super(); + } + + isEmpty() { + return false; + } + + isMultiple() { + return false; + } + + isSingle() { + return true; + } +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/index.ts b/blocksuite/affine/blocks/code/src/code-toolbar/index.ts new file mode 100644 index 00000000..26962b88 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/index.ts @@ -0,0 +1,173 @@ +import { HoverController } from '@blocksuite/affine-components/hover'; +import type { + AdvancedMenuItem, + MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import { + cloneGroups, + getMoreMenuConfig, +} from '@blocksuite/affine-components/toolbar'; +import type { CodeBlockModel } from '@blocksuite/affine-model'; +import { + BlockSelection, + TextSelection, + WidgetComponent, +} from '@blocksuite/std'; +import { limitShift, shift, size } from '@floating-ui/dom'; +import { html } from 'lit'; + +import type { CodeBlockComponent } from '../code-block.js'; +import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js'; +import { CodeBlockToolbarContext } from './context.js'; + +export const AFFINE_CODE_TOOLBAR_WIDGET = 'affine-code-toolbar-widget'; +export class AffineCodeToolbarWidget extends WidgetComponent< + CodeBlockModel, + CodeBlockComponent +> { + private _hoverController: HoverController | null = null; + + private _isActivated = false; + + private readonly _setHoverController = () => { + this._hoverController = null; + this._hoverController = new HoverController( + this, + ({ abortController }) => { + const codeBlock = this.block; + if (!codeBlock) { + return null; + } + + const selection = this.host.selection; + + const textSelection = selection.find(TextSelection); + if ( + !!textSelection && + (!!textSelection.to || !!textSelection.from.length) + ) { + return null; + } + + const blockSelections = selection.filter(BlockSelection); + if ( + blockSelections.length > 1 || + (blockSelections.length === 1 && + blockSelections[0].blockId !== codeBlock.blockId) + ) { + return null; + } + + const setActive = (active: boolean) => { + this._isActivated = active; + if (!active && !this._hoverController?.isHovering) { + this._hoverController?.abort(); + } + }; + + const context = new CodeBlockToolbarContext( + codeBlock, + abortController, + setActive + ); + + return { + template: html``, + container: this.block, + // stacking-context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + computePosition: { + referenceElement: codeBlock, + placement: 'top', + middleware: [ + size({ + apply({ rects, elements }) { + elements.floating.style.width = `${rects.reference.width}px`; + }, + }), + shift({ + crossAxis: true, + padding: { + bottom: 12, + right: 12, + }, + limiter: limitShift(), + }), + ], + autoUpdate: true, + }, + }; + }, + { allowMultiple: true } + ); + + const codeBlock = this.block; + if (!codeBlock) { + return; + } + this._hoverController.setReference(codeBlock); + this._hoverController.onAbort = () => { + // If the more menu is opened, don't close it. + if (this._isActivated) return; + this._hoverController?.abort(); + return; + }; + }; + + addMoretems = ( + items: AdvancedMenuItem[], + index?: number, + type?: string + ) => { + let group; + if (type) { + group = this.moreGroups.find(g => g.type === type); + } + if (!group) { + group = this.moreGroups[0]; + } + + if (index === undefined) { + group.items.push(...items); + return this; + } + + group.items.splice(index, 0, ...items); + return this; + }; + + addPrimaryItems = ( + items: AdvancedMenuItem[], + index?: number + ) => { + if (index === undefined) { + this.primaryGroups[0].items.push(...items); + return this; + } + + this.primaryGroups[0].items.splice(index, 0, ...items); + return this; + }; + + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + protected moreGroups: MenuItemGroup[] = + cloneGroups(MORE_GROUPS); + + protected primaryGroups: MenuItemGroup[] = + cloneGroups(PRIMARY_GROUPS); + + override firstUpdated() { + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + this._setHoverController(); + } +} diff --git a/blocksuite/affine/blocks/code/src/code-toolbar/utils.ts b/blocksuite/affine/blocks/code/src/code-toolbar/utils.ts new file mode 100644 index 00000000..0909982f --- /dev/null +++ b/blocksuite/affine/blocks/code/src/code-toolbar/utils.ts @@ -0,0 +1,16 @@ +import type { CodeBlockModel } from '@blocksuite/affine-model'; + +export const duplicateCodeBlock = (model: CodeBlockModel) => { + const keys = model.keys as (keyof (typeof model)['props'])[]; + const values = keys.map(key => model.props[key]); + const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]])); + const { text: _text, ...duplicateProps } = blockProps; + + const newProps = { + flavour: model.flavour, + text: model.props.text.clone(), + ...duplicateProps, + }; + + return model.store.addSiblingBlocks(model, [newProps])[0]; +}; diff --git a/blocksuite/affine/blocks/code/src/configs/slash-menu.ts b/blocksuite/affine/blocks/code/src/configs/slash-menu.ts new file mode 100644 index 00000000..11ed76aa --- /dev/null +++ b/blocksuite/affine/blocks/code/src/configs/slash-menu.ts @@ -0,0 +1,8 @@ +import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu'; + +export const codeSlashMenuConfig: SlashMenuConfig = { + disableWhen: ({ model }) => { + return model.flavour === 'affine:code'; + }, + items: [], +}; diff --git a/blocksuite/affine/blocks/code/src/effects.ts b/blocksuite/affine/blocks/code/src/effects.ts new file mode 100644 index 00000000..31af358c --- /dev/null +++ b/blocksuite/affine/blocks/code/src/effects.ts @@ -0,0 +1,30 @@ +import { CodeBlockComponent } from './code-block'; +import { + AFFINE_CODE_TOOLBAR_WIDGET, + AffineCodeToolbarWidget, +} from './code-toolbar'; +import { AffineCodeToolbar } from './code-toolbar/components/code-toolbar'; +import { LanguageListButton } from './code-toolbar/components/lang-button'; +import { AffineCodeMoreMenu } from './code-toolbar/components/more-menu'; +import { PreviewButton } from './code-toolbar/components/preview-button'; +import { AffineCodeUnit } from './highlight/affine-code-unit'; + +export function effects() { + customElements.define('language-list-button', LanguageListButton); + customElements.define('affine-code-toolbar', AffineCodeToolbar); + customElements.define('affine-code-more-menu', AffineCodeMoreMenu); + customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget); + customElements.define('affine-code-unit', AffineCodeUnit); + customElements.define('affine-code', CodeBlockComponent); + customElements.define('preview-button', PreviewButton); +} + +declare global { + interface HTMLElementTagNameMap { + 'language-list-button': LanguageListButton; + 'affine-code-toolbar': AffineCodeToolbar; + 'affine-code-more-menu': AffineCodeMoreMenu; + 'preview-button': PreviewButton; + [AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget; + } +} diff --git a/blocksuite/affine/blocks/code/src/highlight/affine-code-unit.ts b/blocksuite/affine/blocks/code/src/highlight/affine-code-unit.ts new file mode 100644 index 00000000..f700c2e4 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/highlight/affine-code-unit.ts @@ -0,0 +1,124 @@ +import { affineTextStyles } from '@blocksuite/affine-shared/styles'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/std'; +import { ZERO_WIDTH_FOR_EMPTY_LINE } from '@blocksuite/std/inline'; +import type { DeltaInsert } from '@blocksuite/store'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import type { ThemedToken } from 'shiki'; + +export class AffineCodeUnit extends ShadowlessElement { + get codeBlock() { + return this.closest('affine-code'); + } + + get vElement() { + return this.closest('v-element'); + } + + override render() { + if (this.delta.attributes?.link && this.codeBlock) { + return html``; + } + + let style = this.delta.attributes + ? affineTextStyles(this.delta.attributes) + : {}; + if (this.delta.attributes?.code) { + style = { + ...style, + 'font-size': 'calc(var(--affine-font-base) - 3px)', + padding: '0px 4px 2px', + }; + } + + const plainContent = html``; + + const codeBlock = this.codeBlock; + const vElement = this.vElement; + if (!codeBlock || !vElement) return plainContent; + const tokens = codeBlock.highlightTokens$.value; + if (tokens.length === 0) return plainContent; + const line = tokens[vElement.lineIndex]; + if (!line) return plainContent; + // copy the tokens to avoid modifying the original tokens + const lineTokens = structuredClone(line); + if (lineTokens.length === 0) return plainContent; + + const startOffset = vElement.startOffset; + const endOffset = vElement.endOffset; + const includedTokens: ThemedToken[] = []; + lineTokens.forEach(token => { + if ( + (token.offset <= startOffset && + token.offset + token.content.length >= startOffset) || + (token.offset >= startOffset && + token.offset + token.content.length <= endOffset) || + (token.offset <= endOffset && + token.offset + token.content.length >= endOffset) + ) { + includedTokens.push(token); + } + }); + if (includedTokens.length === 0) return plainContent; + + if (includedTokens.length === 1) { + const token = includedTokens[0]; + const content = token.content.slice( + startOffset - token.offset, + endOffset - token.offset + ); + + return html``; + } else { + const firstToken = includedTokens[0]; + const lastToken = includedTokens[includedTokens.length - 1]; + + const firstContent = firstToken.content.slice( + startOffset - firstToken.offset, + firstToken.content.length + ); + const lastContent = lastToken.content.slice( + 0, + endOffset - lastToken.offset + ); + firstToken.content = firstContent; + lastToken.content = lastContent; + + const vTexts = includedTokens.map(token => { + return html``; + }); + + return html`${vTexts}`; + } + } + + @property({ type: Object }) + accessor delta: DeltaInsert = { + insert: ZERO_WIDTH_FOR_EMPTY_LINE, + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-code-unit': AffineCodeUnit; + } +} diff --git a/blocksuite/affine/blocks/code/src/highlight/const.ts b/blocksuite/affine/blocks/code/src/highlight/const.ts new file mode 100644 index 00000000..f10bc765 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/highlight/const.ts @@ -0,0 +1,4 @@ +export const CODE_BLOCK_DEFAULT_DARK_THEME = + import('shiki/themes/dark-plus.mjs'); +export const CODE_BLOCK_DEFAULT_LIGHT_THEME = + import('shiki/themes/light-plus.mjs'); diff --git a/blocksuite/affine/blocks/code/src/index.ts b/blocksuite/affine/blocks/code/src/index.ts new file mode 100644 index 00000000..430df892 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/index.ts @@ -0,0 +1,10 @@ +export * from './adapters'; +export * from './clipboard'; +export * from './code-block'; +export * from './code-block-config'; +export * from './code-block-service'; +export * from './code-preview-extension'; +export * from './code-toolbar'; +export * from './highlight/const'; +export * from './turbo/code-layout-handler'; +export * from './turbo/code-painter.worker'; diff --git a/blocksuite/affine/blocks/code/src/markdown.ts b/blocksuite/affine/blocks/code/src/markdown.ts new file mode 100644 index 00000000..502ad037 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/markdown.ts @@ -0,0 +1,61 @@ +import { + type CodeBlockModel, + CodeBlockSchema, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { matchModels } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/std'; +import { InlineMarkdownExtension } from '@blocksuite/std/inline'; + +export const CodeBlockMarkdownExtension = + InlineMarkdownExtension({ + name: 'code-block', + pattern: /^```([a-zA-Z0-9]*)\s$/, + action: ({ inlineEditor, inlineRange, prefixText, pattern }) => { + if (inlineEditor.yTextString.slice(0, inlineRange.index).includes('\n')) { + return; + } + + const match = prefixText.match(pattern); + if (!match) return; + + const language = match[1]; + + if (!inlineEditor.rootElement) return; + const blockComponent = + inlineEditor.rootElement.closest('[data-block-id]'); + if (!blockComponent) return; + + const { model, std, store } = blockComponent; + + if ( + matchModels(model, [ParagraphBlockModel]) && + model.props.type === 'quote' + ) { + return; + } + + const parent = store.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + + store.captureSync(); + const codeId = store.addBlock( + CodeBlockSchema.model.flavour, + { language }, + parent, + index + ); + + if (model.text && model.text.length > prefixText.length) { + const text = model.text.clone(); + store.addBlock('affine:paragraph', { text }, parent, index + 1); + text.delete(0, prefixText.length); + } + store.deleteBlock(model, { bringChildrenTo: parent }); + + focusTextModel(std, codeId); + }, + }); diff --git a/blocksuite/affine/blocks/code/src/store.ts b/blocksuite/affine/blocks/code/src/store.ts new file mode 100644 index 00000000..8621f9b6 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/store.ts @@ -0,0 +1,19 @@ +import { + type StoreExtensionContext, + StoreExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { CodeBlockSchemaExtension } from '@blocksuite/affine-model'; + +import { CodeBlockAdapterExtensions } from './adapters/extension'; +import { CodeMarkdownPreprocessorExtension } from './adapters/markdown/preprocessor'; + +export class CodeStoreExtension extends StoreExtensionProvider { + override name = 'affine-code-block'; + + override setup(context: StoreExtensionContext) { + super.setup(context); + context.register(CodeBlockSchemaExtension); + context.register(CodeBlockAdapterExtensions); + context.register(CodeMarkdownPreprocessorExtension); + } +} diff --git a/blocksuite/affine/blocks/code/src/styles.ts b/blocksuite/affine/blocks/code/src/styles.ts new file mode 100644 index 00000000..652c0b03 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/styles.ts @@ -0,0 +1,114 @@ +import { scrollbarStyle } from '@blocksuite/affine-shared/styles'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css } from 'lit'; + +export const codeBlockStyles = css` + affine-code { + display: block; + } + + .affine-code-block-container { + font-size: var(--affine-font-xs); + line-height: var(--affine-line-height); + position: relative; + padding: 32px 20px; + background: var(--affine-background-code-block); + border-radius: 10px; + box-sizing: border-box; + } + + .affine-code-block-container.mobile { + padding: 12px; + } + + .affine-code-block-container.highlight-comment { + outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}; + } + + ${scrollbarStyle('.affine-code-block-container rich-text')} + + /* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */ + @supports not selector(::-webkit-scrollbar) { + .affine-code-block-container rich-text { + scrollbar-width: thin; + scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')} + transparent; + scrollbar-gutter: stable both-edges; + } + } + + .affine-code-block-container .inline-editor { + font-family: var(--affine-font-code-family); + font-variant-ligatures: none; + } + + .affine-code-block-container v-line { + position: relative; + display: inline-grid !important; + grid-template-columns: auto minmax(0, 1fr); + } + + .affine-code-block-container.disable-line-numbers v-line { + grid-template-columns: unset; + } + + .affine-code-block-container div:has(> v-line) { + display: grid; + } + + .affine-code-block-container .line-number { + position: sticky; + text-align: left; + padding-right: 12px; + width: 32px; + word-break: break-word; + white-space: nowrap; + left: -0.5px; + z-index: 1; + background: var(--affine-background-code-block); + font-size: var(--affine-font-xs); + line-height: var(--affine-line-height); + color: var(--affine-text-secondary); + box-sizing: border-box; + user-select: none; + } + + .affine-code-block-container.disable-line-numbers .line-number { + display: none; + } + + affine-code .affine-code-block-preview { + padding: 12px; + } + + /* ── Collapsed state ──────────────────────────────────────────────── */ + + /* Clamp the rich-text to the first 8 lines */ + .affine-code-block-container.collapsed rich-text { + display: block; + max-height: calc(8 * var(--affine-line-height)); + overflow: hidden; + } + + /* Reduce bottom padding so the fade sits flush with the border */ + .affine-code-block-container.collapsed { + padding-bottom: 0; + } + + /* Gradient overlay that fades to the block background */ + .affine-code-block-container .code-collapsed-fade { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 80px; + background: linear-gradient( + to bottom, + transparent, + var(--affine-background-code-block) + ); + border-radius: 0 0 10px 10px; + pointer-events: none; + z-index: 1; + } +`; diff --git a/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts b/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts new file mode 100644 index 00000000..0c6fc807 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/turbo/code-layout-handler.ts @@ -0,0 +1,73 @@ +import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer'; +import { + BlockLayoutHandlerExtension, + BlockLayoutHandlersIdentifier, +} from '@blocksuite/affine-gfx-turbo-renderer'; +import type { Container } from '@blocksuite/global/di'; +import type { EditorHost, GfxBlockComponent } from '@blocksuite/std'; +import { clientToModelCoord, type ViewportRecord } from '@blocksuite/std/gfx'; +import type { BlockModel } from '@blocksuite/store'; + +import type { CodeLayout } from './code-painter.worker'; + +export class CodeLayoutHandlerExtension extends BlockLayoutHandlerExtension { + readonly blockType = 'affine:code'; + + static override setup(di: Container) { + di.addImpl( + BlockLayoutHandlersIdentifier('code'), + CodeLayoutHandlerExtension + ); + } + + override queryLayout( + model: BlockModel, + host: EditorHost, + viewportRecord: ViewportRecord + ): CodeLayout | null { + const component = host.std.view.getBlock( + model.id + ) as GfxBlockComponent | null; + if (!component) return null; + + const codeBlockElement = component.querySelector( + '.affine-code-block-container' + ); + if (!codeBlockElement) return null; + + const { zoom, viewScale } = viewportRecord; + const codeLayout: CodeLayout = { + type: 'affine:code', + blockId: model.id, + rect: { x: 0, y: 0, w: 0, h: 0 }, + }; + + // Get the bounding rect of the code block + const clientRect = codeBlockElement.getBoundingClientRect(); + if (!clientRect) return null; + + // Convert client coordinates to model coordinates + const [modelX, modelY] = clientToModelCoord(viewportRecord, [ + clientRect.x, + clientRect.y, + ]); + + codeLayout.rect = { + x: modelX, + y: modelY, + w: clientRect.width / zoom / viewScale, + h: clientRect.height / zoom / viewScale, + }; + + return codeLayout; + } + + calculateBound(layout: CodeLayout) { + const rect: Rect = layout.rect; + + return { + rect, + subRects: [rect], + }; + } +} diff --git a/blocksuite/affine/blocks/code/src/turbo/code-painter.worker.ts b/blocksuite/affine/blocks/code/src/turbo/code-painter.worker.ts new file mode 100644 index 00000000..9b929a31 --- /dev/null +++ b/blocksuite/affine/blocks/code/src/turbo/code-painter.worker.ts @@ -0,0 +1,53 @@ +import type { + BlockLayout, + BlockLayoutPainter, + WorkerToHostMessage, +} from '@blocksuite/affine-gfx-turbo-renderer'; +import { BlockLayoutPainterExtension } from '@blocksuite/affine-gfx-turbo-renderer/painter'; + +export interface CodeLayout extends BlockLayout { + type: 'affine:code'; +} + +function isCodeLayout(layout: BlockLayout): layout is CodeLayout { + return layout.type === 'affine:code'; +} + +class CodeLayoutPainter implements BlockLayoutPainter { + paint( + ctx: OffscreenCanvasRenderingContext2D, + layout: BlockLayout, + layoutBaseX: number, + layoutBaseY: number + ): void { + if (!isCodeLayout(layout)) { + const message: WorkerToHostMessage = { + type: 'paintError', + error: 'Invalid layout format', + blockType: 'affine:code', + }; + self.postMessage(message); + return; + } + + // Get the layout dimensions + const x = layout.rect.x - layoutBaseX; + const y = layout.rect.y - layoutBaseY; + const width = layout.rect.w; + const height = layout.rect.h; + + // Simple white rectangle for now + ctx.fillStyle = 'white'; + ctx.fillRect(x, y, width, height); + + // Add a border to visualize the code block + ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, width, height); + } +} + +export const CodeLayoutPainterExtension = BlockLayoutPainterExtension( + 'affine:code', + CodeLayoutPainter +); diff --git a/blocksuite/affine/blocks/code/src/view.ts b/blocksuite/affine/blocks/code/src/view.ts new file mode 100644 index 00000000..e88bcb7a --- /dev/null +++ b/blocksuite/affine/blocks/code/src/view.ts @@ -0,0 +1,65 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; +import { + BlockViewExtension, + FlavourExtension, + WidgetViewExtension, +} from '@blocksuite/std'; +import { literal, unsafeStatic } from 'lit/static-html.js'; + +import { getCodeClipboardExtensions } from './clipboard/index.js'; +import { CodeBlockConfigExtension } from './code-block-config'; +import { + CodeBlockInlineManagerExtension, + CodeBlockUnitSpecExtension, +} from './code-block-inline.js'; +import { CodeBlockHighlighter } from './code-block-service.js'; +import { CodeKeymapExtension } from './code-keymap.js'; +import { AFFINE_CODE_TOOLBAR_WIDGET } from './code-toolbar/index.js'; +import { codeSlashMenuConfig } from './configs/slash-menu.js'; +import { effects } from './effects.js'; +import { CodeBlockMarkdownExtension } from './markdown.js'; + +const codeToolbarWidget = WidgetViewExtension( + 'affine:code', + AFFINE_CODE_TOOLBAR_WIDGET, + literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}` +); + +export class CodeBlockViewExtension extends ViewExtensionProvider { + override name = 'affine-code-block'; + + override effect() { + super.effect(); + effects(); + } + + override setup(context: ViewExtensionContext) { + super.setup(context); + context.register([ + FlavourExtension('affine:code'), + CodeBlockHighlighter, + BlockViewExtension('affine:code', literal`affine-code`), + SlashMenuConfigExtension('affine:code', codeSlashMenuConfig), + CodeKeymapExtension, + CodeBlockMarkdownExtension, + ...getCodeClipboardExtensions(), + ]); + context.register([ + CodeBlockInlineManagerExtension, + CodeBlockUnitSpecExtension, + ]); + if (!this.isMobile(context.scope)) { + context.register(codeToolbarWidget); + } else { + context.register( + CodeBlockConfigExtension({ + showLineNumbers: false, + }) + ); + } + } +} diff --git a/blocksuite/affine/blocks/code/tsconfig.json b/blocksuite/affine/blocks/code/tsconfig.json new file mode 100644 index 00000000..b3b796ee --- /dev/null +++ b/blocksuite/affine/blocks/code/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [ + { "path": "../../components" }, + { "path": "../../ext-loader" }, + { "path": "../../gfx/turbo-renderer" }, + { "path": "../../inlines/comment" }, + { "path": "../../inlines/latex" }, + { "path": "../../inlines/link" }, + { "path": "../../inlines/preset" }, + { "path": "../../model" }, + { "path": "../../rich-text" }, + { "path": "../../shared" }, + { "path": "../../widgets/slash-menu" }, + { "path": "../../../framework/global" }, + { "path": "../../../framework/std" }, + { "path": "../../../framework/store" } + ] +} diff --git a/blocksuite/affine/blocks/data-view/package.json b/blocksuite/affine/blocks/data-view/package.json new file mode 100644 index 00000000..d0b1b3b7 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/package.json @@ -0,0 +1,40 @@ +{ + "name": "@blocksuite/affine-block-data-view", + "description": "Data view block for BlockSuite.", + "type": "module", + "scripts": { + "build": "yarn run -T tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-block-database": "workspace:*", + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/data-view": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.2.17", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@preact/signals-core": "^1.8.0", + "lit": "^3.2.0", + "rxjs": "^7.8.2" + }, + "exports": { + ".": "./src/index.ts", + "./view": "./src/view.ts", + "./store": "./src/store.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5" +} diff --git a/blocksuite/affine/blocks/data-view/src/block-meta/base.ts b/blocksuite/affine/blocks/data-view/src/block-meta/base.ts new file mode 100644 index 00000000..f4fdeb6b --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/block-meta/base.ts @@ -0,0 +1,37 @@ +import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import type { DisposableMember } from '@blocksuite/global/disposable'; +import type { Block, BlockModel } from '@blocksuite/store'; + +type PropertyMeta< + T extends BlockModel = BlockModel, + RawValue = unknown, + JsonValue = unknown, + ColumnData extends NonNullable = NonNullable, +> = { + name: string; + key: string; + metaConfig: PropertyMetaConfig; + getColumnData?: (block: T) => ColumnData; + setColumnData?: (block: T, data: ColumnData) => void; + get: (block: T) => RawValue; + set?: (block: T, value: RawValue) => void; + updated: (block: T, callback: () => void) => DisposableMember; +}; +export type BlockMeta = { + selector: (block: Block) => boolean; + properties: PropertyMeta[]; +}; +export const createBlockMeta = ( + options: Omit, 'properties'> +) => { + const meta: BlockMeta = { + ...options, + properties: [], + }; + return { + ...meta, + addProperty: (property: PropertyMeta) => { + meta.properties.push(property as PropertyMeta); + }, + }; +}; diff --git a/blocksuite/affine/blocks/data-view/src/block-meta/index.ts b/blocksuite/affine/blocks/data-view/src/block-meta/index.ts new file mode 100644 index 00000000..24e9ea9a --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/block-meta/index.ts @@ -0,0 +1,6 @@ +import type { BlockMeta } from './base.js'; +import { todoMeta } from './todo.js'; + +export const blockMetaMap = { + todo: todoMeta, +} satisfies Record; diff --git a/blocksuite/affine/blocks/data-view/src/block-meta/todo.ts b/blocksuite/affine/blocks/data-view/src/block-meta/todo.ts new file mode 100644 index 00000000..ae8e8d19 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/block-meta/todo.ts @@ -0,0 +1,13 @@ +import { type ListBlockModel, ListBlockSchema } from '@blocksuite/affine-model'; + +import { createBlockMeta } from './base.js'; + +export const todoMeta = createBlockMeta({ + selector: block => { + if (block.flavour !== ListBlockSchema.model.flavour) { + return false; + } + + return (block.model as ListBlockModel).props.type === 'todo'; + }, +}); diff --git a/blocksuite/affine/blocks/data-view/src/columns/index.ts b/blocksuite/affine/blocks/data-view/src/columns/index.ts new file mode 100644 index 00000000..bc8e2ee8 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/columns/index.ts @@ -0,0 +1,22 @@ +import { richTextColumnConfig } from '@blocksuite/affine-block-database'; +import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; + +export const queryBlockColumns = [ + propertyPresets.datePropertyConfig, + propertyPresets.numberPropertyConfig, + propertyPresets.progressPropertyConfig, + propertyPresets.selectPropertyConfig, + propertyPresets.multiSelectPropertyConfig, + propertyPresets.checkboxPropertyConfig, +]; +export const queryBlockHiddenColumns: PropertyMetaConfig< + string, + any, + any, + any +>[] = [richTextColumnConfig]; +const queryBlockAllColumns = [...queryBlockColumns, ...queryBlockHiddenColumns]; +export const queryBlockAllColumnMap = Object.fromEntries( + queryBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig]) +); diff --git a/blocksuite/affine/blocks/data-view/src/configs/slash-menu.ts b/blocksuite/affine/blocks/data-view/src/configs/slash-menu.ts new file mode 100644 index 00000000..d72dbe48 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/configs/slash-menu.ts @@ -0,0 +1,51 @@ +import { FeatureFlagService } from '@blocksuite/affine-shared/services'; +import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils'; +import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu'; +import { DatabaseTableViewIcon } from '@blocksuite/icons/lit'; + +import type { DataViewBlockComponent } from '../data-view-block'; +import { ToDoListTooltip } from './tooltips'; + +export const dataViewSlashMenuConfig: SlashMenuConfig = { + disableWhen: ({ model }) => { + return model.flavour === 'affine:data-view'; + }, + items: [ + { + name: 'Todo', + searchAlias: ['todo view'], + icon: DatabaseTableViewIcon(), + tooltip: { + figure: ToDoListTooltip, + caption: 'To-do List', + }, + group: '7_Database@1', + when: ({ model, std }) => + !isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text') && + !!std.get(FeatureFlagService).getFlag('enable_block_query'), + + action: ({ model, std }) => { + const { host } = std; + const parent = host.store.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + const id = host.store.addBlock( + 'affine:data-view', + {}, + host.store.getParent(model), + index + 1 + ); + const dataViewModel = host.store.getBlock(id)!; + + const dataView = std.view.getBlock( + dataViewModel.id + ) as DataViewBlockComponent | null; + dataView?.dataSource.viewManager.viewAdd('table'); + + if (model.text?.length === 0) { + model.store.deleteBlock(model); + } + }, + }, + ], +}; diff --git a/blocksuite/affine/blocks/data-view/src/configs/tooltips.ts b/blocksuite/affine/blocks/data-view/src/configs/tooltips.ts new file mode 100644 index 00000000..541d6acd --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/configs/tooltips.ts @@ -0,0 +1,16 @@ +import { html } from 'lit'; + +// prettier-ignore +export const ToDoListTooltip = html` + + + + + + +Here is an example of todo list. + +Make a list for building preview. + + +`; diff --git a/blocksuite/affine/blocks/data-view/src/data-source.ts b/blocksuite/affine/blocks/data-view/src/data-source.ts new file mode 100644 index 00000000..c5ec2cc5 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/data-source.ts @@ -0,0 +1,326 @@ +import { + DatabaseBlockDataSource, + databasePropertyConverts, +} from '@blocksuite/affine-block-database'; +import type { ColumnDataType } from '@blocksuite/affine-model'; +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { DataSourceBase, type PropertyMetaConfig } from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; +import { BlockSuiteError } from '@blocksuite/global/exceptions'; +import type { EditorHost } from '@blocksuite/std'; +import type { Block, Store } from '@blocksuite/store'; +import { Subject } from 'rxjs'; + +import type { BlockMeta } from './block-meta/base.js'; +import { blockMetaMap } from './block-meta/index.js'; +import { queryBlockAllColumnMap, queryBlockColumns } from './columns/index.js'; +import type { DataViewBlockModel } from './data-view-model.js'; + +export type BlockQueryDataSourceConfig = { + type: keyof typeof blockMetaMap; +}; + +// @ts-expect-error FIXME: ts error +export class BlockQueryDataSource extends DataSourceBase { + private readonly columnMetaMap = new Map< + string, + PropertyMetaConfig + >(); + + private readonly meta: BlockMeta; + + blockMap = new Map(); + + docDisposeMap = new Map void>(); + + slots = { + update: new Subject(), + }; + + private get blocks() { + return [...this.blockMap.values()]; + } + + get properties(): string[] { + return [ + ...this.meta.properties.map(v => v.key), + ...this.block.props.columns.map(v => v.id), + ]; + } + + get propertyMetas(): PropertyMetaConfig[] { + return queryBlockColumns as PropertyMetaConfig[]; + } + + get rows(): string[] { + return this.blocks.map(v => v.id); + } + + get workspace() { + return this.host.store.workspace; + } + + constructor( + private readonly host: EditorHost, + private readonly block: DataViewBlockModel, + config: BlockQueryDataSourceConfig + ) { + super(); + this.meta = blockMetaMap[config.type]; + for (const property of this.meta.properties) { + this.columnMetaMap.set(property.metaConfig.type, property.metaConfig); + } + for (const collection of this.workspace.docs.values()) { + for (const block of Object.values(collection.getStore().blocks.peek())) { + if (this.meta.selector(block)) { + this.blockMap.set(block.id, block); + } + } + } + this.workspace.docs.forEach(doc => { + this.listenToDoc(doc.getStore()); + }); + this.workspace.slots.docListUpdated.subscribe(() => { + this.workspace.docs.forEach(doc => { + if (!this.docDisposeMap.has(doc.id)) { + this.listenToDoc(doc.getStore()); + } + }); + this.docDisposeMap.forEach((_, id) => { + if (!this.workspace.docs.has(id)) { + this.docDisposeMap.get(id)?.(); + this.docDisposeMap.delete(id); + } + }); + }); + } + + private getProperty(propertyId: string) { + const property = this.meta.properties.find(v => v.key === propertyId); + if (!property) { + throw new BlockSuiteError( + BlockSuiteError.ErrorCode.ValueNotExists, + `property ${propertyId} not found` + ); + } + return property; + } + + private newColumnName() { + let i = 1; + while ( + this.block.props.columns.some(column => column.name === `Column ${i}`) + ) { + i++; + } + return `Column ${i}`; + } + + cellValueChange(rowId: string, propertyId: string, value: unknown): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + this.block.props.cells[rowId] = { + ...this.block.props.cells[rowId], + [propertyId]: value, + }; + return; + } + const block = this.blockMap.get(rowId); + if (block) { + this.meta.properties + .find(v => v.key === propertyId) + ?.set?.(block.model, value); + } + } + + cellValueGet(rowId: string, propertyId: string): unknown { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return this.block.props.cells[rowId]?.[propertyId]; + } + const block = this.blockMap.get(rowId); + if (block) { + return this.getProperty(propertyId)?.get(block.model); + } + return; + } + + getViewColumn(id: string) { + return this.block.props.columns.find(v => v.id === id); + } + + listenToDoc(doc: Store) { + this.docDisposeMap.set( + doc.id, + doc.slots.blockUpdated.subscribe(v => { + if (v.type === 'add') { + const blockById = doc.getBlock(v.id); + if (blockById && this.meta.selector(blockById)) { + this.blockMap.set(v.id, blockById); + } + } else if (v.type === 'delete') { + this.blockMap.delete(v.id); + } + this.slots.update.next(undefined); + }).unsubscribe + ); + } + + propertyAdd( + insertToPosition: InsertToPosition, + ops?: { + type?: string; + name?: string; + } + ): string { + const { type } = ops ?? {}; + const doc = this.block.store; + doc.captureSync(); + const column = DatabaseBlockDataSource.propertiesMap.value[ + type ?? propertyPresets.multiSelectPropertyConfig.type + ].create(this.newColumnName()); + + const id = doc.workspace.idGenerator(); + if (this.block.props.columns.some(v => v.id === id)) { + return id; + } + doc.transact(() => { + const col: ColumnDataType = { + ...column, + id, + }; + this.block.props.columns.splice( + insertPositionToIndex(insertToPosition, this.block.props.columns), + 0, + col + ); + }); + return id; + } + + propertyDataGet(propertyId: string): Record { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return viewColumn.data; + } + const property = this.getProperty(propertyId); + return ( + property.getColumnData?.(this.blocks[0].model) ?? + property.metaConfig.config.propertyData.default() + ); + } + + propertyDataSet(propertyId: string, data: Record): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + viewColumn.data = data; + } + } + + propertyDelete(_id: string): void { + const index = this.block.props.columns.findIndex(v => v.id === _id); + if (index >= 0) { + this.block.props.columns.splice(index, 1); + } + } + + propertyDuplicate(_columnId: string): string | undefined { + throw new Error('Method not implemented.'); + } + + propertyMetaGet(type: string): PropertyMetaConfig { + const meta = this.columnMetaMap.get(type); + if (meta) { + return meta; + } + return queryBlockAllColumnMap[type]; + } + + propertyNameGet(propertyId: string): string { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return viewColumn.name; + } + if (propertyId === 'type') { + return 'Block Type'; + } + return this.getProperty(propertyId)?.name ?? ''; + } + + propertyNameSet(propertyId: string, name: string): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + viewColumn.name = name; + } + } + + override propertyReadonlyGet(propertyId: string): boolean { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return false; + } + if (propertyId === 'type') return true; + return this.getProperty(propertyId)?.set == null; + } + + propertyTypeGet(propertyId: string): string { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return viewColumn.type; + } + if (propertyId === 'type') { + return 'image'; + } + return this.getProperty(propertyId).metaConfig.type; + } + + propertyTypeSet(propertyId: string, toType: string): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + const currentType = viewColumn.type; + const currentData = viewColumn.data; + const rows = this.rows$.value; + const currentCells = rows.map(rowId => + this.cellValueGet(rowId, propertyId) + ); + const convertFunction = databasePropertyConverts.find( + v => v.from === currentType && v.to === toType + )?.convert; + const result = convertFunction?.( + currentData as any, + + currentCells as any + ) ?? { + property: + DatabaseBlockDataSource.propertiesMap.value[ + toType + ].config.propertyData.default(), + cells: currentCells.map(() => undefined), + }; + this.block.store.captureSync(); + viewColumn.type = toType; + viewColumn.data = result.property; + currentCells.forEach((value, i) => { + if (value != null || result.cells[i] != null) { + this.block.props.cells[rows[i]] = { + ...this.block.props.cells[rows[i]], + [propertyId]: result.cells[i], + }; + } + }); + } + } + + rowAdd(_insertPosition: InsertToPosition | number): string { + throw new Error('Method not implemented.'); + } + + rowDelete(_ids: string[]): void { + throw new Error('Method not implemented.'); + } + + rowMove(_rowId: string, _position: InsertToPosition): void {} +} diff --git a/blocksuite/affine/blocks/data-view/src/data-view-block.ts b/blocksuite/affine/blocks/data-view/src/data-view-block.ts new file mode 100644 index 00000000..9f86a725 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/data-view-block.ts @@ -0,0 +1,326 @@ +import { + BlockRenderer, + DatabaseSelection, + NoteRenderer, +} from '@blocksuite/affine-block-database'; +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { CopyIcon, DeleteIcon } from '@blocksuite/affine-components/icons'; +import { PeekViewProvider } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; +import { + DocModeProvider, + NotificationProvider, + type TelemetryEventMap, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + createRecordDetail, + createUniComponentFromWebComponent, + type DataSource, + dataViewCommonStyle, + type DataViewProps, + DataViewRootUILogic, + type DataViewSelection, + type DataViewWidget, + type DataViewWidgetProps, + defineUniComponent, + renderUniLit, + uniMap, +} from '@blocksuite/data-view'; +import { widgetPresets } from '@blocksuite/data-view/widget-presets'; +import { MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { type BlockComponent } from '@blocksuite/std'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline'; +import { Slice } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; +import { css, nothing, unsafeCSS } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import { BlockQueryDataSource } from './data-source.js'; +import type { DataViewBlockModel } from './data-view-model.js'; + +export class DataViewBlockComponent extends CaptionedBlockComponent { + static override styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-database'))} + affine-database { + display: block; + border-radius: 8px; + background-color: var(--affine-background-primary-color); + padding: 8px; + margin: 8px -8px -8px; + } + + .database-block-selected { + background-color: var(--affine-hover-color); + border-radius: 4px; + } + + .database-ops { + padding: 2px; + border-radius: 4px; + display: flex; + cursor: pointer; + } + + .database-ops svg { + width: 16px; + height: 16px; + color: var(--affine-icon-color); + } + + .database-ops:hover { + background-color: var(--affine-hover-color); + } + + @media print { + .database-ops { + display: none; + } + + .database-header-bar { + display: none !important; + } + } + `; + + private readonly _clickDatabaseOps = (e: MouseEvent) => { + popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), { + options: { + items: [ + menu.input({ + initialValue: this.model.props.title, + placeholder: 'Untitled', + onChange: text => { + this.model.props.title = text; + }, + }), + menu.action({ + prefix: CopyIcon, + name: 'Copy', + select: () => { + const slice = Slice.fromModels(this.store, [this.model]); + this.std.clipboard.copySlice(slice).catch(console.error); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + prefix: DeleteIcon, + class: { + 'delete-item': true, + }, + name: 'Delete Database', + select: () => { + this.model.children.slice().forEach(block => { + this.store.deleteBlock(block); + }); + this.store.deleteBlock(this.model); + }, + }), + ], + }), + ], + }, + }); + }; + + private _dataSource?: DataSource; + + _bindHotkey: DataViewProps['bindHotkey'] = hotkeys => { + return { + dispose: this.host.event.bindHotkey(hotkeys, { + blockId: this.topContenteditableElement?.blockId ?? this.blockId, + }), + }; + }; + + _handleEvent: DataViewProps['handleEvent'] = (name, handler) => { + return { + dispose: this.host.event.add(name, handler, { + blockId: this.blockId, + }), + }; + }; + + headerWidget: DataViewWidget = defineUniComponent( + (props: DataViewWidgetProps) => { + return html` +
+
+
${this.model.props.title}
+ ${this.renderDatabaseOps()} +
+
+
+ ${renderUniLit(widgetPresets.viewBar, props)} +
+ ${renderUniLit(this.toolsWidget, props)} +
+ ${renderUniLit(widgetPresets.quickSettingBar, props)} +
+ `; + } + ); + + selection$ = computed(() => { + const databaseSelection = this.selection.value.find( + (selection): selection is DatabaseSelection => { + if (selection.blockId !== this.blockId) { + return false; + } + return selection instanceof DatabaseSelection; + } + ); + return databaseSelection?.viewSelection; + }); + + setSelection = (selection: DataViewSelection | undefined) => { + this.selection.setGroup( + 'note', + selection + ? [ + new DatabaseSelection({ + blockId: this.blockId, + viewSelection: selection, + }), + ] + : [] + ); + }; + + toolsWidget: DataViewWidget = widgetPresets.createTools({ + table: [ + widgetPresets.tools.filter, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + widgetPresets.tools.tableAddRow, + ], + kanban: [ + widgetPresets.tools.filter, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + ], + }); + + get dataSource(): DataSource { + if (!this._dataSource) { + this._dataSource = new BlockQueryDataSource(this.host, this.model, { + type: 'todo', + }); + } + return this._dataSource; + } + + override get topContenteditableElement() { + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return this.closest( + EDGELESS_TOP_CONTENTEDITABLE_SELECTOR + ); + } + return this.rootComponent; + } + + private renderDatabaseOps() { + if (this.store.readonly) { + return nothing; + } + return html`
+ ${MoreHorizontalIcon()} +
`; + } + + override connectedCallback() { + super.connectedCallback(); + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + private readonly dataViewRootLogic = new DataViewRootUILogic({ + virtualPadding$: signal(0), + bindHotkey: this._bindHotkey, + handleEvent: this._handleEvent, + selection$: this.selection$, + setSelection: this.setSelection, + dataSource: this.dataSource, + headerWidget: this.headerWidget, + clipboard: this.std.clipboard, + dnd: this.std.dnd, + notification: { + toast: message => { + const notification = this.std.getOptional(NotificationProvider); + if (notification) { + notification.toast(message); + } else { + toast(this.host, message); + } + }, + }, + eventTrace: (key, params) => { + const telemetryService = this.std.getOptional(TelemetryProvider); + telemetryService?.track(key, { + ...(params as TelemetryEventMap[typeof key]), + blockId: this.blockId, + }); + }, + detailPanelConfig: { + openDetailPanel: (target, data) => { + const peekViewService = this.std.getOptional(PeekViewProvider); + if (peekViewService) { + const template = createRecordDetail({ + ...data, + openDoc: () => {}, + detail: { + header: uniMap( + createUniComponentFromWebComponent(BlockRenderer), + props => ({ + ...props, + host: this.host, + }) + ), + note: uniMap( + createUniComponentFromWebComponent(NoteRenderer), + props => ({ + ...props, + model: this.model, + host: this.host, + }) + ), + }, + }); + return peekViewService.peek({ target, template }); + } else { + return Promise.resolve(); + } + }, + }, + }); + override renderBlock() { + const widgets = html`${repeat( + Object.entries(this.widgets), + ([id]) => id, + ([_, widget]) => widget + )}`; + + return html` +
+ ${this.dataViewRootLogic.render()} ${widgets} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view': DataViewBlockComponent; + } +} diff --git a/blocksuite/affine/blocks/data-view/src/data-view-model.ts b/blocksuite/affine/blocks/data-view/src/data-view-model.ts new file mode 100644 index 00000000..4551fc97 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/data-view-model.ts @@ -0,0 +1,102 @@ +import type { ColumnDataType } from '@blocksuite/affine-model'; +import { + arrayMove, + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import type { DataViewDataType } from '@blocksuite/data-view'; +import { + BlockModel, + BlockSchemaExtension, + defineBlockSchema, +} from '@blocksuite/store'; + +type Props = { + title: string; + views: DataViewDataType[]; + columns: ColumnDataType[]; + cells: Record>; +}; + +export class DataViewBlockModel extends BlockModel { + constructor() { + super(); + } + + applyViewsUpdate() { + this.store.updateBlock(this, { + views: this.props.views, + }); + } + + deleteView(id: string) { + this.store.captureSync(); + this.store.transact(() => { + this.props.views = this.props.views.filter(v => v.id !== id); + }); + } + + duplicateView(id: string): string { + const newId = this.store.workspace.idGenerator(); + this.store.transact(() => { + const index = this.props.views.findIndex(v => v.id === id); + const view = this.props.views[index]; + if (view) { + this.props.views.splice( + index + 1, + 0, + JSON.parse(JSON.stringify({ ...view, id: newId })) + ); + } + }); + return newId; + } + + moveViewTo(id: string, position: InsertToPosition) { + this.store.transact(() => { + this.props.views = arrayMove( + this.props.views, + v => v.id === id, + arr => insertPositionToIndex(position, arr) + ); + }); + this.applyViewsUpdate(); + } + + updateView( + id: string, + update: (data: DataViewDataType) => Partial + ) { + this.store.transact(() => { + this.props.views = this.props.views.map(v => { + if (v.id !== id) { + return v; + } + return { ...v, ...(update(v) as DataViewDataType) }; + }); + }); + this.applyViewsUpdate(); + } +} + +export const DataViewBlockSchema = defineBlockSchema({ + flavour: 'affine:data-view', + props: (): Props => ({ + views: [], + title: '', + columns: [], + cells: {}, + }), + metadata: { + role: 'hub', + version: 1, + parent: ['affine:note'], + children: ['affine:paragraph', 'affine:list'], + }, + toModel: () => { + return new DataViewBlockModel(); + }, +}); + +export const DataViewBlockSchemaExtension = + BlockSchemaExtension(DataViewBlockSchema); diff --git a/blocksuite/affine/blocks/data-view/src/data-view-spec.ts b/blocksuite/affine/blocks/data-view/src/data-view-spec.ts new file mode 100644 index 00000000..fd852a6f --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/data-view-spec.ts @@ -0,0 +1,8 @@ +import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; +import type { ExtensionType } from '@blocksuite/store'; +import { literal } from 'lit/static-html.js'; + +export const DataViewBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:data-view'), + BlockViewExtension('affine:data-view', literal`affine-data-view`), +]; diff --git a/blocksuite/affine/blocks/data-view/src/effects.ts b/blocksuite/affine/blocks/data-view/src/effects.ts new file mode 100644 index 00000000..0c4abb89 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/effects.ts @@ -0,0 +1,5 @@ +import { DataViewBlockComponent } from './data-view-block'; + +export function effects() { + customElements.define('affine-data-view', DataViewBlockComponent); +} diff --git a/blocksuite/affine/blocks/data-view/src/index.ts b/blocksuite/affine/blocks/data-view/src/index.ts new file mode 100644 index 00000000..62f9d514 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/index.ts @@ -0,0 +1,3 @@ +export * from './data-view-block.js'; +export * from './data-view-model.js'; +export * from './data-view-spec.js'; diff --git a/blocksuite/affine/blocks/data-view/src/store.ts b/blocksuite/affine/blocks/data-view/src/store.ts new file mode 100644 index 00000000..7f7ee559 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/store.ts @@ -0,0 +1,15 @@ +import { + type StoreExtensionContext, + StoreExtensionProvider, +} from '@blocksuite/affine-ext-loader'; + +import { DataViewBlockSchemaExtension } from './data-view-model'; + +export class DataViewStoreExtension extends StoreExtensionProvider { + override name = 'affine-data-view-block'; + + override setup(context: StoreExtensionContext) { + super.setup(context); + context.register(DataViewBlockSchemaExtension); + } +} diff --git a/blocksuite/affine/blocks/data-view/src/view.ts b/blocksuite/affine/blocks/data-view/src/view.ts new file mode 100644 index 00000000..503893c9 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/view.ts @@ -0,0 +1,28 @@ +import { + type ViewExtensionContext, + ViewExtensionProvider, +} from '@blocksuite/affine-ext-loader'; +import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; +import { literal } from 'lit/static-html.js'; + +import { DataViewBlockSchema } from './data-view-model'; +import { effects } from './effects'; + +const flavour = DataViewBlockSchema.model.flavour; + +export class DataViewViewExtension extends ViewExtensionProvider { + override name = 'affine-data-view-block'; + + override effect() { + super.effect(); + effects(); + } + + override setup(context: ViewExtensionContext) { + super.setup(context); + context.register([ + FlavourExtension(flavour), + BlockViewExtension(flavour, literal`affine-data-view`), + ]); + } +} diff --git a/blocksuite/affine/blocks/data-view/src/views/index.ts b/blocksuite/affine/blocks/data-view/src/views/index.ts new file mode 100644 index 00000000..1f8e74b2 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/src/views/index.ts @@ -0,0 +1,11 @@ +import type { ViewMeta } from '@blocksuite/data-view'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; + +export const blockQueryViews: ViewMeta[] = [ + viewPresets.tableViewMeta, + viewPresets.kanbanViewMeta, +]; + +export const blockQueryViewMap = Object.fromEntries( + blockQueryViews.map(view => [view.type, view]) +); diff --git a/blocksuite/affine/blocks/data-view/tsconfig.json b/blocksuite/affine/blocks/data-view/tsconfig.json new file mode 100644 index 00000000..8e6889a3 --- /dev/null +++ b/blocksuite/affine/blocks/data-view/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [ + { "path": "../database" }, + { "path": "../../components" }, + { "path": "../../ext-loader" }, + { "path": "../../model" }, + { "path": "../../shared" }, + { "path": "../../widgets/slash-menu" }, + { "path": "../../data-view" }, + { "path": "../../../framework/global" }, + { "path": "../../../framework/std" }, + { "path": "../../../framework/store" } + ] +} diff --git a/blocksuite/affine/blocks/database/package.json b/blocksuite/affine/blocks/database/package.json new file mode 100644 index 00000000..b8dc12f2 --- /dev/null +++ b/blocksuite/affine/blocks/database/package.json @@ -0,0 +1,49 @@ +{ + "name": "@blocksuite/affine-block-database", + "description": "Database block for BlockSuite.", + "type": "module", + "scripts": { + "build": "yarn run -T tsc" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-ext-loader": "workspace:*", + "@blocksuite/affine-inline-preset": "workspace:*", + "@blocksuite/affine-inline-reference": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-rich-text": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/affine-widget-drag-handle": "workspace:*", + "@blocksuite/affine-widget-slash-menu": "workspace:*", + "@blocksuite/data-view": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.2.17", + "@blocksuite/std": "workspace:*", + "@blocksuite/store": "workspace:*", + "@emotion/css": "^11.13.5", + "@floating-ui/dom": "^1.6.13", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.23", + "@types/mdast": "^4.0.4", + "date-fns": "^4.4.0", + "lit": "^3.2.0", + "yjs": "^13.6.27", + "zod": "^3.25.76" + }, + "exports": { + ".": "./src/index.ts", + "./store": "./src/store.ts", + "./view": "./src/view.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "version": "0.27.5" +} diff --git a/blocksuite/affine/blocks/database/src/adapters/extension.ts b/blocksuite/affine/blocks/database/src/adapters/extension.ts new file mode 100644 index 00000000..cdcc4739 --- /dev/null +++ b/blocksuite/affine/blocks/database/src/adapters/extension.ts @@ -0,0 +1,13 @@ +import type { ExtensionType } from '@blocksuite/store'; + +import { DatabaseBlockHtmlAdapterExtension } from './html.js'; +import { DatabaseBlockMarkdownAdapterExtension } from './markdown.js'; +import { DatabaseBlockNotionHtmlAdapterExtension } from './notion-html.js'; +import { DatabaseBlockPlainTextAdapterExtension } from './plain-text.js'; + +export const DatabaseBlockAdapterExtensions: ExtensionType[] = [ + DatabaseBlockHtmlAdapterExtension, + DatabaseBlockMarkdownAdapterExtension, + DatabaseBlockNotionHtmlAdapterExtension, + DatabaseBlockPlainTextAdapterExtension, +]; diff --git a/blocksuite/affine/blocks/database/src/adapters/html.ts b/blocksuite/affine/blocks/database/src/adapters/html.ts new file mode 100644 index 00000000..a14bc5d0 --- /dev/null +++ b/blocksuite/affine/blocks/database/src/adapters/html.ts @@ -0,0 +1,105 @@ +import { + type ColumnDataType, + DatabaseBlockSchema, + type SerializedCells, +} from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + type InlineHtmlAST, +} from '@blocksuite/affine-shared/adapters'; +import type { Element } from 'hast'; + +import { processTable } from './utils'; + +export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext } = context; + const columns = o.node.props.columns as Array; + const children = o.node.children; + const cells = o.node.props.cells as SerializedCells; + const table = processTable(columns, children, cells); + const createAstTableCell = ( + children: InlineHtmlAST[] + ): InlineHtmlAST => ({ + type: 'element', + tagName: 'td', + properties: Object.create(null), + children, + }); + + const createAstTableHeaderCell = ( + children: InlineHtmlAST[] + ): InlineHtmlAST => ({ + type: 'element', + tagName: 'th', + properties: Object.create(null), + children, + }); + + const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({ + type: 'element', + tagName: 'tr', + properties: Object.create(null), + children: cells, + }); + + const { deltaConverter } = context; + + const tableHeaderAst: Element = { + type: 'element', + tagName: 'thead', + properties: Object.create(null), + children: [ + createAstTableRow( + table.headers.map(v => + createAstTableHeaderCell([ + { + type: 'text', + value: v.name ?? '', + }, + ]) + ) + ), + ], + }; + + const tableBodyAst: Element = { + type: 'element', + tagName: 'tbody', + properties: Object.create(null), + children: table.rows.map(v => { + return createAstTableRow( + v.cells.map(cell => { + return createAstTableCell( + typeof cell.value === 'string' + ? [{ type: 'text', value: cell.value }] + : deltaConverter.deltaToAST(cell.value.delta) + ); + }) + ); + }), + }; + + walkerContext + .openNode({ + type: 'element', + tagName: 'table', + properties: Object.create(null), + children: [tableHeaderAst, tableBodyAst], + }) + .closeNode(); + + walkerContext.skipAllChildren(); + }, + }, +}; + +export const DatabaseBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + databaseBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/blocks/database/src/adapters/index.ts b/blocksuite/affine/blocks/database/src/adapters/index.ts new file mode 100644 index 00000000..9c9650bb --- /dev/null +++ b/blocksuite/affine/blocks/database/src/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html'; +export * from './markdown'; +export * from './notion-html'; +export * from './plain-text'; diff --git a/blocksuite/affine/blocks/database/src/adapters/markdown.ts b/blocksuite/affine/blocks/database/src/adapters/markdown.ts new file mode 100644 index 00000000..49d082cc --- /dev/null +++ b/blocksuite/affine/blocks/database/src/adapters/markdown.ts @@ -0,0 +1,67 @@ +import { + type ColumnDataType, + DatabaseBlockSchema, + type SerializedCells, +} from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import type { TableRow } from 'mdast'; + +import { processTable } from './utils'; + +const DATABASE_NODE_TYPES = new Set(['table', 'tableRow']); + +const isDatabaseNode = (node: MarkdownAST) => + DATABASE_NODE_TYPES.has(node.type); + +export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: o => isDatabaseNode(o.node), + fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + const rows: TableRow[] = []; + const columns = o.node.props.columns as Array; + const children = o.node.children; + const cells = o.node.props.cells as SerializedCells; + const table = processTable(columns, children, cells); + rows.push({ + type: 'tableRow', + children: table.headers.map(v => ({ + type: 'tableCell', + children: [{ type: 'text', value: v.name }], + })), + }); + table.rows.forEach(v => { + rows.push({ + type: 'tableRow', + children: v.cells.map(v => ({ + type: 'tableCell', + children: + typeof v.value === 'string' + ? [{ type: 'text', value: v.value }] + : deltaConverter.deltaToAST(v.value.delta), + })), + }); + }); + + walkerContext + .openNode({ + type: 'table', + children: rows, + }) + .closeNode(); + + walkerContext.skipAllChildren(); + }, + }, + }; + +export const DatabaseBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(databaseBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/blocks/database/src/adapters/notion-html.ts b/blocksuite/affine/blocks/database/src/adapters/notion-html.ts new file mode 100644 index 00000000..12a39f13 --- /dev/null +++ b/blocksuite/affine/blocks/database/src/adapters/notion-html.ts @@ -0,0 +1,388 @@ +import { DatabaseBlockSchema } from '@blocksuite/affine-model'; +import { + AdapterTextUtils, + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { getTagColor } from '@blocksuite/data-view'; +import { type BlockSnapshot, nanoid } from '@blocksuite/store'; + +const ColumnClassMap: Record = { + typesSelect: 'select', + typesMultipleSelect: 'multi-select', + typesNumber: 'number', + typesCheckbox: 'checkbox', + typesText: 'rich-text', + typesTitle: 'title', + typesDate: 'date', +}; + +const NotionDatabaseToken = '.collection-content'; +const NotionDatabaseTitleToken = '.collection-title'; + +type BlocksuiteTableColumn = { + type: string; + name: string; + data: { + options?: { + id: string; + value: string; + color: string; + }[]; + }; + id: string; +}; + +type BlocksuiteTableRow = Record< + string, + { + columnId: string; + value: unknown; + } +>; + +const DATABASE_NODE_TYPES = new Set(['table', 'th', 'tr']); + +export const databaseBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: o => + HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName), + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext, deltaConverter, pageMap } = context; + switch (o.node.tagName) { + case 'th': { + const columnId = nanoid(); + const columnTypeClass = HastUtils.querySelector(o.node, 'svg') + ?.properties?.className; + const columnType = Array.isArray(columnTypeClass) + ? (ColumnClassMap[columnTypeClass[0] ?? ''] ?? 'rich-text') + : 'rich-text'; + walkerContext.pushGlobalContextStack( + 'hast:table:column', + { + type: columnType, + name: HastUtils.getTextContent( + HastUtils.getTextChildrenOnlyAst(o.node) + ), + data: Object.create(null), + id: columnId, + } + ); + // disable icon img in th + walkerContext.setGlobalContext('hast:disableimg', true); + break; + } + case 'tr': { + if ( + o.parent?.node.type === 'element' && + o.parent.node.tagName === 'tbody' + ) { + const columns = + walkerContext.getGlobalContextStack( + 'hast:table:column' + ); + const row = Object.create(null); + let plainTable = false; + HastUtils.getElementChildren(o.node).forEach((child, index) => { + if (plainTable || columns[index] === undefined) { + plainTable = true; + if (columns[index] === undefined) { + columns.push({ + type: 'rich-text', + name: '', + data: Object.create(null), + id: nanoid(), + }); + walkerContext.pushGlobalContextStack( + 'hast:table:children', + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(child), + }, + type: 'text', + }, + children: [], + } + ); + } + walkerContext.pushGlobalContextStack( + 'hast:table:children', + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(child), + }, + type: 'text', + }, + children: [], + } + ); + const column = columns[index]; + if (!column) { + return; + } + row[column.id] = { + columnId: column.id, + value: HastUtils.getTextContent(child), + }; + } else if (HastUtils.querySelector(child, '.cell-title')) { + walkerContext.pushGlobalContextStack( + 'hast:table:children', + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(child, { pageMap }), + }, + type: 'text', + }, + children: [], + } + ); + columns[index].type = 'title'; + return; + } + const optionIds: string[] = []; + const column = columns[index]; + if (!column) { + return; + } + + // Check for