Compare commits

..

1 Commits

Author SHA1 Message Date
flrande
e363ba5f4f feat(editor): support edgeless code block 2025-05-19 18:15:55 +08:00
606 changed files with 8536 additions and 17187 deletions

View File

@@ -1,6 +1,3 @@
postgres
.env
compose.yml
certs/*
!certs/.gitkeep
nginx/conf.d/*
compose.yml

View File

@@ -1,27 +0,0 @@
# 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 dev.affine.fail
```
### 3. Enable dns and nginx service in compose.yml
### 4. Add custom dns server
```bash
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/dev.affine.fail
```

View File

@@ -0,0 +1,65 @@
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
mailhog:
image: mailhog/mailhog:latest
ports:
- 1025:1025
- 8025:8025
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
networks:
dev:
volumes:
postgres_data:
elasticsearch_data:

View File

@@ -27,6 +27,7 @@ services:
# https://manual.manticoresearch.com/Starting_the_server/Docker
manticoresearch:
image: manticoresearch/manticore:${MANTICORE_VERSION:-9.2.14}
restart: always
ports:
- 9308:9308
ulimits:
@@ -39,58 +40,6 @@ services:
hard: -1
volumes:
- manticoresearch_data:/var/lib/manticore
# 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
# dns:
# image: strm/dnsmasq
# volumes:
# - ./dnsmasq.conf:/etc/dnsmasq.d/local.conf
# ports:
# - "53:53/udp"
# cap_add:
# - NET_ADMIN
# depends_on:
# - nginx
# 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:
@@ -98,4 +47,3 @@ networks:
volumes:
postgres_data:
manticoresearch_data:
elasticsearch_data:

View File

@@ -1,2 +0,0 @@
log-queries
address=/dev.affine.fail/127.0.0.1

View File

@@ -1,28 +0,0 @@
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/*";
}

View File

@@ -1,27 +0,0 @@
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;
}
}

View File

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

View File

@@ -48,14 +48,14 @@
},
"queues.copilot": {
"type": "object",
"description": "The config for copilot job queue\n@default {\"concurrency\":5}",
"description": "The config for copilot job queue\n@default {\"concurrency\":1}",
"properties": {
"concurrency": {
"type": "number"
}
},
"default": {
"concurrency": 5
"concurrency": 1
}
},
"queues.doc": {
@@ -812,7 +812,7 @@
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable indexer plugin\n@default true\n@environment `AFFINE_INDEXER_ENABLED`",
"description": "Enable indexer plugin\n@default true",
"default": true
},
"provider.type": {
@@ -825,11 +825,6 @@
"description": "Indexer search service endpoint\n@default \"http://localhost:9308\"\n@environment `AFFINE_INDEXER_SEARCH_ENDPOINT`",
"default": "http://localhost:9308"
},
"provider.apiKey": {
"type": "string",
"description": "Indexer search service api key. Optional for elasticsearch\n@default \"\"\n@environment `AFFINE_INDEXER_SEARCH_API_KEY`\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 elasticsearch\n@default \"\"\n@environment `AFFINE_INDEXER_SEARCH_USERNAME`\n@link https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html",
@@ -839,11 +834,6 @@
"type": "string",
"description": "Indexer search service auth password, if not set, basic auth will be disabled. Optional for elasticsearch\n@default \"\"\n@environment `AFFINE_INDEXER_SEARCH_PASSWORD`",
"default": ""
},
"autoIndex.batchSize": {
"type": "number",
"description": "Number of workspaces automatically indexed per batch\n@default 10",
"default": 10
}
}
},
@@ -891,43 +881,13 @@
},
"providers.oidc": {
"type": "object",
"description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}\n@link https://openid.net/specs/openid-connect-core-1_0.html",
"properties": {
"clientId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"args": {
"type": "object"
}
},
"description": "OIDC OAuth provider config\n@default {\"clientId\":\"\",\"clientSecret\":\"\",\"issuer\":\"\",\"args\":{}}",
"default": {
"clientId": "",
"clientSecret": "",
"issuer": "",
"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": ""
}
}
}
},

View File

@@ -18,7 +18,8 @@ const {
STATIC_IP_NAME,
AFFINE_INDEXER_SEARCH_PROVIDER,
AFFINE_INDEXER_SEARCH_ENDPOINT,
AFFINE_INDEXER_SEARCH_API_KEY,
AFFINE_INDEXER_SEARCH_USERNAME,
AFFINE_INDEXER_SEARCH_PASSWORD,
} = process.env;
const buildType = BUILD_TYPE || 'canary';
@@ -87,7 +88,8 @@ const createHelmCommand = ({ isDryRun }) => {
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}"`,
`--set-string global.indexer.username="${AFFINE_INDEXER_SEARCH_USERNAME}"`,
`--set-string global.indexer.password="${AFFINE_INDEXER_SEARCH_PASSWORD}"`,
];
const serviceAnnotations = [
`--set-json web.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,

View File

@@ -73,11 +73,13 @@ spec:
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
- name: AFFINE_INDEXER_SEARCH_USERNAME
value: "{{ .Values.global.indexer.username }}"
- name: AFFINE_INDEXER_SEARCH_PASSWORD
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
key: indexer-password
- name: AFFINE_SERVER_PORT
value: "{{ .Values.global.docService.port }}"
- name: AFFINE_SERVER_SUB_PATH

View File

@@ -1,12 +0,0 @@
{{- if eq .Values.global.deployment.platform "gcp" -}}
apiVersion: monitoring.googleapis.com/v1
kind: ClusterPodMonitoring
metadata:
name: "{{ include "doc.fullname" . }}"
spec:
selector:
{{- include "doc.selectorLabels" . | nindent 4 }}
endpoints:
- port: 9464
interval: 30s
{{- end }}

View File

@@ -71,11 +71,13 @@ spec:
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
- name: AFFINE_INDEXER_SEARCH_USERNAME
value: "{{ .Values.global.indexer.username }}"
- name: AFFINE_INDEXER_SEARCH_PASSWORD
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
key: indexer-password
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_SUB_PATH

View File

@@ -48,11 +48,13 @@ spec:
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
- name: AFFINE_INDEXER_SEARCH_USERNAME
value: "{{ .Values.global.indexer.username }}"
- name: AFFINE_INDEXER_SEARCH_PASSWORD
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
key: indexer-password
resources:
requests:
cpu: '100m'

View File

@@ -1,12 +0,0 @@
{{- if eq .Values.global.deployment.platform "gcp" -}}
apiVersion: monitoring.googleapis.com/v1
kind: ClusterPodMonitoring
metadata:
name: "{{ include "graphql.fullname" . }}"
spec:
selector:
{{- include "graphql.selectorLabels" . | nindent 4 }}
endpoints:
- port: 9464
interval: 30s
{{- end }}

View File

@@ -73,11 +73,13 @@ spec:
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
- name: AFFINE_INDEXER_SEARCH_USERNAME
value: "{{ .Values.global.indexer.username }}"
- name: AFFINE_INDEXER_SEARCH_PASSWORD
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
key: indexer-password
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_SUB_PATH

View File

@@ -1,12 +0,0 @@
{{- if eq .Values.global.deployment.platform "gcp" -}}
apiVersion: monitoring.googleapis.com/v1
kind: ClusterPodMonitoring
metadata:
name: "{{ include "renderer.fullname" . }}"
spec:
selector:
{{- include "renderer.selectorLabels" . | nindent 4 }}
endpoints:
- port: 9464
interval: 30s
{{- end }}

View File

@@ -73,11 +73,13 @@ spec:
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
- name: AFFINE_INDEXER_SEARCH_USERNAME
value: "{{ .Values.global.indexer.username }}"
- name: AFFINE_INDEXER_SEARCH_PASSWORD
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
key: indexer-password
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_HOST

View File

@@ -1,12 +0,0 @@
{{- if eq .Values.global.deployment.platform "gcp" -}}
apiVersion: monitoring.googleapis.com/v1
kind: ClusterPodMonitoring
metadata:
name: "{{ include "sync.fullname" . }}"
spec:
selector:
{{- include "sync.selectorLabels" . | nindent 4 }}
endpoints:
- port: 9464
interval: 30s
{{- end }}

View File

@@ -1,4 +1,4 @@
{{- if .Values.global.indexer.apiKey -}}
{{- if .Values.global.indexer.password -}}
apiVersion: v1
kind: Secret
metadata:
@@ -9,5 +9,5 @@ metadata:
"helm.sh/hook-delete-policy": before-hook-creation
type: Opaque
data:
indexer-apiKey: {{ .Values.global.indexer.apiKey | b64enc }}
indexer-password: {{ .Values.global.indexer.password | b64enc }}
{{- end }}

View File

@@ -1355,13 +1355,6 @@ jobs:
target: x86_64-unknown-linux-gnu,
test: true,
}
- {
os: windows-latest,
platform: windows,
arch: x64,
target: x86_64-pc-windows-msvc,
test: true,
}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -1402,18 +1395,6 @@ jobs:
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

View File

@@ -105,7 +105,8 @@ jobs:
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 }}
AFFINE_INDEXER_SEARCH_USERNAME: ${{ secrets.AFFINE_INDEXER_SEARCH_USERNAME }}
AFFINE_INDEXER_SEARCH_PASSWORD: ${{ secrets.AFFINE_INDEXER_SEARCH_PASSWORD }}
deploy-done:
needs:

View File

@@ -252,7 +252,7 @@ jobs:
shell: bash
# node_modules of nbstore is not needed for building, and it will make the build process out of memory
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/nbstore/node_modules/@blocksuite
rm -rf packages/frontend/apps/electron/node_modules/@affine/native/node_modules
- name: package

View File

@@ -180,6 +180,7 @@ jobs:
- name: Testflight
if: ${{ env.BUILD_TYPE != 'stable' }}
working-directory: packages/frontend/apps/ios/App
continue-on-error: true
run: |
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles

2
.nvmrc
View File

@@ -1 +1 @@
22.16.0
22.15.1

View File

@@ -292,7 +292,6 @@
"version": "0.21.0",
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"msw": "^2.8.4",
"vitest": "3.1.3"
}
}

View File

@@ -2697,335 +2697,4 @@ describe('html to snapshot', () => {
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('block level element in b should not be treated as inline', async () => {
const html = template(`<b><p><span>aaa</span></p></b>`);
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(`<span style="font-weight: normal;">aaa</span>`);
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(
`<p><span style="font-weight: bold;">aaa</span><span style="font-weight: 100;">aaa</span><span style="font-weight: 500;">bbb</span><span style="font-weight: 200;">bbb</span><span style="font-weight: 600;">ccc</span><span style="font-weight: 300;">ccc</span><span style="font-weight: 700;">ddd</span></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
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(
`<p><i>aaa</i><span>aaa</span><em>bbb</em><span>bbb</span><span style="font-style: italic;">ccc</span></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
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(
`<p><u>aaa</u><span>aaa</span><span style="text-decoration: underline;">bbb</span></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
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(
`<p><del>aaa</del><span>aaa</span><span style="text-decoration: line-through;">bbb</span></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
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);
});
});

View File

@@ -4417,69 +4417,6 @@ hhh
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('should handle footnote reference with url prefix', async () => {
const 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: 'https://example.com',
attributes: {
link: 'https://example.com',
},
},
{
insert: ' ',
},
{
insert: ' ',
attributes: {
footnote: {
label: '1',
reference: {
type: 'url',
url,
favicon,
title,
description,
},
},
},
},
],
},
},
children: [],
},
],
};
const markdown = `https://example.com[^1]\n\n[^1]: {"type":"url","url":"${url}","favicon":"${favicon}","title":"${title}","description":"${description}"}\n`;
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
});
test('should not wrap url with angle brackets if it is not a url', async () => {

View File

@@ -1,14 +1,11 @@
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 { describe, expect, test } from 'vitest';
import { createJob } from '../utils/create-job.js';
import { getProvider } from '../utils/get-provider.js';
@@ -1198,71 +1195,43 @@ describe('notion html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
describe('image', () => {
const originalUrl =
'https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg';
test('image', async () => {
const html = `<div class="page-body">
<figure id="ed3d2ae9-62f5-433a-9049-9ddbd1c81ac5" class="image"><a
href="https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg"><img src="https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg" /></a>
</figure>
</div>`;
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 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]',
},
});
}),
];
const server = setupServer(...imageRequestHandlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
test('network image resource', async () => {
const html = `<div class="page-body">
<figure id="ed3d2ae9-62f5-433a-9049-9ddbd1c81ac5" class="image"><a
href="${originalUrl}"><img src="${originalUrl}" /></a>
</figure>
</div>`;
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: [],
},
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);
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 () => {

View File

@@ -102,9 +102,9 @@ export function getInternalViewExtensions() {
InlinePresetViewExtension,
// Widget
// order will affect the z-index of the widget
DragHandleViewExtension,
EdgelessAutoConnectViewExtension,
EdgelessToolbarViewExtension,
FrameTitleViewExtension,
KeyboardToolbarViewExtension,
LinkedDocViewExtension,
@@ -118,7 +118,6 @@ export function getInternalViewExtensions() {
EdgelessSelectedRectViewExtension,
EdgelessDraggingAreaViewExtension,
NoteSlicerViewExtension,
EdgelessToolbarViewExtension,
// Fragment
DocTitleViewExtension,

View File

@@ -22,7 +22,7 @@ import {
TelemetryProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { formatSize } from '@blocksuite/affine-shared/utils';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import {
AttachmentIcon,
ResetIcon,
@@ -316,7 +316,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
errorIcon: WarningIcon(),
icon: AttachmentIcon(),
title: name,
description: formatSize(size),
description: humanFileSize(size),
});
return { ...resolvedState, kind };

View File

@@ -13,7 +13,7 @@ import {
FileSizeLimitProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { formatSize } from '@blocksuite/affine-shared/utils';
import { humanFileSize } 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';
@@ -93,7 +93,7 @@ function hasExceeded(
const exceeded = files.some(file => file.size > maxFileSize);
if (exceeded) {
const size = formatSize(maxFileSize);
const size = humanFileSize(maxFileSize, true, 0);
toast(std.host, `You can only upload files less than ${size}`);
}

View File

@@ -11,7 +11,6 @@ import {
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';
@@ -100,12 +99,12 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
selectionManager.setGroup('note', [blockSelection]);
};
get link() {
return normalizeUrl(this.model.props.url);
}
open = () => {
window.open(this.link, '_blank');
let link = this.model.props.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {

View File

@@ -48,11 +48,7 @@ const codePreprocessor: MarkdownAdapterPreprocessor = {
}
trimmedLine = trimmedLine.trimEnd();
if (
!trimmedLine.startsWith('<') &&
!trimmedLine.endsWith('>') &&
!trimmedLine.includes(' ')
) {
if (!trimmedLine.startsWith('<') && !trimmedLine.endsWith('>')) {
// check if it is a url link and wrap it with the angle brackets
// sometimes the url includes emphasis `_` that will break URL parsing
//

View File

@@ -0,0 +1,13 @@
import { toGfxBlockComponent } from '@blocksuite/std';
import { CodeBlockComponent } from './code-block.js';
export class CodeEdgelessBlockComponent extends toGfxBlockComponent(
CodeBlockComponent
) {}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-code': CodeEdgelessBlockComponent;
}
}

View File

@@ -4,7 +4,6 @@ import type {
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';
@@ -34,8 +33,8 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
}
.code-toolbar-button {
color: ${unsafeCSSVarV2('icon/primary')};
background-color: ${unsafeCSSVarV2('segment/background')};
color: var(--affine-icon-color);
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
border-radius: 4px;
}

View File

@@ -19,6 +19,8 @@ export class LanguageListButton extends WithDisposable(
) {
static override styles = css`
.lang-button {
background-color: var(--affine-background-primary-color);
box-shadow: var(--affine-shadow-1);
display: flex;
gap: 4px;
padding: 2px 4px;
@@ -26,11 +28,11 @@ export class LanguageListButton extends WithDisposable(
}
.lang-button:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
background: var(--affine-hover-color-filled);
}
.lang-button[hover] {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
background: var(--affine-hover-color-filled);
}
.lang-button-icon {

View File

@@ -1,4 +1,5 @@
import { CodeBlockComponent } from './code-block';
import { CodeEdgelessBlockComponent } from './code-edgeless-block';
import {
AFFINE_CODE_TOOLBAR_WIDGET,
AffineCodeToolbarWidget,
@@ -14,6 +15,7 @@ export function effects() {
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
customElements.define('affine-code-unit', AffineCodeUnit);
customElements.define('affine-code', CodeBlockComponent);
customElements.define('affine-edgeless-code', CodeEdgelessBlockComponent);
customElements.define('preview-button', PreviewButton);
}

View File

@@ -2,10 +2,6 @@ import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
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);

View File

@@ -41,7 +41,11 @@ export class CodeBlockViewExtension extends ViewExtensionProvider {
context.register([
FlavourExtension('affine:code'),
CodeBlockHighlighter,
BlockViewExtension('affine:code', literal`affine-code`),
BlockViewExtension('affine:code', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-edgeless-code`
: literal`affine-code`;
}),
SlashMenuConfigExtension('affine:code', codeSlashMenuConfig),
CodeKeymapExtension,
...getCodeClipboardExtensions(),

View File

@@ -26,7 +26,6 @@ import {
GfxViewInteractionExtension,
type SelectedContext,
} from '@blocksuite/std/gfx';
import { computed } from '@preact/signals-core';
import { css, html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
@@ -83,23 +82,6 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
});
}
private readonly _style$ = computed(() => {
const {
color$: { value: color },
fontFamily$: { value: fontFamily },
fontStyle$: { value: fontStyle },
fontWeight$: { value: fontWeight },
textAlign$: { value: textAlign },
} = this.model.props;
return {
color,
fontFamily,
fontStyle,
fontWeight,
textAlign,
};
});
checkWidthOverflow(width: number) {
let wValid = true;
@@ -383,7 +365,7 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
override renderPageContent() {
const { color, fontFamily, fontStyle, fontWeight, textAlign } =
this._style$.value;
this.model.props;
const themeProvider = this.std.get(ThemeProvider);
const textColor = themeProvider.generateColorProperty(
color,

View File

@@ -259,7 +259,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
);
}
private readonly _handleDoubleClick = (event: MouseEvent) => {
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
const openDocService = this.std.get(OpenDocExtensionIdentifier);
const shouldOpenInPeek =
@@ -270,7 +270,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
: 'open-in-active-view',
event,
});
};
}
private _isDocEmpty() {
const linkedDoc = this.linkedDoc;
@@ -311,7 +311,6 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
.citationIdentifier=${footnoteIdentifier}
.active=${this.selected$.value}
.onClickCallback=${this._handleClick}
.onDoubleClickCallback=${this._handleDoubleClick}
></affine-citation-card>
</div> `;
};

View File

@@ -20,6 +20,7 @@ import { choose } from 'lit/directives/choose.js';
import { 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 { EmbedSyncedDocConfigExtension } from './configs';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block';
@@ -122,18 +123,22 @@ export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock(
<div class="affine-embed-synced-doc-edgeless-header-wrapper">
${header}
</div>
<div class="affine-embed-synced-doc-editor">
${this.isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard([editorMode, syncedDoc], renderEditor)}
</div>
<div class="affine-embed-synced-doc-editor-overlay"></div>
${when(
!this.model.isFolded,
() =>
html`<div class="affine-embed-synced-doc-editor">
${this.isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard([editorMode, syncedDoc], renderEditor)}
</div>
<div class="affine-embed-synced-doc-editor-overlay"></div>`
)}
</div>
`
);

View File

@@ -160,30 +160,19 @@ const builtinSurfaceToolbarConfig = {
background => resolveColor(background, theme)
) ?? DefaultTheme.transparent;
const onPick = (e: PickColorEvent) => {
switch (e.type) {
case 'pick':
{
const color = e.detail.value;
const props = packColor(field, color);
const crud = ctx.std.get(EdgelessCRUDIdentifier);
models.forEach(model => {
crud.updateElement(model.id, props);
});
}
break;
case 'start':
ctx.store.captureSync();
models.forEach(model => {
model.stash(field);
});
break;
case 'end':
ctx.store.transact(() => {
models.forEach(model => {
model.pop(field);
});
});
break;
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
for (const model of models) {
model[e.type === 'start' ? 'stash' : 'pop'](field);
}
};

View File

@@ -9,7 +9,7 @@ import {
ThemeProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { formatSize } from '@blocksuite/affine-shared/utils';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit';
import { BlockSelection } from '@blocksuite/std';
@@ -142,7 +142,7 @@ export class ImageBlockComponent extends CaptionedBlockComponent<ImageBlockModel
errorIcon: BrokenImageIcon(),
icon: ImageIcon(),
title: 'Image',
description: formatSize(size),
description: humanFileSize(size),
});
return html`

View File

@@ -8,7 +8,7 @@ import {
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { formatSize } from '@blocksuite/affine-shared/utils';
import { humanFileSize } from '@blocksuite/affine-shared/utils';
import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit';
import { GfxBlockComponent } from '@blocksuite/std';
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
@@ -128,7 +128,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
errorIcon: BrokenImageIcon(),
icon: ImageIcon(),
title: 'Image',
description: formatSize(size),
description: humanFileSize(size),
});
return html`

View File

@@ -11,8 +11,8 @@ import {
NativeClipboardProvider,
} from '@blocksuite/affine-shared/services';
import {
formatSize,
getBlockProps,
humanFileSize,
isInsidePageEditor,
readImageSize,
transformModel,
@@ -241,7 +241,7 @@ function hasExceeded(
const exceeded = files.some(file => file.size > maxFileSize);
if (exceeded) {
const size = formatSize(maxFileSize);
const size = humanFileSize(maxFileSize, true, 0);
toast(std.host, `You can only upload files less than ${size}`);
}

View File

@@ -40,12 +40,12 @@ export class EdgelessNoteStylePanel extends SignalWatcher(
@property({ attribute: false })
accessor std!: BlockStdScope;
@query('.edgeless-note-style-panel')
private accessor _panel!: HTMLDivElement;
@state()
accessor tabType: 'style' | 'customColor' = 'style';
@query('div.edgeless-note-style-panel-container')
accessor container!: HTMLDivElement;
static override styles = css`
.edgeless-note-style-panel {
display: flex;
@@ -187,32 +187,7 @@ export class EdgelessNoteStylePanel extends SignalWatcher(
};
private readonly _pickColor = (e: PickColorEvent) => {
switch (e.type) {
case 'pick':
{
const color = e.detail.value;
const crud = this.std.get(EdgelessCRUDIdentifier);
this.notes.forEach(note => {
crud.updateElement(note.id, {
background: color,
} satisfies Partial<NoteProps>);
});
}
break;
case 'start':
this._beforeChange();
this.notes.forEach(note => {
note.stash('background');
});
break;
case 'end':
this.std.store.transact(() => {
this.notes.forEach(note => {
note.pop('background');
});
});
break;
}
console.log(e);
};
private readonly _selectShadow = (e: CustomEvent<NoteShadow>) => {
@@ -290,7 +265,7 @@ export class EdgelessNoteStylePanel extends SignalWatcher(
};
private _renderStylePanel() {
return html`<div class="edgeless-note-style-panel">
return html` <div class="edgeless-note-style-panel">
<div class="edgeless-note-style-section">
<div class="edgeless-note-style-section-title">Fill color</div>
<edgeless-color-panel
@@ -394,11 +369,9 @@ export class EdgelessNoteStylePanel extends SignalWatcher(
}
override firstUpdated() {
if (this.container) {
this.disposables.addFromEvent(this.container, 'click', e => {
e.stopPropagation();
});
}
this.disposables.addFromEvent(this._panel, 'click', e => {
e.stopPropagation();
});
}
override render() {
@@ -410,18 +383,11 @@ export class EdgelessNoteStylePanel extends SignalWatcher(
${PaletteIcon()}
</editor-icon-button>
`}
@toggle=${(e: CustomEvent<boolean>) => {
if (!e.detail) {
this.tabType = 'style';
}
}}
>
<div class="edgeless-note-style-panel-container">
${choose(this.tabType, [
['style', () => this._renderStylePanel()],
['customColor', () => this._renderCustomColorPanel()],
])}
</div>
${choose(this.tabType, [
['style', () => this._renderStylePanel()],
['customColor', () => this._renderCustomColorPanel()],
])}
</editor-menu-button>
`;
}

View File

@@ -8,7 +8,6 @@ import {
EdgelessTextBlockModel,
ImageBlockModel,
ListBlockModel,
NoteBlockModel,
ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
@@ -20,6 +19,7 @@ import { EMBED_BLOCK_MODEL_LIST } from '@blocksuite/affine-shared/consts';
import type { ExtendedModel } from '@blocksuite/affine-shared/types';
import {
focusTitle,
getDocTitleInlineEditor,
getPrevContentBlock,
matchModels,
} from '@blocksuite/affine-shared/utils';
@@ -122,39 +122,41 @@ function handleNoPreviousSibling(editorHost: EditorHost, model: ExtendedModel) {
const text = model.text;
const parent = doc.getParent(model);
if (!parent) return false;
if (matchModels(parent, [NoteBlockModel]) && parent.isPageBlock()) {
const rootModel = model.store.root as RootBlockModel;
const title = rootModel.props.title;
doc.captureSync();
let textLength = 0;
if (text) {
textLength = text.length;
title.join(text);
}
// Preserve at least one block to be able to focus on container click
if (doc.getNext(model) || model.children.length > 0) {
const titleEditor = getDocTitleInlineEditor(editorHost);
// Probably no title, e.g. in edgeless mode
if (!titleEditor) {
if (
matchModels(parent, [EdgelessTextBlockModel]) ||
model.children.length > 0
) {
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
} else {
text?.clear();
return true;
}
focusTitle(editorHost, title.length - textLength);
return true;
return false;
}
if (
matchModels(parent, [EdgelessTextBlockModel]) ||
model.children.length > 0
) {
const rootModel = model.store.root as RootBlockModel;
const title = rootModel.props.title;
doc.captureSync();
let textLength = 0;
if (text) {
textLength = text.length;
title.join(text);
}
// Preserve at least one block to be able to focus on container click
if (doc.getNext(model) || model.children.length > 0) {
const parent = doc.getParent(model);
if (!parent) return false;
doc.deleteBlock(model, {
bringChildrenTo: parent,
});
return true;
} else {
text?.clear();
}
return false;
focusTitle(editorHost, title.length - textLength);
return true;
}

View File

@@ -209,10 +209,9 @@ export class EdgelessClipboardController extends PageClipboard {
await addImages(this.std, imageFiles, {
point,
maxWidth: MAX_IMAGE_WIDTH,
shouldTransformPoint: false,
});
} else {
await addAttachments(this.std, [...files], point, false);
await addAttachments(this.std, [...files], point);
}
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
@@ -228,7 +227,11 @@ export class EdgelessClipboardController extends PageClipboard {
if (isUrlInClipboard(data)) {
const url = data.getData('text/plain');
const { x, y } = this.toolManager.lastMousePos$.peek();
const lastMousePos = this.toolManager.lastMousePos$.peek();
const [x, y] = this.gfx.viewport.toModelCoord(
lastMousePos.x,
lastMousePos.y
);
// try to interpret url as affine doc url
const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider);
@@ -559,7 +562,11 @@ export class EdgelessClipboardController extends PageClipboard {
}
private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) {
const { x, y } = this.toolManager.lastMousePos$.peek();
const lastMousePos = this.toolManager.lastMousePos$.peek();
const [x, y] = this.gfx.viewport.toModelCoord(
lastMousePos.x,
lastMousePos.y
);
const noteProps = {
xywh: new Bound(

View File

@@ -52,7 +52,9 @@ export const createElementsFromClipboardDataCommand: Command<Input, Output> = (
let oldCommonBound, pasteX, pasteY;
{
const lastMousePos = toolManager.lastMousePos$.peek();
pasteCenter = pasteCenter ?? [lastMousePos.x, lastMousePos.y];
pasteCenter =
pasteCenter ??
gfx.viewport.toModelCoord(lastMousePos.x, lastMousePos.y);
const [modelX, modelY] = pasteCenter;
oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData);

View File

@@ -31,7 +31,7 @@ import { mountShapeTextEditor, ShapeTool } from '@blocksuite/affine-gfx-shape';
import { TextTool } from '@blocksuite/affine-gfx-text';
import {
ConnectorElementModel,
type ConnectorMode,
ConnectorMode,
EdgelessTextBlockModel,
GroupElementModel,
LayoutType,
@@ -93,18 +93,10 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
this._setEdgelessTool(TextTool);
},
c: () => {
const editPropsStore = this.std.get(EditPropsStore);
let mode: ConnectorMode;
if (
this.gfx.tool.currentToolName$.peek() === ConnectorTool.toolName
) {
mode = this.gfx.tool.get(ConnectorTool).getNextMode();
editPropsStore.recordLastProps('connector', { mode });
} else {
mode = editPropsStore.lastProps$.peek().connector.mode;
}
const mode = ConnectorMode.Curve;
rootComponent.std.get(EditPropsStore).recordLastProps('connector', {
mode,
});
this._setEdgelessTool(ConnectorTool, { mode });
},
h: () => {

View File

@@ -1,4 +1,3 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon } from '@blocksuite/icons/lit';
@@ -8,7 +7,7 @@ import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { DarkDeletedSmallBanner, LightDeletedSmallBanner } from '../icons';
import { SurfaceRefNotFoundBackground } from '../icons';
import { getReferenceModelTitle, TYPE_ICON_MAP } from '../utils';
export class SurfaceRefPlaceHolder extends SignalWatcher(
@@ -71,9 +70,6 @@ export class SurfaceRefPlaceHolder extends SignalWatcher(
@property({ attribute: false })
accessor inEdgeless = false;
@property({ attribute: false })
accessor theme: ColorScheme = ColorScheme.Light;
override render() {
const { referenceModel, refFlavour, inEdgeless } = this;
@@ -87,11 +83,6 @@ export class SurfaceRefPlaceHolder extends SignalWatcher(
(referenceModel && getReferenceModelTitle(referenceModel)) ??
matchedType.name;
const notFoundBackground =
this.theme === ColorScheme.Light
? LightDeletedSmallBanner
: DarkDeletedSmallBanner;
return html`
<div
class=${classMap({
@@ -101,7 +92,7 @@ export class SurfaceRefPlaceHolder extends SignalWatcher(
>
${modelNotFound
? html`<div class="surface-ref-not-found-background">
${notFoundBackground}
${SurfaceRefNotFoundBackground}
</div>`
: nothing}
<div class="surface-ref-placeholder-heading">

View File

@@ -1,211 +1,105 @@
import { html } from 'lit';
export const LightDeletedSmallBanner = html`<svg
width="204"
height="66"
viewBox="0 0 204 66"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_d_3075_418)">
<rect width="53" height="66" transform="translate(49 22)" fill="white" />
<rect
x="57.0168"
y="30.8"
width="26.0545"
height="3.85"
rx="1.925"
fill="black"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="38.5"
width="36.9664"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="44"
width="19.5409"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="49.5"
width="36.9664"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="55"
width="19.5409"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.1"
/>
</g>
<path
d="M157.341 17.6783L144.153 14.3561L144.708 12.2671C145.628 8.80601 143.153 5.189 139.18 4.18818L129.588 1.77201C125.616 0.771194 121.65 2.7656 120.73 6.22672L120.175 8.31566L106.987 4.99344C103.676 4.15945 100.371 5.82152 99.6047 8.70569L98.4946 12.8836C98.188 14.0373 99.013 15.2429 100.337 15.5766L157.885 30.0735C159.209 30.4072 160.531 29.7424 160.837 28.5886L161.948 24.4108C162.714 21.5266 160.651 18.5123 157.341 17.6783ZM125.525 7.4348C125.831 6.28327 127.157 5.61692 128.478 5.9499L138.07 8.36606C139.391 8.69904 140.218 9.90752 139.912 11.059L139.357 13.148L124.97 9.52375L125.525 7.4348Z"
fill="#E6E6E6"
/>
<path
d="M98.6798 34.2639C98.2253 34.2631 97.8625 34.6108 97.8834 35.0271L99.9152 75.4671C100.103 79.2098 103.451 82.1461 107.536 82.1528L146.222 82.216C150.307 82.2227 153.665 79.2973 153.866 75.5553L156.037 35.1222C156.06 34.7059 155.698 34.3571 155.243 34.3563L98.6798 34.2639ZM137.141 40.1651C137.143 38.8748 138.285 37.8316 139.693 37.8339C141.1 37.8362 142.238 38.8831 142.236 40.1734L142.183 70.5327C142.181 71.823 141.039 72.8662 139.632 72.8639C138.225 72.8616 137.086 71.8147 137.089 70.5244L137.141 40.1651ZM124.404 40.1443C124.406 38.854 125.548 37.8108 126.956 37.8131C128.363 37.8154 129.501 38.8623 129.499 40.1526L129.447 70.5119C129.444 71.8022 128.303 72.8454 126.895 72.8431C125.488 72.8408 124.349 71.7938 124.352 70.5036L124.404 40.1443ZM111.667 40.1234C111.669 38.8332 112.811 37.79 114.219 37.7923C115.626 37.7946 116.764 38.8415 116.762 40.1318L116.71 70.4911C116.707 71.7813 115.566 72.8245 114.158 72.8222C112.751 72.8199 111.613 71.773 111.615 70.4827L111.667 40.1234Z"
fill="#E6E6E6"
/>
<defs>
<filter
id="filter0_d_3075_418"
x="46"
y="19"
width="59"
height="72"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
export const SurfaceRefNotFoundBackground = html`
<svg
width="204"
height="66"
viewBox="0 0 204 66"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_d_877_26)">
<rect width="53" height="66" transform="translate(49 22)" fill="white" />
<rect
x="57.0168"
y="30.8"
width="26.0545"
height="3.85"
rx="1.925"
fill="black"
fill-opacity="0.07"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.1 0"
<rect
x="57.0168"
y="38.5"
width="36.9664"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.07"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3075_418"
<rect
x="57.0168"
y="44"
width="19.5409"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.07"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3075_418"
result="shape"
<rect
x="57.0168"
y="49.5"
width="36.9664"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.07"
/>
</filter>
</defs>
</svg> `;
export const DarkDeletedSmallBanner = html`<svg
width="204"
height="66"
viewBox="0 0 204 66"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_d_3075_12843)">
<rect
width="53"
height="66"
transform="translate(49 22)"
fill="white"
fill-opacity="0.08"
<rect
x="57.0168"
y="55"
width="19.5409"
height="2.2"
rx="1.1"
fill="black"
fill-opacity="0.07"
/>
</g>
<path
d="M157.341 17.6783L144.153 14.356L144.708 12.2671C145.628 8.80598 143.153 5.18897 139.18 4.18815L129.589 1.77198C125.616 0.771163 121.65 2.76557 120.73 6.22669L120.175 8.31563L106.987 4.99341C103.676 4.15942 100.371 5.82148 99.6048 8.70566L98.4947 12.8835C98.1881 14.0373 99.0131 15.2429 100.337 15.5765L157.885 30.0735C159.209 30.4071 160.531 29.7424 160.837 28.5886L161.948 24.4107C162.714 21.5266 160.651 18.5123 157.341 17.6783ZM125.526 7.43477C125.831 6.28324 127.157 5.61689 128.478 5.94987L138.07 8.36603C139.391 8.69901 140.218 9.90749 139.912 11.059L139.357 13.148L124.97 9.52372L125.526 7.43477Z"
fill="#E6E6E6"
/>
<rect
x="57.0168"
y="30.8"
width="26.0545"
height="3.85"
rx="1.925"
fill="white"
fill-opacity="0.1"
<path
d="M98.6798 34.2638C98.2253 34.2631 97.8625 34.6108 97.8834 35.0271L99.9152 75.4671C100.103 79.2097 103.451 82.1461 107.536 82.1527L146.222 82.216C150.307 82.2226 153.665 79.2973 153.866 75.5552L156.037 35.1221C156.06 34.7059 155.698 34.357 155.243 34.3563L98.6798 34.2638ZM137.141 40.1651C137.143 38.8748 138.285 37.8316 139.693 37.8339C141.1 37.8362 142.238 38.8831 142.236 40.1734L142.183 70.5327C142.181 71.8229 141.039 72.8661 139.632 72.8638C138.225 72.8615 137.086 71.8146 137.089 70.5244L137.141 40.1651ZM124.404 40.1442C124.406 38.854 125.548 37.8108 126.956 37.8131C128.363 37.8154 129.501 38.8623 129.499 40.1526L129.447 70.5119C129.444 71.8021 128.303 72.8453 126.895 72.843C125.488 72.8407 124.349 71.7938 124.352 70.5035L124.404 40.1442ZM111.667 40.1234C111.669 38.8331 112.811 37.7899 114.219 37.7922C115.626 37.7945 116.764 38.8415 116.762 40.1317L116.71 70.491C116.707 71.7813 115.566 72.8245 114.158 72.8222C112.751 72.8199 111.613 71.773 111.615 70.4827L111.667 40.1234Z"
fill="#E6E6E6"
/>
<rect
x="57.0168"
y="38.5"
width="36.9664"
height="2.2"
rx="1.1"
fill="white"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="44"
width="19.5409"
height="2.2"
rx="1.1"
fill="white"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="49.5"
width="36.9664"
height="2.2"
rx="1.1"
fill="white"
fill-opacity="0.1"
/>
<rect
x="57.0168"
y="55"
width="19.5409"
height="2.2"
rx="1.1"
fill="white"
fill-opacity="0.1"
/>
</g>
<path
d="M157.341 17.6783L144.153 14.3561L144.708 12.2671C145.628 8.80601 143.153 5.189 139.18 4.18818L129.588 1.77201C125.616 0.771194 121.65 2.7656 120.73 6.22672L120.175 8.31566L106.987 4.99344C103.676 4.15945 100.371 5.82152 99.6048 8.70569L98.4946 12.8836C98.1881 14.0373 99.013 15.2429 100.337 15.5766L157.885 30.0735C159.209 30.4072 160.531 29.7424 160.837 28.5886L161.948 24.4108C162.714 21.5266 160.651 18.5123 157.341 17.6783ZM125.525 7.4348C125.831 6.28327 127.157 5.61692 128.478 5.9499L138.07 8.36606C139.391 8.69904 140.218 9.90752 139.912 11.059L139.357 13.148L124.97 9.52375L125.525 7.4348Z"
fill="#646464"
/>
<path
d="M98.6798 34.2639C98.2253 34.2631 97.8625 34.6108 97.8834 35.0271L99.9152 75.4671C100.103 79.2098 103.451 82.1461 107.536 82.1528L146.222 82.216C150.307 82.2227 153.665 79.2973 153.866 75.5553L156.037 35.1222C156.06 34.7059 155.698 34.3571 155.243 34.3563L98.6798 34.2639ZM137.141 40.1651C137.143 38.8748 138.285 37.8316 139.693 37.8339C141.1 37.8362 142.238 38.8831 142.236 40.1734L142.183 70.5327C142.181 71.823 141.04 72.8662 139.632 72.8639C138.225 72.8616 137.086 71.8147 137.089 70.5244L137.141 40.1651ZM124.404 40.1443C124.406 38.854 125.548 37.8108 126.956 37.8131C128.363 37.8154 129.501 38.8623 129.499 40.1526L129.447 70.5119C129.444 71.8022 128.303 72.8454 126.895 72.8431C125.488 72.8408 124.35 71.7938 124.352 70.5036L124.404 40.1443ZM111.667 40.1234C111.669 38.8332 112.811 37.79 114.219 37.7923C115.626 37.7946 116.764 38.8415 116.762 40.1318L116.71 70.4911C116.707 71.7813 115.566 72.8245 114.158 72.8222C112.751 72.8199 111.613 71.773 111.615 70.4827L111.667 40.1234Z"
fill="#646464"
/>
<defs>
<filter
id="filter0_d_3075_12843"
x="46"
y="19"
width="59"
height="72"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_3075_12843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_3075_12843"
result="shape"
/>
</filter>
</defs>
</svg> `;
<defs>
<filter
id="filter0_d_877_26"
x="46"
y="19"
width="59"
height="72"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_877_26"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_877_26"
result="shape"
/>
</filter>
</defs>
</svg>
`;

View File

@@ -374,7 +374,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
const { w, h } = Bound.deserialize(this._referenceXYWH$.value);
const aspectRatio = h !== 0 ? w / h : 1;
const _previewSpec = this._previewSpec.concat(this._runtimePreviewExt);
const edgelessTheme = this.std.get(ThemeProvider).edgeless$.value;
return html`<div class="ref-content">
<div
@@ -382,7 +381,6 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
style=${styleMap({
aspectRatio: `${aspectRatio}`,
})}
data-theme=${edgelessTheme}
>
${guard(this._previewDoc, () => {
return this._previewDoc
@@ -442,14 +440,13 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
const { _referencedModel, model } = this;
const isEmpty = !_referencedModel || !_referencedModel.xywh;
const theme = this.std.get(ThemeProvider).theme$.value;
const content = isEmpty
? html`<surface-ref-placeholder
.referenceModel=${_referencedModel}
.refFlavour=${model.props.refFlavour$.value}
.theme=${theme}
></surface-ref-placeholder>`
: this._renderRefContent();
const edgelessTheme = this.std.get(ThemeProvider).edgeless$.value;
return html`
<div
@@ -457,6 +454,7 @@ export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockMode
'affine-surface-ref': true,
focused: this.selected$.value,
})}
data-theme=${edgelessTheme}
@click=${this._handleClick}
>
${content}

View File

@@ -26,7 +26,7 @@ export class ToolOverlay extends Overlay {
this.gfx.viewport.viewportUpdated.pipe(startWith(null)).subscribe(() => {
// when viewport is updated, we should keep the overlay in the same position
// to get last mouse position and convert it to model coordinates
const pos = this.gfx.tool.lastMouseViewPos$.value;
const pos = this.gfx.tool.lastMousePos$.value;
const [x, y] = this.gfx.viewport.toModelCoord(pos.x, pos.y);
this.x = x;
this.y = y;

View File

@@ -28,6 +28,7 @@ export const SurfaceBlockSchema = defineBlockSchema({
'affine:attachment',
'affine:embed-*',
'affine:edgeless-text',
'affine:code',
],
},
transformer: transformerConfigs =>

View File

@@ -26,7 +26,9 @@ export enum DefaultModeDragType {
export class DefaultTool extends BaseTool {
static override toolName: string = 'default';
private _edgeScrollingTimer: number | null = null;
private _accumulateDelta: IVec = [0, 0];
private _autoPanTimer: number | null = null;
private readonly _clearDisposable = () => {
if (this._disposables) {
@@ -36,17 +38,19 @@ export class DefaultTool extends BaseTool {
};
private readonly _clearSelectingState = () => {
this._stopEdgeScrolling();
this._stopAutoPanning();
this._clearDisposable();
};
private _disposables: DisposableGroup | null = null;
private _scrollViewport(delta: IVec) {
private _panViewport(delta: IVec) {
this._accumulateDelta[0] += delta[0];
this._accumulateDelta[1] += delta[1];
this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]);
}
private _spaceTranslationRect: null | {
private _selectionRectTransition: null | {
w: number;
h: number;
startX: number;
@@ -55,43 +59,61 @@ export class DefaultTool extends BaseTool {
endY: number;
} = null;
private readonly _enableEdgeScrolling = (delta: IVec) => {
this._stopEdgeScrolling();
this._scrollViewport(delta);
private readonly _startAutoPanning = (delta: IVec) => {
this._panViewport(delta);
this._updateSelectingState(delta);
this._stopAutoPanning();
this._edgeScrollingTimer = window.setInterval(() => {
this._scrollViewport(delta);
this._autoPanTimer = window.setInterval(() => {
this._panViewport(delta);
this._updateSelectingState(delta);
}, 30);
};
private readonly _stopEdgeScrolling = () => {
if (this._edgeScrollingTimer) {
clearInterval(this._edgeScrollingTimer);
this._edgeScrollingTimer = null;
private readonly _stopAutoPanning = () => {
if (this._autoPanTimer) {
clearTimeout(this._autoPanTimer);
this._autoPanTimer = null;
}
};
private _toBeMoved: GfxModel[] = [];
private readonly _updateSelection = () => {
private readonly _updateSelectingState = (delta: IVec = [0, 0]) => {
const { gfx } = this;
if (gfx.keyboard.spaceKey$.peek() && this._spaceTranslationRect) {
const { w, h, startX, startY, endX, endY } = this._spaceTranslationRect;
const { endX: lastX, endY: lastY } = this.controller.draggingArea$.peek();
if (gfx.keyboard.spaceKey$.peek() && this._selectionRectTransition) {
/* Move the selection if space is pressed */
const curDraggingViewArea = this.controller.draggingViewArea$.peek();
const { w, h, startX, startY, endX, endY } =
this._selectionRectTransition;
const { endX: lastX, endY: lastY } = curDraggingViewArea;
const dx = lastX - endX;
const dy = lastY - endY;
const dx = lastX + delta[0] - endX + this._accumulateDelta[0];
const dy = lastY + delta[1] - endY + this._accumulateDelta[1];
this.controller.draggingArea$.value = {
this.controller.draggingViewArea$.value = {
...curDraggingViewArea,
x: Math.min(startX + dx, lastX),
y: Math.min(startY + dy, lastY),
w,
h,
startX: startX + dx,
startY: startY + dy,
endX: endX + dx,
endY: endY + dy,
};
} else {
const curDraggingArea = this.controller.draggingViewArea$.peek();
const newStartX = curDraggingArea.startX - delta[0];
const newStartY = curDraggingArea.startY - delta[1];
this.controller.draggingViewArea$.value = {
...curDraggingArea,
startX: newStartX,
startY: newStartY,
x: Math.min(newStartX, curDraggingArea.endX),
y: Math.min(newStartY, curDraggingArea.endY),
w: Math.abs(curDraggingArea.endX - newStartX),
h: Math.abs(curDraggingArea.endY - newStartY),
};
}
@@ -152,7 +174,7 @@ export class DefaultTool extends BaseTool {
}
private _determineDragType(evt: PointerEventState): DefaultModeDragType {
const { x, y } = this.controller.lastMousePos$.peek();
const { x, y } = this.controller.lastMouseModelPos$.peek();
if (this.selection.isInSelectedRect(x, y)) {
if (this.selection.selectedElements.length === 1) {
const currentHoveredElem = this._getElementInGroup(x, y);
@@ -221,9 +243,10 @@ export class DefaultTool extends BaseTool {
this.gfx.viewport.viewportUpdated.subscribe(() => {
if (
this.dragType === DefaultModeDragType.Selecting &&
this.controller.dragging$.peek()
this.controller.dragging$.peek() &&
!this._autoPanTimer
) {
this._updateSelection();
this._updateSelectingState();
}
})
);
@@ -257,8 +280,9 @@ export class DefaultTool extends BaseTool {
}
override deactivate() {
this._stopEdgeScrolling();
this._stopAutoPanning();
this._clearDisposable();
this._accumulateDelta = [0, 0];
}
override doubleClick(e: PointerEventState) {
@@ -299,12 +323,13 @@ export class DefaultTool extends BaseTool {
switch (this.dragType) {
case DefaultModeDragType.Selecting: {
// Record the last drag pointer position for auto panning and view port updating
this._updateSelection();
this._updateSelectingState();
const moveDelta = calPanDelta(viewport, e);
if (moveDelta) {
this._enableEdgeScrolling(moveDelta);
this._startAutoPanning(moveDelta);
} else {
this._stopEdgeScrolling();
this._stopAutoPanning();
}
break;
}
@@ -360,11 +385,18 @@ export class DefaultTool extends BaseTool {
const pressed = this.gfx.keyboard.spaceKey$.value;
if (pressed) {
const currentDraggingArea = this.controller.draggingArea$.peek();
const currentDraggingArea = this.controller.draggingViewArea$.peek();
this._spaceTranslationRect = currentDraggingArea;
this._selectionRectTransition = {
w: currentDraggingArea.w,
h: currentDraggingArea.h,
startX: currentDraggingArea.startX,
startY: currentDraggingArea.startY,
endX: currentDraggingArea.endX,
endY: currentDraggingArea.endY,
};
} else {
this._spaceTranslationRect = null;
this._selectionRectTransition = null;
}
})
);

View File

@@ -20,7 +20,7 @@ export type StateKind =
export type StateInfo = {
icon: TemplateResult;
title?: string;
description?: string | null;
description?: string;
};
export type ResolvedStateInfoPart = {

View File

@@ -1,4 +1,3 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
@@ -19,7 +18,8 @@ const styles = css`
text-indent: -9999px;
width: 38px;
height: 20px;
background: ${unsafeCSSVarV2('toggle/backgroundOff')};
background: var(--affine-icon-color);
border: 1px solid var(--affine-black-10);
display: block;
border-radius: 20px;
position: relative;
@@ -28,21 +28,22 @@ const styles = css`
label:after {
content: '';
position: absolute;
top: 2px;
left: 2px;
top: 1px;
left: 1px;
width: 16px;
height: 16px;
background: ${unsafeCSSVarV2('toggle/foreground')};
background: var(--affine-white);
border: 1px solid var(--affine-black-10);
border-radius: 16px;
transition: 0.1s;
}
label.on {
background: ${unsafeCSSVarV2('toggle/background')};
background: var(--affine-primary-color);
}
label.on:after {
left: calc(100% - 2px);
left: calc(100% - 1px);
transform: translateX(-100%);
}

View File

@@ -39,9 +39,6 @@ export const dragHandlerIndicator = css({
backgroundColor: 'var(--affine-placeholder-color)',
});
export const show = css({
opacity: '1 !important',
});
export const rowSelectCheckbox = css({
display: 'flex',
alignItems: 'center',
@@ -50,3 +47,7 @@ export const rowSelectCheckbox = css({
fontSize: '20px',
color: cssVarV2.icon.primary,
});
export const show = css({
opacity: 1,
});

View File

@@ -138,7 +138,7 @@ export class FramesSettingMenu extends WithDisposable(LitElement) {
<div class="action-label">Fill Screen</div>
<div class="toggle-button">
<toggle-switch
.on=${this.fillScreen}
.subscribe=${this.fillScreen}
.onChange=${this._onFillScreenChange}
></toggle-switch>
</div>
@@ -153,7 +153,7 @@ export class FramesSettingMenu extends WithDisposable(LitElement) {
<div class="action-label">Dark background</div>
<div class="toggle-button">
<toggle-switch
.on=${this.blackBackground}
.subscribe=${this.blackBackground}
.onChange=${this._onBlackBackgroundChange}
></toggle-switch>
</div>
@@ -162,7 +162,7 @@ export class FramesSettingMenu extends WithDisposable(LitElement) {
<div class="action-label">Hide toolbar</div>
<div class="toggle-button">
<toggle-switch
.on=${this.hideToolbar}
.subscribe=${this.hideToolbar}
.onChange=${this._onHideToolBarChange}
></toggle-switch>
</div>

View File

@@ -7,7 +7,7 @@ import { effect, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { type TocContext, tocContext } from '../config';
import { getNotesFromStore } from '../utils/query';
import { getNotesFromDoc } from '../utils/query';
import * as styles from './outline-notice.css';
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
@@ -31,7 +31,7 @@ export class OutlineNotice extends SignalWatcher(
}
const shouldShowNotice =
getNotesFromStore(this._context.editor$.value.store, [
getNotesFromDoc(this._context.editor$.value.store, [
NoteDisplayMode.DocOnly,
]).length > 0;

View File

@@ -35,7 +35,7 @@ import type {
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
import {
getHeadingBlocksFromDoc,
getNotesFromStore,
getNotesFromDoc,
isHeadingBlock,
} from '../utils/query';
import {
@@ -91,7 +91,7 @@ export class OutlinePanelBody extends SignalWatcher(
return this._context.editor$.value;
}
private get store() {
private get doc() {
return this.editor.store;
}
@@ -154,11 +154,11 @@ export class OutlinePanelBody extends SignalWatcher(
}
private _moveSelectedNotes(insertIndex: number) {
if (!this.store.root) return;
if (!this.doc.root) return;
const pageVisibleNotes = this._pageVisibleNotes$.peek();
const selected = this._allSelectedNotes$.peek();
const children = this.store.root.children.slice();
const children = this.doc.root.children.slice();
const noteIndex = new Map<NoteBlockModel, number>();
children.forEach((block, index) => {
@@ -189,14 +189,14 @@ export class OutlinePanelBody extends SignalWatcher(
const newChildren = [...leftPart, ...selected, ...rightPart];
this.store.updateBlock(this.store.root, {
this.doc.updateBlock(this.doc.root, {
children: newChildren,
});
}
private async _scrollToBlock(blockId: string) {
// if focus title
if (blockId === this.store.root?.id) {
if (blockId === this.doc.root?.id) {
this.editor.std.selection.setGroup('note', []);
this.editor.std.event.active = false;
focusTitle(this.editor);
@@ -221,7 +221,7 @@ export class OutlinePanelBody extends SignalWatcher(
const { selected, id, multiselect } = e.detail;
const gfx = this.editor.std.get(GfxControllerIdentifier);
const editorMode = this.editor.std.get(DocModeProvider).getEditorMode();
const note = this.store.getBlock(id)?.model;
const note = this.doc.getBlock(id)?.model;
if (!note || !matchModels(note, [NoteBlockModel])) return;
// map from signal to value
@@ -302,12 +302,12 @@ export class OutlinePanelBody extends SignalWatcher(
return hasHeadings || this._context.enableSorting$.value;
};
this._pageVisibleNotes$.value = getNotesFromStore(this.store, [
this._pageVisibleNotes$.value = getNotesFromDoc(this.doc, [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
]).filter(isRenderableNote);
this._edgelessOnlyNotes$.value = getNotesFromStore(this.store, [
this._edgelessOnlyNotes$.value = getNotesFromDoc(this.doc, [
NoteDisplayMode.EdgelessOnly,
]).filter(isRenderableNote);
})
@@ -379,23 +379,23 @@ export class OutlinePanelBody extends SignalWatcher(
}
private _renderDocTitle() {
if (!this.store.root) return nothing;
if (!this.doc.root) return nothing;
const hasNotEmptyHeadings =
getHeadingBlocksFromDoc(
this.store,
this.doc,
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless],
true
).length > 0;
if (!hasNotEmptyHeadings) return nothing;
const rootId = this.store.root.id;
const rootId = this.doc.root.id;
const active = rootId === this._activeHeadingId$.value;
return html`<affine-outline-block-preview
class=${classMap({ active: active })}
.block=${this.store.root}
.block=${this.doc.root}
@click=${() => {
this._scrollToBlock(rootId).catch(console.error);
}}

View File

@@ -26,7 +26,7 @@ export class OutlineNotePreviewSettingMenu extends SignalWatcher(
<div class=${styles.actionLabel}>Show type icon</div>
<div class=${styles.toggleButton}>
<toggle-switch
.on=${showPreviewIcon}
.subscribe=${showPreviewIcon}
.onChange=${() => {
this._context.showIcons$.value = !showPreviewIcon;
}}

View File

@@ -200,7 +200,6 @@ export class OutlineViewer extends SignalWatcher(
)
);
// title update
this.disposables.add(
this.editor.store.workspace.meta.docMetaUpdated.subscribe(() => {
this.requestUpdate();

View File

@@ -9,15 +9,15 @@ import type { BlockModel, Store } from '@blocksuite/store';
import { headingKeys } from '../config.js';
export function getNotesFromStore(
store: Store,
export function getNotesFromDoc(
doc: Store,
modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
NoteDisplayMode.EdgelessOnly,
]
) {
const rootModel = store.root;
const rootModel = doc.root;
if (!rootModel) return [];
const notes: NoteBlockModel[] = [];
@@ -59,7 +59,7 @@ export function getHeadingBlocksFromNote(
}
export function getHeadingBlocksFromDoc(
store: Store,
doc: Store,
modes: NoteDisplayMode[] = [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
@@ -67,6 +67,6 @@ export function getHeadingBlocksFromDoc(
],
ignoreEmpty = false
) {
const notes = getNotesFromStore(store, modes);
const notes = getNotesFromDoc(doc, modes);
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
}

View File

@@ -1,7 +1,4 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import { CanvasElementType } from '@blocksuite/affine-block-surface';
import type { BrushElementModel } from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import type { IVec } from '@blocksuite/global/gfx';
@@ -121,7 +118,7 @@ export class BrushTool extends BaseTool {
this._lastPoint = [pointX, pointY];
this._draggingPathPoints = points;
this.gfx.updateElement(this._draggingElement, {
this.gfx.updateElement(this._draggingElement!, {
points: this._tryGetPressurePoints(e),
});
@@ -170,24 +167,6 @@ export class BrushTool extends BaseTool {
this._lastPopLength = 0;
}
override click(e: PointerEventState) {
this.doc.captureSync();
const [modelX, modelY] = this.gfx.viewport.toModelCoord(
e.point.x,
e.point.y
);
const points = this._pressureSupportedPointerIds.has(e.raw.pointerId)
? [[modelX, modelY, e.pressure]]
: [[modelX, modelY]];
const crud = this.std.get(EdgelessCRUDIdentifier);
crud.addElement(CanvasElementType.BRUSH, {
points,
});
}
override activate() {
this.std.getOptional(TelemetryProvider)?.track('EdgelessToolPicked', {
page: 'whiteboard editor',

View File

@@ -68,30 +68,19 @@ export const brushToolbarConfig = {
resolveColor(color, theme)
) ?? resolveColor(DefaultTheme.black, theme);
const onPick = (e: PickColorEvent) => {
switch (e.type) {
case 'pick':
{
const color = e.detail.value;
const props = packColor(field, color);
const crud = ctx.std.get(EdgelessCRUDIdentifier);
models.forEach(model => {
crud.updateElement(model.id, props);
});
}
break;
case 'start':
ctx.store.captureSync();
models.forEach(model => {
model.stash(field);
});
break;
case 'end':
ctx.store.transact(() => {
models.forEach(model => {
model.pop(field);
});
});
break;
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
for (const model of models) {
model[e.type === 'start' ? 'stash' : 'pop'](field);
}
};

View File

@@ -3,10 +3,12 @@ import {
DefaultTool,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import {
type Connection,
type ConnectorElementModel,
import type {
Connection,
ConnectorElementModel,
ConnectorMode,
} from '@blocksuite/affine-model';
import {
GroupElementModel,
ShapeElementModel,
ShapeType,
@@ -221,15 +223,4 @@ export class ConnectorTool extends BaseTool<ConnectorToolOptions> {
this.findTargetByPoint(point);
}
getNextMode() {
switch (this.activatedOption.mode) {
case ConnectorMode.Curve:
return ConnectorMode.Orthogonal;
case ConnectorMode.Orthogonal:
return ConnectorMode.Straight;
case ConnectorMode.Straight:
return ConnectorMode.Curve;
}
}
}

View File

@@ -148,30 +148,19 @@ export const connectorToolbarConfig = {
) ?? resolveColor(DefaultTheme.connectorColor, theme);
const onPickColor = (e: PickColorEvent) => {
switch (e.type) {
case 'pick':
{
const color = e.detail.value;
const props = packColor(field, color);
const crud = ctx.std.get(EdgelessCRUDIdentifier);
models.forEach(model => {
crud.updateElement(model.id, props);
});
}
break;
case 'start':
ctx.store.captureSync();
models.forEach(model => {
model.stash(field);
});
break;
case 'end':
ctx.store.transact(() => {
models.forEach(model => {
model.pop(field);
});
});
break;
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
return;
}
for (const model of models) {
model[e.type === 'start' ? 'stash' : 'pop'](field);
}
};

View File

@@ -315,7 +315,7 @@ export class EdgelessMindmapToolButton extends EdgelessToolbarToolMixin(
}
this.setEdgelessTool(EmptyTool);
const icon = this.mindmapElement;
const { x, y } = gfx.tool.lastMouseViewPos$.peek();
const { x, y } = gfx.tool.lastMousePos$.peek();
const { viewport } = this.edgeless.std.get(ViewportElementProvider);
const { left, top } = viewport;
const clientPos = { x: x + left, y: y + top };

View File

@@ -258,7 +258,7 @@ export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin(
console.error('Edgeless toolbar Shape element not found');
return;
}
const { x, y } = this.gfx.tool.lastMouseViewPos$.peek();
const { x, y } = this.gfx.tool.lastMousePos$.peek();
const { viewport } = this.edgeless.std.get(ViewportElementProvider);
const { left, top } = viewport;
const clientPos = { x: x + left, y: y + top };

View File

@@ -117,24 +117,24 @@ export class ShapeTool extends BaseTool<ShapeToolOption> {
if (spacePressed && this._spacePressedCtx) {
const {
w,
h,
startX,
startY,
w,
h,
endX: pressedX,
endY: pressedY,
} = this._spacePressedCtx.draggingArea;
const { endX: lastX, endY: lastY } = controller.draggingArea$.peek();
const curDraggingArea = controller.draggingViewArea$.peek();
const { endX: lastX, endY: lastY } = curDraggingArea;
const dx = lastX - pressedX;
const dy = lastY - pressedY;
this.controller.draggingArea$.value = {
this.controller.draggingViewArea$.value = {
...curDraggingArea,
x: Math.min(startX + dx, lastX),
y: Math.min(startY + dy, lastY),
w,
h,
endX: endX + dx,
endY: endY + dy,
startX: startX + dx,
startY: startY + dy,
};
@@ -306,7 +306,7 @@ export class ShapeTool extends BaseTool<ShapeToolOption> {
if (spacePressed && this._draggingElementId) {
this._spacePressedCtx = {
draggingArea: this.controller.draggingArea$.peek(),
draggingArea: this.controller.draggingViewArea$.peek(),
};
}
})

View File

@@ -15,7 +15,6 @@ import {
isTransparent,
LineWidth,
MindmapElementModel,
type Palette,
resolveColor,
ShapeElementModel,
type ShapeName,
@@ -168,53 +167,56 @@ export const shapeToolbarConfig = {
const strokeStyle =
getMostCommonValue(mapped, 'strokeStyle') ?? StrokeStyle.Solid;
const pickColorWrapper =
(field: string, pickCallback: (palette: Palette) => void) =>
(e: CustomEvent<PickColorEvent>) => {
e.stopPropagation();
const onPickFillColor = (e: CustomEvent<PickColorEvent>) => {
e.stopPropagation();
switch (e.detail.type) {
case 'pick':
pickCallback(e.detail.detail);
break;
case 'start':
ctx.store.captureSync();
models.forEach(model => {
model.stash(field);
});
break;
case 'end':
ctx.store.transact(() => {
models.forEach(model => {
model.pop(field);
});
});
const d = e.detail;
const field = 'fillColor';
if (d.type === 'pick') {
const value = d.detail.value;
const filled = isTransparent(value);
for (const model of models) {
const props = packColor(field, value);
// If `filled` can be set separately, this logic can be removed
if (field && !model.filled) {
const color = getTextColor(value, filled);
Object.assign(props, { filled, color });
}
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
};
return;
}
const onPickFillColor = pickColorWrapper('fillColor', palette => {
const value = palette.value;
const filled = isTransparent(value);
const props = packColor('fillColor', value);
const crud = ctx.std.get(EdgelessCRUDIdentifier);
models.forEach(model => {
if (filled && !model.filled) {
const color = getTextColor(value, filled);
Object.assign(props, { filled, color });
for (const model of models) {
model[d.type === 'start' ? 'stash' : 'pop'](field);
}
};
const onPickStrokeColor = (e: CustomEvent<PickColorEvent>) => {
e.stopPropagation();
const d = e.detail;
const field = 'strokeColor';
if (d.type === 'pick') {
const value = d.detail.value;
for (const model of models) {
const props = packColor(field, value);
ctx.std
.get(EdgelessCRUDIdentifier)
.updateElement(model.id, props);
}
crud.updateElement(model.id, props);
});
});
const onPickStrokeColor = pickColorWrapper('strokeColor', palette => {
const value = palette.value;
const props = packColor('strokeColor', value);
const crud = ctx.std.get(EdgelessCRUDIdentifier);
models.forEach(model => {
crud.updateElement(model.id, props);
});
});
return;
}
for (const model of models) {
model[d.type === 'start' ? 'stash' : 'pop'](field);
}
};
const onPickStrokeStyle = (e: CustomEvent<LineDetailType>) => {
e.stopPropagation();

View File

@@ -6,7 +6,7 @@ import {
type IModelCoord,
TextUtils,
} from '@blocksuite/affine-block-surface';
import { DefaultTheme, TextElementModel } from '@blocksuite/affine-model';
import { TextElementModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
@@ -432,7 +432,7 @@ export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
const isEmpty = !text.length && !this._isComposition;
const color = this.std
.get(ThemeProvider)
.generateColorProperty(this.element.color, DefaultTheme.textColor);
.generateColorProperty(this.element.color, '#000000');
return html`<div
style=${styleMap({

View File

@@ -127,18 +127,18 @@ export function createTextActions<
);
if (!allowed) return null;
const mappedModels = models.map(mapInto);
const fontFamily =
getMostCommonValue(mappedModels, 'fontFamily') ?? FontFamily.Inter;
getMostCommonValue(models.map(mapInto), 'fontFamily') ??
FontFamily.Inter;
const styleInfo = { fontFamily: TextUtils.wrapFontFamily(fontFamily) };
const onPick = (fontFamily: FontFamily) => {
let fontWeight =
getMostCommonValue(mappedModels, 'fontWeight') ??
getMostCommonValue(models.map(mapInto), 'fontWeight') ??
FontWeight.Regular;
let fontStyle =
getMostCommonValue(mappedModels, 'fontStyle') ?? FontStyle.Normal;
getMostCommonValue(models.map(mapInto), 'fontStyle') ??
FontStyle.Normal;
if (!isFontWeightSupported(fontFamily, fontWeight)) {
fontWeight = FontWeight.Regular;
@@ -199,40 +199,26 @@ export function createTextActions<
? DefaultTheme.shapeTextColor
: DefaultTheme.textColor;
const mappedModels = models.map(mapInto);
const field = 'color';
const firstModel = mappedModels[0];
const originalColor = firstModel[field];
const firstModel = models[0];
const originalColor = mapInto(firstModel)[field];
const color =
getMostCommonResolvedValue(mappedModels, field, color =>
getMostCommonResolvedValue(models, field, color =>
resolveColor(color, theme)
) ?? resolveColor(defaultColor, theme);
const onPick = (e: PickColorEvent) => {
switch (e.type) {
case 'pick':
{
const color = e.detail.value;
const props = packColor(field, color);
models.forEach(model => {
update(ctx, model, props);
});
}
break;
case 'start':
ctx.store.captureSync();
models.forEach(model => {
stash(model, 'stash', field);
});
break;
case 'end':
ctx.store.transact(() => {
models.forEach(model => {
stash(model, 'pop', field);
});
});
break;
if (e.type === 'pick') {
const color = e.detail.value;
for (const model of models) {
const props = packColor(field, color);
update(ctx, model, props);
}
return;
}
for (const model of models) {
stash(model, e.type === 'start' ? 'stash' : 'pop', field);
}
};

View File

@@ -33,9 +33,6 @@
"yjs": "^13.6.21",
"zod": "^3.23.8"
},
"devDependencies": {
"vitest": "3.1.3"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",

View File

@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest';
import { preprocessFootnoteReference } from '../../adapters/markdown/preprocessor';
describe('FootnoteReferenceMarkdownPreprocessorExtension', () => {
it('should add space before footnote reference when it follows a URL', () => {
const content = 'https://example.com[^label]';
const expected = 'https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should add space before footnote reference when URL has text prefix with space', () => {
const content = 'hello world https://example.com[^label]';
const expected = 'hello world https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should add space before footnote reference when URL has text prefix with dash', () => {
const content = 'text-https://example.com[^label]';
const expected = 'text-https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not add space when footnote reference follows non-URL text', () => {
const content = 'normal text[^label]';
const expected = 'normal text[^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not add space when there is already a space before footnote reference', () => {
const content = 'https://example.com [^label]';
const expected = 'https://example.com [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle multiple footnote references with mixed URL and non-URL text', () => {
const content = 'https://example.com[^1]normal text[^2]http://test.com[^3]';
const expected =
'https://example.com [^1]normal text[^2]http://test.com [^3]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should not modify footnote definitions', () => {
const content = '[^label]: This is a footnote definition';
const expected = '[^label]: This is a footnote definition';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle content without footnote references', () => {
const content = 'This is a normal text without any footnotes';
const expected = 'This is a normal text without any footnotes';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle complex URLs with paths and parameters', () => {
const content = 'https://example.com/path?param=value[^label]';
const expected = 'https://example.com/path?param=value [^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
it('should handle invalid URLs', () => {
const content = 'not-a-url[^label]';
const expected = 'not-a-url[^label]';
expect(preprocessFootnoteReference(content)).toBe(expected);
});
});

View File

@@ -1,3 +1,2 @@
export * from './markdown/inline-delta';
export * from './markdown/markdown-inline';
export * from './markdown/preprocessor';

View File

@@ -1,54 +0,0 @@
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
/**
* Check if a string is a URL
* @param str
* @returns
*/
function isUrl(str: string): boolean {
try {
new URL(str);
return true;
} catch {
return false;
}
}
/**
* Preprocess footnote references to avoid markdown link parsing
* Only add space when footnote reference follows a URL
* @param content
* @returns
* @example
* ```md
* https://example.com[^label] -> https://example.com [^label]
* normal text[^label] -> normal text[^label]
* ```
*/
export function preprocessFootnoteReference(content: string) {
return content.replace(
/([^\s]+?)(\[\^[^\]]+\])(?!:)/g,
(match, prevText, footnoteRef) => {
// Only add space if the previous text is a URL
if (isUrl(prevText)) {
return prevText + ' ' + footnoteRef;
}
// Otherwise return the original match
return match;
}
);
}
const footnoteReferencePreprocessor: MarkdownAdapterPreprocessor = {
name: 'footnote-reference',
levels: ['block', 'slice', 'doc'],
preprocess: content => {
return preprocessFootnoteReference(content);
},
};
export const FootnoteReferenceMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(footnoteReferencePreprocessor);

View File

@@ -5,7 +5,6 @@ import {
import {
footnoteReferenceDeltaToMarkdownAdapterMatcher,
FootnoteReferenceMarkdownPreprocessorExtension,
markdownFootnoteReferenceToDeltaMatcher,
} from './adapters';
@@ -16,6 +15,5 @@ export class FootnoteStoreExtension extends StoreExtensionProvider {
super.setup(context);
context.register(markdownFootnoteReferenceToDeltaMatcher);
context.register(footnoteReferenceDeltaToMarkdownAdapterMatcher);
context.register(FootnoteReferenceMarkdownPreprocessorExtension);
}
}

View File

@@ -1,25 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
browser: {
enabled: true,
headless: true,
name: 'chromium',
provider: 'playwright',
isolate: false,
providerOptions: {},
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/footnote',
},
restoreMocks: true,
},
});

View File

@@ -7,7 +7,6 @@ import {
} from '@blocksuite/affine-shared/services';
import { affineTextStyles } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { normalizeUrl } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import type { BlockComponent, BlockStdScope } from '@blocksuite/std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/std';
@@ -121,7 +120,7 @@ export class AffineLink extends WithDisposable(ShadowlessElement) {
}
get link() {
return normalizeUrl(this.delta.attributes?.link ?? '');
return this.delta.attributes?.link ?? '';
}
get selfInlineRange() {

View File

@@ -38,7 +38,6 @@ export const builtinInlineLinkToolbarConfig = {
if (!(target instanceof AffineLink)) return null;
const { link } = target;
if (!link) return null;
return html`<affine-link-preview .url=${link}></affine-link-preview>`;
},
@@ -116,9 +115,6 @@ export const builtinInlineLinkToolbarConfig = {
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const url = target.link;
if (!url) return;
const {
block: { model },
inlineEditor,
@@ -128,6 +124,9 @@ export const builtinInlineLinkToolbarConfig = {
if (!inlineEditor || !selfInlineRange || !parent) return;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return;
// Clears
ctx.reset();
@@ -183,9 +182,6 @@ export const builtinInlineLinkToolbarConfig = {
if (!(target instanceof AffineLink)) return false;
if (!target.block) return false;
const url = target.link;
if (!url) return false;
const {
block: { model },
inlineEditor,
@@ -195,6 +191,9 @@ export const builtinInlineLinkToolbarConfig = {
if (!inlineEditor || !selfInlineRange || !parent) return false;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return false;
// check if the url can be embedded as iframe block
const embedIframeService = ctx.std.get(EmbedIframeService);
const canEmbedAsIframe = embedIframeService.canEmbed(url);
@@ -209,9 +208,6 @@ export const builtinInlineLinkToolbarConfig = {
if (!(target instanceof AffineLink)) return;
if (!target.block) return;
const url = target.link;
if (!url) return;
const {
block: { model },
inlineEditor,
@@ -221,6 +217,9 @@ export const builtinInlineLinkToolbarConfig = {
if (!inlineEditor || !selfInlineRange || !parent) return;
const url = inlineEditor.getFormat(selfInlineRange).link;
if (!url) return;
// Clears
ctx.reset();
@@ -307,7 +306,16 @@ export const builtinInlineLinkToolbarConfig = {
)
return false;
if (!target.link.startsWith('http')) return false;
const { link } = target;
try {
const url = new URL(link);
if (!url.protocol.startsWith('http')) {
return false;
}
} catch (err) {
console.error(err);
return false;
}
const { model } = target.block;
const parent = model.parent;

View File

@@ -32,122 +32,6 @@ const listElementTags = new Set(['ol', 'ul']);
const strongElementTags = new Set(['strong', 'b']);
const italicElementTags = new Set(['i', 'em']);
/**
* Check if the element is a strong element through style or tag
* If the element tag is <strong>, <b> or the style is `font-weight: bold;`, or the font-weight is 500 or above,
* we consider it as a strong element
* @param ast - The HTML AST node to check
* @returns `true` if the element is a strong element, `false` otherwise
* @example
* ```html
* <strong>Hello</strong>
* <b>Hello</b>
* <span style="font-weight: bold;">Hello</span>
* <span style="font-weight: 700;">Hello</span>
* ```
*/
const isStrongElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isStrongTag = strongElementTags.has(ast.tagName);
// Should exclude the case like <b style="font-weight: normal;">
const isNotNormalFontWeight = !/font-weight:\s*normal/.test(style);
const isBoldFontWeight = /font-weight:\s*(([5-9]\d{2})|bold)/.test(style);
return (isStrongTag && isNotNormalFontWeight) || isBoldFontWeight;
};
/**
* Check if the element is an italic element through style or tag
* If the element tag is <i>, <em> or the style is `font-style: italic;`,
* we consider it as an italic element
* @param ast - The HTML AST node to check
* @returns `true` if the element is an italic element, `false` otherwise
* @example
* ```html
* <i>Hello</i>
* <em>Hello</em>
* <span style="font-style: italic;">Hello</span>
* ```
*/
const isItalicElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isItalicTag = italicElementTags.has(ast.tagName);
const isItalicStyle = /font-style:\s*italic/.test(style);
return isItalicTag || isItalicStyle;
};
/**
* Check if the element is an underline element through style or tag
* If the element tag is <u> or the style is `text-decoration: underline;`,
* we consider it as an underline element
* @param ast - The HTML AST node to check
* @returns `true` if the element is an underline element, `false` otherwise
* @example
* ```html
* <u>Hello</u>
* <span style="text-decoration: underline;">Hello</span>
* ```
*/
const isUnderlineElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isUnderlineTag = ast.tagName === 'u';
const isUnderlineStyle = /text-decoration:\s*underline/.test(style);
return isUnderlineTag || isUnderlineStyle;
};
/**
* Check if the element is a line-through element through style or tag
* If the element tag is <del> or the style is `text-decoration: line-through;`,
* we consider it as a line-through element
* @param ast - The HTML AST node to check
* @returns `true` if the element is a line-through element, `false` otherwise
* @example
* ```html
* <del>Hello</del>
* <span style="text-decoration: line-through;">Hello</span>
* ```
*/
const isLineThroughElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
const style =
typeof ast.properties.style === 'string' ? ast.properties.style : '';
const isLineThroughTag = ast.tagName === 'del';
const isLineThroughStyle = /text-decoration:\s*line-through/.test(style);
return isLineThroughTag || isLineThroughStyle;
};
/**
* Handle the case like <span>Hello</span>
* @param ast
* @returns
*/
const isTextLikeElement = (ast: HtmlAST) => {
if (!isElement(ast)) {
return false;
}
return (
textLikeElementTags.has(ast.tagName) &&
!isStrongElement(ast) &&
!isItalicElement(ast) &&
!isUnderlineElement(ast) &&
!isLineThroughElement(ast)
);
};
export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text',
match: ast => ast.type === 'text',
@@ -175,7 +59,7 @@ export const htmlTextToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlTextLikeElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'text-like-element',
match: ast => isTextLikeElement(ast),
match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -196,7 +80,7 @@ export const htmlListToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'strong-element',
match: ast => isStrongElement(ast),
match: ast => isElement(ast) && strongElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -212,7 +96,7 @@ export const htmlStrongElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlItalicElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'italic-element',
match: ast => isItalicElement(ast),
match: ast => isElement(ast) && italicElementTags.has(ast.tagName),
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -244,7 +128,7 @@ export const htmlCodeElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'del-element',
match: ast => isLineThroughElement(ast),
match: ast => isElement(ast) && ast.tagName === 'del',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];
@@ -260,7 +144,7 @@ export const htmlDelElementToDeltaMatcher = HtmlASTToDeltaExtension({
export const htmlUnderlineElementToDeltaMatcher = HtmlASTToDeltaExtension({
name: 'underline-element',
match: ast => isUnderlineElement(ast),
match: ast => isElement(ast) && ast.tagName === 'u',
toDelta: (ast, context) => {
if (!isElement(ast)) {
return [];

View File

@@ -1,3 +1,8 @@
import {
type GfxCommonBlockProps,
GfxCompatible,
type GfxElementGeometry,
} from '@blocksuite/std/gfx';
import {
BlockModel,
BlockSchemaExtension,
@@ -14,7 +19,8 @@ type CodeBlockProps = {
caption: string;
preview?: boolean;
lineNumber?: boolean;
} & BlockMeta;
} & BlockMeta &
GfxCommonBlockProps;
export const CodeBlockSchema = defineBlockSchema({
flavour: 'affine:code',
@@ -30,6 +36,10 @@ export const CodeBlockSchema = defineBlockSchema({
'meta:createdBy': undefined,
'meta:updatedAt': undefined,
'meta:updatedBy': undefined,
xywh: '[0,0,16,16]',
index: 'a0',
scale: 1,
rotate: 0,
}) as CodeBlockProps,
metadata: {
version: 1,
@@ -39,6 +49,7 @@ export const CodeBlockSchema = defineBlockSchema({
'affine:paragraph',
'affine:list',
'affine:edgeless-text',
'affine:surface',
],
children: [],
},
@@ -47,4 +58,6 @@ export const CodeBlockSchema = defineBlockSchema({
export const CodeBlockSchemaExtension = BlockSchemaExtension(CodeBlockSchema);
export class CodeBlockModel extends BlockModel<CodeBlockProps> {}
export class CodeBlockModel
extends GfxCompatible<CodeBlockProps>(BlockModel)
implements GfxElementGeometry {}

View File

@@ -115,7 +115,7 @@ export const DefaultTheme: Theme = {
black: Black,
white: White,
transparent: Transparent,
textColor: Black,
textColor: Medium.Blue,
shapeTextColor: pureBlack,
shapeStrokeColor: Medium.Yellow,
shapeFillColor: Medium.Yellow,

View File

@@ -22,7 +22,6 @@
"@types/hast": "^3.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"bytes": "^3.1.2",
"dompurify": "^3.2.4",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
@@ -71,7 +70,6 @@
"!dist/__tests__"
],
"devDependencies": {
"@types/bytes": "^3.1.5",
"vitest": "3.1.3"
},
"version": "0.21.0"

View File

@@ -1,61 +0,0 @@
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import { unified } from 'unified';
import { describe, expect, it } from 'vitest';
import { rehypeInlineToBlock } from '../../../../adapters/html/rehype-plugins/inline-to-block';
describe('rehypeInlineToBlock', () => {
const process = (html: string) => {
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeInlineToBlock)
.use(rehypeStringify)
.processSync(html)
.toString();
};
it('should not transform inline elements without block children', () => {
const input = '<b>Hello World</b>';
const output = process(input);
expect(output).toBe('<b>Hello World</b>');
});
it('should transform inline elements containing block children', () => {
const input = '<b><p>Hello World</p></b>';
const output = process(input);
expect(output).toBe('<div data-original-tag="b"><p>Hello World</p></div>');
});
it('should preserve existing attributes when transforming', () => {
const input = '<b class="test" id="demo"><p>Hello World</p></b>';
const output = process(input);
expect(output).toBe(
'<div class="test" id="demo" data-original-tag="b"><p>Hello World</p></div>'
);
});
it('should handle multiple block elements within inline element', () => {
const input = '<b><p>First</p><div>Second</div><h1>Third</h1></b>';
const output = process(input);
expect(output).toBe(
'<div data-original-tag="b"><p>First</p><div>Second</div><h1>Third</h1></div>'
);
});
it('should handle mixed content (text and block elements)', () => {
const input = '<b>Text before<p>Block element</p>Text after</b>';
const output = process(input);
expect(output).toBe(
'<div data-original-tag="b">Text before<p>Block element</p>Text after</div>'
);
});
it('should handle complex nested structures', () => {
const input = '<b><div><p>Nested <b>inline</b> content</p></div></b>';
const output = process(input);
expect(output).toBe(
'<div data-original-tag="b"><div><p>Nested <b>inline</b> content</p></div></div>'
);
});
});

View File

@@ -1,55 +0,0 @@
import rehypeParse from 'rehype-parse';
import rehypeStringify from 'rehype-stringify';
import { unified } from 'unified';
import { describe, expect, it } from 'vitest';
import { rehypeWrapInlineElements } from '../../../../adapters/html/rehype-plugins/wrap-inline-element';
describe('rehypeWrapInlineElements', () => {
const process = (html: string) => {
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeWrapInlineElements)
.use(rehypeStringify)
.processSync(html)
.toString();
};
it('should not wrap inline elements without block children in a div tag', () => {
const input = '<div><span>Hello World</span></div>';
const output = process(input);
expect(output).toBe('<div><span>Hello World</span></div>');
});
it('should not wrap elements without inline children in a div tag', () => {
const input = '<div><h1>Hello World</h1></div>';
const output = process(input);
expect(output).toBe('<div><h1>Hello World</h1></div>');
});
it('should wrap inline elements containing block children in a p tag', () => {
const input = '<div><p>Hello World</p><span>Hello World</span></div>';
const output = process(input);
expect(output).toBe(
'<div><p>Hello World</p><p><span>Hello World</span></p></div>'
);
});
it('should wrap inline elements sequentially', () => {
const input =
'<div><p>Hello World</p><span>Hello</span><span>World</span></div>';
const output = process(input);
expect(output).toBe(
'<div><p>Hello World</p><p><span>Hello</span><span>World</span></p></div>'
);
});
it('should wrap inline elements sequentially mixed with block elements', () => {
const input =
'<div><p>Hello World</p><span>Hello</span><span>World</span><h1>Title</h1><span>Hello</span><span>World</span></div>';
const output = process(input);
expect(output).toBe(
'<div><p>Hello World</p><p><span>Hello</span><span>World</span></p><h1>Title</h1><p><span>Hello</span><span>World</span></p></div>'
);
});
});

View File

@@ -29,13 +29,13 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
expect(isValidUrl('www.example.com')).toEqual(true);
expect(isValidUrl('example.co')).toEqual(true);
expect(isValidUrl('example.cm')).toEqual(true);
expect(isValidUrl('1.1.1.1')).toEqual(false);
expect(isValidUrl('1.1.1.1')).toEqual(true);
expect(isValidUrl('example.c')).toEqual(false);
});
test('special cases', () => {
expect(isValidUrl('example.com.')).toEqual(false);
expect(isValidUrl('example.com.')).toEqual(true);
// I don't know why
// private & local networks is excluded
@@ -44,8 +44,8 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
expect(isValidUrl('localhost')).toEqual(false);
expect(isValidUrl('0.0.0.0')).toEqual(false);
expect(isValidUrl('128.0.0.1')).toEqual(false);
expect(isValidUrl('1.0.0.1')).toEqual(false);
expect(isValidUrl('128.0.0.1')).toEqual(true);
expect(isValidUrl('1.0.0.1')).toEqual(true);
});
test('email link is a valid URL', () => {

View File

@@ -40,10 +40,6 @@ import {
HtmlDeltaConverter,
InlineDeltaToHtmlAdapterMatcherIdentifier,
} from './delta-converter';
import {
rehypeInlineToBlock,
rehypeWrapInlineElements,
} from './rehype-plugins';
export type Html = string;
@@ -199,12 +195,7 @@ export class HtmlAdapter extends BaseAdapter<Html> {
}
private _htmlToAst(html: Html) {
const processor = unified()
.use(rehypeParse)
.use(rehypeInlineToBlock)
.use(rehypeWrapInlineElements);
const ast = processor.parse(html);
return processor.runSync(ast);
return unified().use(rehypeParse).parse(html);
}
override async fromBlockSnapshot(

View File

@@ -1,2 +0,0 @@
export * from './inline-to-block';
export * from './wrap-inline-element';

View File

@@ -1,37 +0,0 @@
import type { Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { HastUtils } from '../../utils/hast';
/**
* The content copied from google docs will be wrapped in <b> tag
* To handle this case, we need to convert the <b> tag to a <div> tag
*/
const inlineElements = new Set(['b']);
export const rehypeInlineToBlock: Plugin<[], Root> = () => {
return tree => {
visit(tree, 'element', node => {
// Check if the current node is an inline element
if (inlineElements.has(node.tagName)) {
// Check if the node has a block element child
const hasBlockChild = node.children.some(
child =>
child.type === 'element' && HastUtils.isTagBlock(child.tagName)
);
if (hasBlockChild) {
const originalTag = node.tagName;
// Convert the inline element to a div
node.tagName = 'div';
// Keep the original properties
node.properties = {
...node.properties,
'data-original-tag': originalTag,
};
}
}
});
};
};

View File

@@ -1,79 +0,0 @@
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { HastUtils } from '../../utils/hast';
/**
* In some cases, the inline elements are wrapped in a div tag mixed with block elements
* We need to wrap them in a p tag to avoid the inline elements being treated as a block element
*/
export const rehypeWrapInlineElements: Plugin<[], Root> = () => {
return tree => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'div') {
// First check if we have a mix of inline and block elements
let hasInline = false;
let hasBlock = false;
for (const child of node.children) {
if (child.type === 'element') {
if (HastUtils.isElementInline(child)) {
hasInline = true;
} else if (HastUtils.isTagBlock(child.tagName)) {
hasBlock = true;
}
if (hasInline && hasBlock) break;
}
}
// Only process if we have both inline and block elements
if (hasInline && hasBlock) {
const newChildren: ElementContent[] = [];
let currentInlineGroup: ElementContent[] = [];
for (const child of node.children) {
if (child.type === 'element') {
const elementChild = child;
if (HastUtils.isElementInline(elementChild)) {
// Add to current inline group
currentInlineGroup.push(elementChild);
} else if (HastUtils.isTagBlock(elementChild.tagName)) {
// If we have accumulated inline elements, wrap them in a p tag
if (currentInlineGroup.length > 0) {
newChildren.push({
type: 'element',
tagName: 'p',
properties: {},
children: currentInlineGroup,
});
currentInlineGroup = [];
}
// Add the block element as is
newChildren.push(elementChild);
} else {
// For unknown elements, treat them as inline
currentInlineGroup.push(elementChild);
}
} else {
// For text nodes, treat them as inline content
currentInlineGroup.push(child);
}
}
// Handle any remaining inline elements at the end
if (currentInlineGroup.length > 0) {
newChildren.push({
type: 'element',
tagName: 'p',
properties: {},
children: currentInlineGroup,
});
}
// Replace the original children with the new structure
node.children = newChildren;
}
}
});
};
};

View File

@@ -2,86 +2,6 @@ import type { Element, ElementContent, Text } from 'hast';
import type { HtmlAST } from '../types/hast.js';
// Block elements that html adapter supports
const blockElements = [
'div',
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'blockquote',
'pre',
];
const blockElementsSet = new Set(blockElements);
// Phrasing content
const inlineElements = [
'a',
'abbr',
'audio',
'b',
'bdi',
'bdo',
'br',
'button',
'canvas',
'cite',
'code',
'data',
'datalist',
'del',
'dfn',
'em',
'embed',
'i',
// 'iframe' is not included because it needs special handling
// 'img' is not included because it needs special handling
'input',
'ins',
'kbd',
'label',
'link',
'map',
'mark',
'math',
'meta',
'meter',
'noscript',
'object',
'output',
'picture',
'progress',
'q',
'ruby',
's',
'samp',
'script',
'select',
'slot',
'small',
'span',
'strong',
'sub',
'sup',
'svg',
'template',
'textarea',
'time',
'u',
'var',
'video',
'wbr',
];
const inlineElementsSet = new Set(inlineElements);
const isElement = (ast: HtmlAST): ast is Element => {
return ast.type === 'element';
};
@@ -133,12 +53,66 @@ const getTextChildrenOnlyAst = (ast: Element): Element => {
};
};
const isTagBlock = (tagName: string): boolean => {
return blockElementsSet.has(tagName);
};
const isTagInline = (tagName: string): boolean => {
return inlineElementsSet.has(tagName);
// Phrasing content
const inlineElements = [
'a',
'abbr',
'audio',
'b',
'bdi',
'bdo',
'br',
'button',
'canvas',
'cite',
'code',
'data',
'datalist',
'del',
'dfn',
'em',
'embed',
'i',
// 'iframe' is not included because it needs special handling
// 'img' is not included because it needs special handling
'input',
'ins',
'kbd',
'label',
'link',
'map',
'mark',
'math',
'meta',
'meter',
'noscript',
'object',
'output',
'picture',
'progress',
'q',
'ruby',
's',
'samp',
'script',
'select',
'slot',
'small',
'span',
'strong',
'sub',
'sup',
'svg',
'template',
'textarea',
'time',
'u',
'var',
'video',
'wbr',
];
return inlineElements.includes(tagName);
};
const isElementInline = (element: Element): boolean => {
@@ -289,7 +263,4 @@ export const HastUtils = {
querySelector,
flatNodes,
isParagraphLike,
isTagBlock,
isTagInline,
isElementInline,
};

View File

@@ -25,4 +25,3 @@ export * from './title';
export * from './url';
export * from './virtual-padding';
export * from './zod-schema';
export { default as formatSize } from 'bytes';

View File

@@ -7,3 +7,39 @@ export function rangeWrap(n: number, min: number, max: number) {
n = (n - min + max) % max;
return min + (Number.isNaN(n) ? 0 : n);
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
*
* @return Formatted string.
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
export function humanFileSize(bytes: number, si = true, dp = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' bytes';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= thresh &&
u < units.length - 1
);
return bytes.toFixed(dp) + ' ' + units[u];
}

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