Compare commits
82 Commits
v0.26.3-be
...
8ba02ed6fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba02ed6fb | ||
|
|
ffa3ff9d7f | ||
|
|
f47ee2bc8a | ||
|
|
bcf2a51d41 | ||
|
|
6a93566422 | ||
|
|
7ac8b14b65 | ||
|
|
16a8f17717 | ||
|
|
1ffb8c922c | ||
|
|
daf536f77a | ||
|
|
0d2d4bb6a1 | ||
|
|
cb9897d493 | ||
|
|
8ca8333cd6 | ||
|
|
3bf2503f55 | ||
|
|
59fd942f40 | ||
|
|
d6d5ae6182 | ||
|
|
c1a09b951f | ||
|
|
4ce68d74f1 | ||
|
|
fbfcc01d14 | ||
|
|
1112a06623 | ||
|
|
bbcb7e69fe | ||
|
|
cc2f23339e | ||
|
|
31101a69e7 | ||
|
|
0b1a44863f | ||
|
|
8406f9656e | ||
|
|
121c0d172d | ||
|
|
8f03090780 | ||
|
|
8125cc0e75 | ||
|
|
f537a75f01 | ||
|
|
9456a07889 | ||
|
|
8f571ddc30 | ||
|
|
13ad1beb10 | ||
|
|
9844ca4d54 | ||
|
|
d7d67841b8 | ||
|
|
29a27b561b | ||
|
|
02744cec00 | ||
|
|
6d710f3bdc | ||
|
|
0b47f92134 | ||
|
|
9c55edeb62 | ||
|
|
9742e9735e | ||
|
|
86d65b2f64 | ||
|
|
f34e25e122 | ||
|
|
b5d5b71f95 | ||
|
|
09fa1a8e4e | ||
|
|
c249011238 | ||
|
|
7f5f7e79df | ||
|
|
fff04395bc | ||
|
|
bbc01533d7 | ||
|
|
e31cca3354 | ||
|
|
11bc333714 | ||
|
|
99b07c2ee1 | ||
|
|
fc9b99cd17 | ||
|
|
2137f68871 | ||
|
|
75efa854bf | ||
|
|
c0139abf79 | ||
|
|
5a38e765bd | ||
|
|
d3dcdd47ee | ||
|
|
727c9d6d71 | ||
|
|
274f491e49 | ||
|
|
478138493a | ||
|
|
5464d1a9ce | ||
|
|
4c40dcacd9 | ||
|
|
76d28aaa38 | ||
|
|
86f48240ce | ||
|
|
c5d622531c | ||
|
|
60acd81d4b | ||
|
|
78f567a178 | ||
|
|
784382cfb1 | ||
|
|
342451be1b | ||
|
|
2b6146727b | ||
|
|
d5245a3273 | ||
|
|
fff63562b1 | ||
|
|
4136abdd97 | ||
|
|
e249e2e884 | ||
|
|
2e95d91093 | ||
|
|
2cb171f553 | ||
|
|
a4e2242b8d | ||
|
|
c90f173821 | ||
|
|
e1e0ac2345 | ||
|
|
bdccf4e9fd | ||
|
|
11cf1928b5 | ||
|
|
5215c73166 | ||
|
|
895e774569 |
@@ -19,3 +19,8 @@ rustflags = [
|
|||||||
# pthread_key_create() destructors and segfault after a DSO unloading
|
# pthread_key_create() destructors and segfault after a DSO unloading
|
||||||
[target.'cfg(all(target_env = "gnu", not(target_os = "windows")))']
|
[target.'cfg(all(target_env = "gnu", not(target_os = "windows")))']
|
||||||
rustflags = ["-C", "link-args=-Wl,-z,nodelete"]
|
rustflags = ["-C", "link-args=-Wl,-z,nodelete"]
|
||||||
|
|
||||||
|
# Temporary local llm_adapter override.
|
||||||
|
# Uncomment when verifying AFFiNE against the sibling llm_adapter workspace.
|
||||||
|
# [patch.crates-io]
|
||||||
|
# llm_adapter = { path = "../llm_adapter" }
|
||||||
|
|||||||
@@ -197,8 +197,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"SMTP.name": {
|
"SMTP.name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`",
|
"description": "Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"\n@environment `MAILER_SERVERNAME`",
|
||||||
"default": "AFFiNE Server"
|
"default": ""
|
||||||
},
|
},
|
||||||
"SMTP.host": {
|
"SMTP.host": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -237,8 +237,8 @@
|
|||||||
},
|
},
|
||||||
"fallbackSMTP.name": {
|
"fallbackSMTP.name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Name of the fallback email server (e.g. your domain name)\n@default \"AFFiNE Server\"",
|
"description": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"",
|
||||||
"default": "AFFiNE Server"
|
"default": ""
|
||||||
},
|
},
|
||||||
"fallbackSMTP.host": {
|
"fallbackSMTP.host": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -971,7 +971,7 @@
|
|||||||
},
|
},
|
||||||
"scenarios": {
|
"scenarios": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-5-mini\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||||
"default": {
|
"default": {
|
||||||
"override_enabled": false,
|
"override_enabled": false,
|
||||||
"scenarios": {
|
"scenarios": {
|
||||||
@@ -979,15 +979,24 @@
|
|||||||
"chat": "gemini-2.5-flash",
|
"chat": "gemini-2.5-flash",
|
||||||
"embedding": "gemini-embedding-001",
|
"embedding": "gemini-embedding-001",
|
||||||
"image": "gpt-image-1",
|
"image": "gpt-image-1",
|
||||||
"rerank": "gpt-4.1",
|
|
||||||
"coding": "claude-sonnet-4-5@20250929",
|
"coding": "claude-sonnet-4-5@20250929",
|
||||||
"complex_text_generation": "gpt-4o-2024-08-06",
|
"complex_text_generation": "gpt-5-mini",
|
||||||
"quick_decision_making": "gpt-5-mini",
|
"quick_decision_making": "gpt-5-mini",
|
||||||
"quick_text_generation": "gemini-2.5-flash",
|
"quick_text_generation": "gemini-2.5-flash",
|
||||||
"polish_and_summarize": "gemini-2.5-flash"
|
"polish_and_summarize": "gemini-2.5-flash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"providers.profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "The profile list for copilot providers.\n@default []",
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"providers.defaults": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The default provider ids for model output types and global fallback.\n@default {}",
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
"providers.openai": {
|
"providers.openai": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.openai.com/v1\"}\n@link https://github.com/openai/openai-node",
|
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.openai.com/v1\"}\n@link https://github.com/openai/openai-node",
|
||||||
|
|||||||
10
.github/actions/build-rust/action.yml
vendored
@@ -50,8 +50,14 @@ runs:
|
|||||||
# https://github.com/tree-sitter/tree-sitter/issues/4186
|
# https://github.com/tree-sitter/tree-sitter/issues/4186
|
||||||
# pass -D_BSD_SOURCE to clang to fix the tree-sitter build issue
|
# pass -D_BSD_SOURCE to clang to fix the tree-sitter build issue
|
||||||
run: |
|
run: |
|
||||||
echo "CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
|
if [[ "${{ inputs.target }}" == "aarch64-unknown-linux-gnu" ]]; then
|
||||||
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
|
# napi cross-toolchain 1.0.3 headers miss AT_HWCAP2 in elf.h
|
||||||
|
echo "CC=clang -D_BSD_SOURCE -DAT_HWCAP2=26" >> "$GITHUB_ENV"
|
||||||
|
echo "TARGET_CC=clang -D_BSD_SOURCE -DAT_HWCAP2=26" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
|
||||||
|
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|||||||
14
.github/actions/setup-node/action.yml
vendored
@@ -53,7 +53,7 @@ runs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
registry-url: https://npm.pkg.github.com
|
registry-url: https://npm.pkg.github.com
|
||||||
@@ -93,7 +93,7 @@ runs:
|
|||||||
run: node -e "const p = $(yarn config cacheFolder --json).effective; console.log('yarn_global_cache=' + p)" >> $GITHUB_OUTPUT
|
run: node -e "const p = $(yarn config cacheFolder --json).effective; console.log('yarn_global_cache=' + p)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache non-full yarn cache on Linux
|
- name: Cache non-full yarn cache on Linux
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
if: ${{ inputs.full-cache != 'true' && runner.os == 'Linux' }}
|
if: ${{ inputs.full-cache != 'true' && runner.os == 'Linux' }}
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -105,7 +105,7 @@ runs:
|
|||||||
# and the decompression performance on Windows is very terrible
|
# and the decompression performance on Windows is very terrible
|
||||||
# so we reduce the number of cached files on non-Linux systems by remove node_modules from cache path.
|
# so we reduce the number of cached files on non-Linux systems by remove node_modules from cache path.
|
||||||
- name: Cache non-full yarn cache on non-Linux
|
- name: Cache non-full yarn cache on non-Linux
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
if: ${{ inputs.full-cache != 'true' && runner.os != 'Linux' }}
|
if: ${{ inputs.full-cache != 'true' && runner.os != 'Linux' }}
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -113,7 +113,7 @@ runs:
|
|||||||
key: node_modules-cache-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}
|
key: node_modules-cache-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}
|
||||||
|
|
||||||
- name: Cache full yarn cache on Linux
|
- name: Cache full yarn cache on Linux
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
if: ${{ inputs.full-cache == 'true' && runner.os == 'Linux' }}
|
if: ${{ inputs.full-cache == 'true' && runner.os == 'Linux' }}
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -122,7 +122,7 @@ runs:
|
|||||||
key: node_modules-cache-full-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}
|
key: node_modules-cache-full-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}
|
||||||
|
|
||||||
- name: Cache full yarn cache on non-Linux
|
- name: Cache full yarn cache on non-Linux
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
if: ${{ inputs.full-cache == 'true' && runner.os != 'Linux' }}
|
if: ${{ inputs.full-cache == 'true' && runner.os != 'Linux' }}
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -154,7 +154,7 @@ runs:
|
|||||||
# Note: Playwright's cache directory is hard coded because that's what it
|
# Note: Playwright's cache directory is hard coded because that's what it
|
||||||
# says to do in the docs. There doesn't appear to be a command that prints
|
# says to do in the docs. There doesn't appear to be a command that prints
|
||||||
# it out for us.
|
# it out for us.
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
if: ${{ inputs.playwright-install == 'true' }}
|
if: ${{ inputs.playwright-install == 'true' }}
|
||||||
with:
|
with:
|
||||||
@@ -189,7 +189,7 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
echo "version=$(yarn why --json electron | grep -h 'workspace:.' | jq --raw-output '.children[].locator' | sed -e 's/@playwright\/test@.*://' | head -n 1)" >> $GITHUB_OUTPUT
|
echo "version=$(yarn why --json electron | grep -h 'workspace:.' | jq --raw-output '.children[].locator' | sed -e 's/@playwright\/test@.*://' | head -n 1)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
id: electron-cache
|
id: electron-cache
|
||||||
if: ${{ inputs.electron-install == 'true' }}
|
if: ${{ inputs.electron-install == 'true' }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
4
.github/helm/affine/charts/front/values.yaml
vendored
@@ -31,10 +31,10 @@ podSecurityContext:
|
|||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: '1'
|
cpu: '1'
|
||||||
memory: 4Gi
|
memory: 6Gi
|
||||||
requests:
|
requests:
|
||||||
cpu: '1'
|
cpu: '1'
|
||||||
memory: 2Gi
|
memory: 4Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
|
|||||||
4
.github/renovate.json
vendored
@@ -63,7 +63,7 @@
|
|||||||
"groupName": "opentelemetry",
|
"groupName": "opentelemetry",
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"/^@opentelemetry/",
|
"/^@opentelemetry/",
|
||||||
"/^@google-cloud\/opentelemetry-/"
|
"/^@google-cloud/opentelemetry-/"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"customManagers": [
|
"customManagers": [
|
||||||
{
|
{
|
||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"fileMatch": ["^rust-toolchain\\.toml?$"],
|
"managerFilePatterns": ["/^rust-toolchain\\.toml?$/"],
|
||||||
"matchStrings": [
|
"matchStrings": [
|
||||||
"channel\\s*=\\s*\"(?<currentValue>\\d+\\.\\d+(\\.\\d+)?)\""
|
"channel\\s*=\\s*\"(?<currentValue>\\d+\\.\\d+(\\.\\d+)?)\""
|
||||||
],
|
],
|
||||||
|
|||||||
4
.github/workflows/auto-labeler.yml
vendored
@@ -13,5 +13,5 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/labeler@v5
|
- uses: actions/labeler@v6
|
||||||
|
|||||||
16
.github/workflows/build-images.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ inputs.build-type }}
|
environment: ${{ inputs.build-type }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ inputs.build-type }}
|
environment: ${{ inputs.build-type }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ inputs.build-type }}
|
environment: ${{ inputs.build-type }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
|
|
||||||
build-server-native:
|
build-server-native:
|
||||||
name: Build Server native - ${{ matrix.targets.name }}
|
name: Build Server native - ${{ matrix.targets.name }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
environment: ${{ inputs.build-type }}
|
environment: ${{ inputs.build-type }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
file: server-native.armv7.node
|
file: server-native.armv7.node
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -166,7 +166,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-server-native
|
- build-server-native
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -202,7 +202,7 @@ jobs:
|
|||||||
- build-mobile
|
- build-mobile
|
||||||
- build-admin
|
- build-admin
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Download server dist
|
- name: Download server dist
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -222,7 +222,7 @@ jobs:
|
|||||||
# setup node without cache configuration
|
# setup node without cache configuration
|
||||||
# Prisma cache is not compatible with docker build cache
|
# Prisma cache is not compatible with docker build cache
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
registry-url: https://npm.pkg.github.com
|
registry-url: https://npm.pkg.github.com
|
||||||
|
|||||||
123
.github/workflows/build-test.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
@@ -67,9 +67,9 @@ jobs:
|
|||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Go (for actionlint)
|
- name: Setup Go (for actionlint)
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
- name: Install actionlint
|
- name: Install actionlint
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=14384
|
NODE_OPTIONS: --max-old-space-size=14384
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -138,7 +138,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
run-rust: ${{ steps.rust-filter.outputs.rust }}
|
run-rust: ${{ steps.rust-filter.outputs.rust }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
id: rust-filter
|
id: rust-filter
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- rust-test-filter
|
- rust-test-filter
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: ./.github/actions/build-rust
|
- uses: ./.github/actions/build-rust
|
||||||
with:
|
with:
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
@@ -182,7 +182,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-server-native
|
- build-server-native
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -212,7 +212,7 @@ jobs:
|
|||||||
name: Check yarn binary
|
name: Check yarn binary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Run check
|
- name: Run check
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -226,9 +226,9 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2]
|
shard: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
name: E2E BlockSuite Cross Browser Test
|
name: E2E BlockSuite Cross Browser Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -269,10 +269,13 @@ jobs:
|
|||||||
- name: Run playground build
|
- name: Run playground build
|
||||||
run: yarn workspace @blocksuite/playground build
|
run: yarn workspace @blocksuite/playground build
|
||||||
|
|
||||||
- name: Run playwright tests
|
- name: Run integration browser tests
|
||||||
run: |
|
timeout-minutes: 10
|
||||||
yarn workspace @blocksuite/integration-test test:unit
|
run: yarn workspace @blocksuite/integration-test test:unit
|
||||||
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
|
|
||||||
|
- name: Run cross-platform playwright tests
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
@@ -282,52 +285,6 @@ jobs:
|
|||||||
path: ./test-results
|
path: ./test-results
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
bundler-matrix:
|
|
||||||
name: Bundler Matrix (${{ matrix.bundler }})
|
|
||||||
runs-on: ubuntu-24.04-arm
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
bundler: [webpack, rspack]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: ./.github/actions/setup-node
|
|
||||||
with:
|
|
||||||
playwright-install: false
|
|
||||||
electron-install: false
|
|
||||||
full-cache: true
|
|
||||||
|
|
||||||
- name: Run frontend build matrix
|
|
||||||
env:
|
|
||||||
AFFINE_BUNDLER: ${{ matrix.bundler }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
packages=(
|
|
||||||
"@affine/web"
|
|
||||||
"@affine/mobile"
|
|
||||||
"@affine/ios"
|
|
||||||
"@affine/android"
|
|
||||||
"@affine/admin"
|
|
||||||
"@affine/electron-renderer"
|
|
||||||
)
|
|
||||||
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
|
|
||||||
: > "$summary"
|
|
||||||
for pkg in "${packages[@]}"; do
|
|
||||||
start=$(date +%s)
|
|
||||||
yarn affine "$pkg" build
|
|
||||||
end=$(date +%s)
|
|
||||||
echo "${pkg},$((end-start))" >> "$summary"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload bundler timing
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-results-bundler-${{ matrix.bundler }}
|
|
||||||
path: ./test-results-bundler-${{ matrix.bundler }}.txt
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
e2e-test:
|
e2e-test:
|
||||||
name: E2E Test
|
name: E2E Test
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
@@ -340,7 +297,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4, 5]
|
shard: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -372,7 +329,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2]
|
shard: [1, 2]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -402,9 +359,9 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3]
|
shard: [1, 2, 3, 4, 5]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -437,7 +394,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -476,7 +433,7 @@ jobs:
|
|||||||
- { os: macos-latest, target: aarch64-apple-darwin }
|
- { os: macos-latest, target: aarch64-apple-darwin }
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -517,7 +474,7 @@ jobs:
|
|||||||
- { os: windows-latest, target: aarch64-pc-windows-msvc }
|
- { os: windows-latest, target: aarch64-pc-windows-msvc }
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: samypr100/setup-dev-drive@v3
|
- uses: samypr100/setup-dev-drive@v3
|
||||||
with:
|
with:
|
||||||
workspace-copy: true
|
workspace-copy: true
|
||||||
@@ -557,7 +514,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -580,7 +537,7 @@ jobs:
|
|||||||
name: Build @affine/electron renderer
|
name: Build @affine/electron renderer
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -607,7 +564,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-native-linux
|
- build-native-linux
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -661,7 +618,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -742,7 +699,7 @@ jobs:
|
|||||||
stack-version: 9.0.1
|
stack-version: 9.0.1
|
||||||
security-enabled: false
|
security-enabled: false
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -805,7 +762,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -846,7 +803,7 @@ jobs:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
|
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -874,7 +831,7 @@ jobs:
|
|||||||
RUST_BACKTRACE: full
|
RUST_BACKTRACE: full
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -898,7 +855,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -937,7 +894,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: ./.github/actions/build-rust
|
uses: ./.github/actions/build-rust
|
||||||
with:
|
with:
|
||||||
@@ -960,7 +917,7 @@ jobs:
|
|||||||
run-api: ${{ steps.decision.outputs.run_api }}
|
run-api: ${{ steps.decision.outputs.run_api }}
|
||||||
run-e2e: ${{ steps.decision.outputs.run_e2e }}
|
run-e2e: ${{ steps.decision.outputs.run_e2e }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
id: copilot-filter
|
id: copilot-filter
|
||||||
@@ -1029,7 +986,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -1102,7 +1059,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -1185,7 +1142,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -1266,7 +1223,7 @@ jobs:
|
|||||||
test: true,
|
test: true,
|
||||||
}
|
}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
8
.github/workflows/copilot-test.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
with:
|
with:
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 9308:9308
|
- 9308:9308
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
@@ -167,7 +167,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Post test result message
|
name: Post test result message
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
|||||||
4
.github/workflows/pr-title-lint.yml
vendored
@@ -18,9 +18,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
|
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|||||||
2
.github/workflows/release-cloud.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
- build-images
|
- build-images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Deploy to ${{ inputs.build-type }}
|
- name: Deploy to ${{ inputs.build-type }}
|
||||||
uses: ./.github/actions/deploy
|
uses: ./.github/actions/deploy
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
SENTRY_RELEASE: ${{ inputs.app_version }}
|
SENTRY_RELEASE: ${{ inputs.app_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Signing By Apple Developer ID
|
- name: Signing By Apple Developer ID
|
||||||
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
|
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
|
||||||
uses: apple-actions/import-codesign-certs@v5
|
uses: apple-actions/import-codesign-certs@v6
|
||||||
with:
|
with:
|
||||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
||||||
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
||||||
@@ -178,14 +178,14 @@ jobs:
|
|||||||
mv packages/frontend/apps/electron/out/*/make/deb/${{ inputs.arch }}/*.deb ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.deb
|
mv packages/frontend/apps/electron/out/*/make/deb/${{ inputs.arch }}/*.deb ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.deb
|
||||||
mv packages/frontend/apps/electron/out/*/make/flatpak/*/*.flatpak ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.flatpak
|
mv packages/frontend/apps/electron/out/*/make/flatpak/*/*.flatpak ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.flatpak
|
||||||
|
|
||||||
- uses: actions/attest-build-provenance@v2
|
- uses: actions/attest-build-provenance@v4
|
||||||
if: ${{ inputs.platform == 'darwin' }}
|
if: ${{ inputs.platform == 'darwin' }}
|
||||||
with:
|
with:
|
||||||
subject-path: |
|
subject-path: |
|
||||||
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.zip
|
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.zip
|
||||||
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.dmg
|
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.dmg
|
||||||
|
|
||||||
- uses: actions/attest-build-provenance@v2
|
- uses: actions/attest-build-provenance@v4
|
||||||
if: ${{ inputs.platform == 'linux' }}
|
if: ${{ inputs.platform == 'linux' }}
|
||||||
with:
|
with:
|
||||||
subject-path: |
|
subject-path: |
|
||||||
|
|||||||
10
.github/workflows/release-desktop.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ inputs.build-type }}
|
environment: ${{ inputs.build-type }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
FILES_TO_BE_SIGNED_x64: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED_x64 }}
|
FILES_TO_BE_SIGNED_x64: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED_x64 }}
|
||||||
FILES_TO_BE_SIGNED_arm64: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED_arm64 }}
|
FILES_TO_BE_SIGNED_arm64: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED_arm64 }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -344,7 +344,7 @@ jobs:
|
|||||||
mv packages/frontend/apps/electron/out/*/make/squirrel.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.exe
|
mv packages/frontend/apps/electron/out/*/make/squirrel.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.exe
|
||||||
mv packages/frontend/apps/electron/out/*/make/nsis.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.nsis.exe
|
mv packages/frontend/apps/electron/out/*/make/nsis.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.nsis.exe
|
||||||
|
|
||||||
- uses: actions/attest-build-provenance@v2
|
- uses: actions/attest-build-provenance@v4
|
||||||
with:
|
with:
|
||||||
subject-path: |
|
subject-path: |
|
||||||
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.zip
|
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.zip
|
||||||
@@ -369,7 +369,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Download Artifacts (macos-x64)
|
- name: Download Artifacts (macos-x64)
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -395,7 +395,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: affine-linux-x64-builds
|
name: affine-linux-x64-builds
|
||||||
path: ./release
|
path: ./release
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: Copy Selfhost Release Files
|
- name: Copy Selfhost Release Files
|
||||||
|
|||||||
14
.github/workflows/release-mobile.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: ${{ inputs.build-type }}
|
environment: ${{ inputs.build-type }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
build-android-web:
|
build-android-web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-ios-web
|
- build-ios-web
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
- name: Cap sync
|
- name: Cap sync
|
||||||
run: yarn workspace @affine/ios sync
|
run: yarn workspace @affine/ios sync
|
||||||
- name: Signing By Apple Developer ID
|
- name: Signing By Apple Developer ID
|
||||||
uses: apple-actions/import-codesign-certs@v5
|
uses: apple-actions/import-codesign-certs@v6
|
||||||
id: import-codesign-certs
|
id: import-codesign-certs
|
||||||
with:
|
with:
|
||||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
|
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-android-web
|
- build-android-web
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Setup Version
|
- name: Setup Version
|
||||||
uses: ./.github/actions/setup-version
|
uses: ./.github/actions/setup-version
|
||||||
with:
|
with:
|
||||||
@@ -180,7 +180,7 @@ jobs:
|
|||||||
no-build: 'true'
|
no-build: 'true'
|
||||||
- name: Cap sync
|
- name: Cap sync
|
||||||
run: yarn workspace @affine/android cap sync
|
run: yarn workspace @affine/android cap sync
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
- name: Auth gcloud
|
- name: Auth gcloud
|
||||||
@@ -192,7 +192,7 @@ jobs:
|
|||||||
token_format: 'access_token'
|
token_format: 'access_token'
|
||||||
project_id: '${{ secrets.GCP_PROJECT_ID }}'
|
project_id: '${{ secrets.GCP_PROJECT_ID }}'
|
||||||
access_token_scopes: 'https://www.googleapis.com/auth/androidpublisher'
|
access_token_scopes: 'https://www.googleapis.com/auth/androidpublisher'
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
4
.github/workflows/release.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
|||||||
GIT_SHORT_HASH: ${{ steps.prepare.outputs.GIT_SHORT_HASH }}
|
GIT_SHORT_HASH: ${{ steps.prepare.outputs.GIT_SHORT_HASH }}
|
||||||
BUILD_TYPE: ${{ steps.prepare.outputs.BUILD_TYPE }}
|
BUILD_TYPE: ${{ steps.prepare.outputs.BUILD_TYPE }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Prepare Release
|
- name: Prepare Release
|
||||||
id: prepare
|
id: prepare
|
||||||
uses: ./.github/actions/prepare-release
|
uses: ./.github/actions/prepare-release
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Decide whether to release
|
- name: Decide whether to release
|
||||||
id: decide
|
id: decide
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
|
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -48,6 +48,7 @@ testem.log
|
|||||||
/typings
|
/typings
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.context
|
.context
|
||||||
|
/*.md
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
942
.yarn/releases/yarn-4.12.0.cjs
vendored
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
@@ -12,4 +12,4 @@ npmPublishAccess: public
|
|||||||
|
|
||||||
npmRegistryServer: "https://registry.npmjs.org"
|
npmRegistryServer: "https://registry.npmjs.org"
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||||
|
|||||||
3037
Cargo.lock
generated
23
Cargo.toml
@@ -36,18 +36,30 @@ resolver = "3"
|
|||||||
criterion2 = { version = "3", default-features = false }
|
criterion2 = { version = "3", default-features = false }
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
dispatch2 = "0.3"
|
dispatch2 = "0.3"
|
||||||
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
|
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
file-format = { version = "0.28", features = ["reader"] }
|
file-format = { version = "0.28", features = ["reader"] }
|
||||||
homedir = "0.3"
|
homedir = "0.3"
|
||||||
|
image = { version = "0.25.9", default-features = false, features = [
|
||||||
|
"bmp",
|
||||||
|
"gif",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"webp",
|
||||||
|
] }
|
||||||
infer = { version = "0.19.0" }
|
infer = { version = "0.19.0" }
|
||||||
lasso = { version = "0.7", features = ["multi-threaded"] }
|
lasso = { version = "0.7", features = ["multi-threaded"] }
|
||||||
lib0 = { version = "0.16", features = ["lib0-serde"] }
|
lib0 = { version = "0.16", features = ["lib0-serde"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
libwebp-sys = "0.14.2"
|
||||||
|
little_exif = "0.6.23"
|
||||||
|
llm_adapter = { version = "0.1.3", default-features = false }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
loom = { version = "0.7", features = ["checkpoint"] }
|
loom = { version = "0.7", features = ["checkpoint"] }
|
||||||
lru = "0.16"
|
lru = "0.16"
|
||||||
|
matroska = "0.30"
|
||||||
memory-indexer = "0.3.0"
|
memory-indexer = "0.3.0"
|
||||||
|
mermaid-rs-renderer = { git = "https://github.com/toeverything/mermaid-rs-renderer", rev = "fba9097", default-features = false }
|
||||||
mimalloc = "0.1"
|
mimalloc = "0.1"
|
||||||
mp4parse = "0.17"
|
mp4parse = "0.17"
|
||||||
nanoid = "0.4"
|
nanoid = "0.4"
|
||||||
@@ -64,6 +76,7 @@ resolver = "3"
|
|||||||
notify = { version = "8", features = ["serde"] }
|
notify = { version = "8", features = ["serde"] }
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-foundation = "0.3"
|
objc2-foundation = "0.3"
|
||||||
|
ogg = "0.9"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
ordered-float = "5"
|
ordered-float = "5"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
@@ -111,6 +124,14 @@ resolver = "3"
|
|||||||
tree-sitter-rust = { version = "0.24" }
|
tree-sitter-rust = { version = "0.24" }
|
||||||
tree-sitter-scala = { version = "0.24" }
|
tree-sitter-scala = { version = "0.24" }
|
||||||
tree-sitter-typescript = { version = "0.23" }
|
tree-sitter-typescript = { version = "0.23" }
|
||||||
|
typst = "0.14.2"
|
||||||
|
typst-as-lib = { version = "0.15.4", default-features = false, features = [
|
||||||
|
"packages",
|
||||||
|
"typst-kit-embed-fonts",
|
||||||
|
"typst-kit-fonts",
|
||||||
|
"ureq",
|
||||||
|
] }
|
||||||
|
typst-svg = "0.14.2"
|
||||||
uniffi = "0.29"
|
uniffi = "0.29"
|
||||||
url = { version = "2.5" }
|
url = { version = "2.5" }
|
||||||
uuid = "1.8"
|
uuid = "1.8"
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ We welcome you to provide us with bug reports via and email at [security@toevery
|
|||||||
|
|
||||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
|
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
|
||||||
|
|
||||||
|
Due to limited resources, we do not accept and will not review any AI-generated security reports.
|
||||||
|
|
||||||
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
|
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
|
||||||
|
|||||||
@@ -300,6 +300,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||||
"msw": "^2.12.4",
|
"msw": "^2.12.4",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`snapshot to markdown > imports obsidian vault fixtures 1`] = `
|
||||||
|
{
|
||||||
|
"entry": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Panel
|
||||||
|
Body line",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"type": "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"emoji": "💡",
|
||||||
|
"flavour": "affine:callout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flavour": "affine:attachment",
|
||||||
|
"name": "archive.zip",
|
||||||
|
"style": "horizontalThin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"footnote": {
|
||||||
|
"label": "1",
|
||||||
|
"reference": {
|
||||||
|
"title": "reference body",
|
||||||
|
"type": "url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"insert": " ",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"type": "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flavour": "affine:divider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "after note",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"type": "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": " ",
|
||||||
|
"reference": {
|
||||||
|
"page": "linked",
|
||||||
|
"type": "LinkedPage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"type": "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "Sources",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"type": "h6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flavour": "affine:bookmark",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:note",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flavour": "affine:page",
|
||||||
|
},
|
||||||
|
"titles": [
|
||||||
|
"entry",
|
||||||
|
"linked",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
> [!custom] Panel
|
||||||
|
> Body line
|
||||||
|
|
||||||
|
![[archive.zip]]
|
||||||
|
|
||||||
|
[^1]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
after note
|
||||||
|
|
||||||
|
[[linked]]
|
||||||
|
|
||||||
|
[^1]: reference body
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
plain linked page
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { basename, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MarkdownTransformer,
|
||||||
|
ObsidianTransformer,
|
||||||
|
} from '@blocksuite/affine/widgets/linked-doc';
|
||||||
import {
|
import {
|
||||||
DefaultTheme,
|
DefaultTheme,
|
||||||
NoteDisplayMode,
|
NoteDisplayMode,
|
||||||
@@ -8,13 +14,18 @@ import {
|
|||||||
CalloutAdmonitionType,
|
CalloutAdmonitionType,
|
||||||
CalloutExportStyle,
|
CalloutExportStyle,
|
||||||
calloutMarkdownExportMiddleware,
|
calloutMarkdownExportMiddleware,
|
||||||
|
docLinkBaseURLMiddleware,
|
||||||
embedSyncedDocMiddleware,
|
embedSyncedDocMiddleware,
|
||||||
MarkdownAdapter,
|
MarkdownAdapter,
|
||||||
|
titleMiddleware,
|
||||||
} from '@blocksuite/affine-shared/adapters';
|
} from '@blocksuite/affine-shared/adapters';
|
||||||
|
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
import type {
|
import type {
|
||||||
BlockSnapshot,
|
BlockSnapshot,
|
||||||
|
DeltaInsert,
|
||||||
DocSnapshot,
|
DocSnapshot,
|
||||||
SliceSnapshot,
|
SliceSnapshot,
|
||||||
|
Store,
|
||||||
TransformerMiddleware,
|
TransformerMiddleware,
|
||||||
} from '@blocksuite/store';
|
} from '@blocksuite/store';
|
||||||
import { AssetsManager, MemoryBlobCRUD, Schema } from '@blocksuite/store';
|
import { AssetsManager, MemoryBlobCRUD, Schema } from '@blocksuite/store';
|
||||||
@@ -29,6 +40,138 @@ import { testStoreExtensions } from '../utils/store.js';
|
|||||||
|
|
||||||
const provider = getProvider();
|
const provider = getProvider();
|
||||||
|
|
||||||
|
function withRelativePath(file: File, relativePath: string): File {
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', {
|
||||||
|
value: relativePath,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownFixture(relativePath: string): File {
|
||||||
|
return withRelativePath(
|
||||||
|
new File(
|
||||||
|
[
|
||||||
|
readFileSync(
|
||||||
|
resolve(import.meta.dirname, 'fixtures/obsidian', relativePath),
|
||||||
|
'utf8'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
basename(relativePath),
|
||||||
|
{ type: 'text/markdown' }
|
||||||
|
),
|
||||||
|
`vault/${relativePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportSnapshot(doc: Store): DocSnapshot {
|
||||||
|
const job = doc.getTransformer([
|
||||||
|
docLinkBaseURLMiddleware(doc.workspace.id),
|
||||||
|
titleMiddleware(doc.workspace.meta.docMetas),
|
||||||
|
]);
|
||||||
|
const snapshot = job.docToSnapshot(doc);
|
||||||
|
expect(snapshot).toBeTruthy();
|
||||||
|
return snapshot!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeltaForSnapshot(
|
||||||
|
delta: DeltaInsert<AffineTextAttributes>[],
|
||||||
|
titleById: ReadonlyMap<string, string>
|
||||||
|
) {
|
||||||
|
return delta.map(item => {
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
insert: item.insert,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.attributes?.link) {
|
||||||
|
normalized.link = item.attributes.link;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.attributes?.reference?.type === 'LinkedPage') {
|
||||||
|
normalized.reference = {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
page: titleById.get(item.attributes.reference.pageId) ?? '<missing>',
|
||||||
|
...(item.attributes.reference.title
|
||||||
|
? { title: item.attributes.reference.title }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.attributes?.footnote) {
|
||||||
|
const reference = item.attributes.footnote.reference;
|
||||||
|
normalized.footnote = {
|
||||||
|
label: item.attributes.footnote.label,
|
||||||
|
reference:
|
||||||
|
reference.type === 'doc'
|
||||||
|
? {
|
||||||
|
type: 'doc',
|
||||||
|
page: reference.docId
|
||||||
|
? (titleById.get(reference.docId) ?? '<missing>')
|
||||||
|
: '<missing>',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: reference.type,
|
||||||
|
...(reference.title ? { title: reference.title } : {}),
|
||||||
|
...(reference.fileName ? { fileName: reference.fileName } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyBlockForSnapshot(
|
||||||
|
block: BlockSnapshot,
|
||||||
|
titleById: ReadonlyMap<string, string>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const simplified: Record<string, unknown> = {
|
||||||
|
flavour: block.flavour,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (block.flavour === 'affine:paragraph' || block.flavour === 'affine:list') {
|
||||||
|
simplified.type = block.props.type;
|
||||||
|
const text = block.props.text as
|
||||||
|
| { delta?: DeltaInsert<AffineTextAttributes>[] }
|
||||||
|
| undefined;
|
||||||
|
simplified.delta = normalizeDeltaForSnapshot(text?.delta ?? [], titleById);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.flavour === 'affine:callout') {
|
||||||
|
simplified.emoji = block.props.emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.flavour === 'affine:attachment') {
|
||||||
|
simplified.name = block.props.name;
|
||||||
|
simplified.style = block.props.style;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.flavour === 'affine:image') {
|
||||||
|
simplified.sourceId = '<asset>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = (block.children ?? [])
|
||||||
|
.filter(child => child.flavour !== 'affine:surface')
|
||||||
|
.map(child => simplifyBlockForSnapshot(child, titleById));
|
||||||
|
if (children.length) {
|
||||||
|
simplified.children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return simplified;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotDocByTitle(
|
||||||
|
collection: TestWorkspace,
|
||||||
|
title: string,
|
||||||
|
titleById: ReadonlyMap<string, string>
|
||||||
|
) {
|
||||||
|
const meta = collection.meta.docMetas.find(meta => meta.title === title);
|
||||||
|
expect(meta).toBeTruthy();
|
||||||
|
const doc = collection.getDoc(meta!.id)?.getStore({ id: meta!.id });
|
||||||
|
expect(doc).toBeTruthy();
|
||||||
|
return simplifyBlockForSnapshot(exportSnapshot(doc!).blocks, titleById);
|
||||||
|
}
|
||||||
|
|
||||||
describe('snapshot to markdown', () => {
|
describe('snapshot to markdown', () => {
|
||||||
test('code', async () => {
|
test('code', async () => {
|
||||||
const blockSnapshot: BlockSnapshot = {
|
const blockSnapshot: BlockSnapshot = {
|
||||||
@@ -127,6 +270,46 @@ Hello world
|
|||||||
expect(meta?.tags).toEqual(['a', 'b']);
|
expect(meta?.tags).toEqual(['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('imports obsidian vault fixtures', async () => {
|
||||||
|
const schema = new Schema().register(AffineSchemas);
|
||||||
|
const collection = new TestWorkspace();
|
||||||
|
collection.storeExtensions = testStoreExtensions;
|
||||||
|
collection.meta.initialize();
|
||||||
|
|
||||||
|
const attachment = withRelativePath(
|
||||||
|
new File([new Uint8Array([80, 75, 3, 4])], 'archive.zip', {
|
||||||
|
type: 'application/zip',
|
||||||
|
}),
|
||||||
|
'vault/archive.zip'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { docIds } = await ObsidianTransformer.importObsidianVault({
|
||||||
|
collection,
|
||||||
|
schema,
|
||||||
|
importedFiles: [
|
||||||
|
markdownFixture('entry.md'),
|
||||||
|
markdownFixture('linked.md'),
|
||||||
|
attachment,
|
||||||
|
],
|
||||||
|
extensions: testStoreExtensions,
|
||||||
|
});
|
||||||
|
expect(docIds).toHaveLength(2);
|
||||||
|
|
||||||
|
const titleById = new Map(
|
||||||
|
collection.meta.docMetas.map(meta => [
|
||||||
|
meta.id,
|
||||||
|
meta.title ?? '<untitled>',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect({
|
||||||
|
titles: collection.meta.docMetas
|
||||||
|
.map(meta => meta.title)
|
||||||
|
.sort((a, b) => (a ?? '').localeCompare(b ?? '')),
|
||||||
|
entry: snapshotDocByTitle(collection, 'entry', titleById),
|
||||||
|
}).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
test('paragraph', async () => {
|
test('paragraph', async () => {
|
||||||
const blockSnapshot: BlockSnapshot = {
|
const blockSnapshot: BlockSnapshot = {
|
||||||
type: 'block',
|
type: 'block',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 1000,
|
testTimeout: 1000,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul',
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/blocksuite-affine',
|
reportsDirectory: '../../../.coverage/blocksuite-affine',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
BlockMarkdownAdapterExtension,
|
BlockMarkdownAdapterExtension,
|
||||||
type BlockMarkdownAdapterMatcher,
|
type BlockMarkdownAdapterMatcher,
|
||||||
|
createAttachmentBlockSnapshot,
|
||||||
FOOTNOTE_DEFINITION_PREFIX,
|
FOOTNOTE_DEFINITION_PREFIX,
|
||||||
getFootnoteDefinitionText,
|
getFootnoteDefinitionText,
|
||||||
isFootnoteDefinitionNode,
|
isFootnoteDefinitionNode,
|
||||||
@@ -56,18 +57,15 @@ export const attachmentBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher
|
|||||||
}
|
}
|
||||||
walkerContext
|
walkerContext
|
||||||
.openNode(
|
.openNode(
|
||||||
{
|
createAttachmentBlockSnapshot({
|
||||||
type: 'block',
|
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
flavour: AttachmentBlockSchema.model.flavour,
|
|
||||||
props: {
|
props: {
|
||||||
name: fileName,
|
name: fileName,
|
||||||
sourceId: blobId,
|
sourceId: blobId,
|
||||||
footnoteIdentifier,
|
footnoteIdentifier,
|
||||||
style: 'citation',
|
style: 'citation',
|
||||||
},
|
},
|
||||||
children: [],
|
}),
|
||||||
},
|
|
||||||
'children'
|
'children'
|
||||||
)
|
)
|
||||||
.closeNode();
|
.closeNode();
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"playwright": "=1.58.2",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
open = () => {
|
open = () => {
|
||||||
window.open(this.link, '_blank');
|
const link = this.link;
|
||||||
|
if (!link) return;
|
||||||
|
window.open(link, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
refreshData = () => {
|
refreshData = () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -8,10 +9,9 @@ export default defineConfig({
|
|||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headless: true,
|
headless: true,
|
||||||
name: 'chromium',
|
instances: [{ browser: 'chromium' }],
|
||||||
provider: 'playwright',
|
provider: playwright(),
|
||||||
isolate: false,
|
isolate: false,
|
||||||
providerOptions: {},
|
|
||||||
},
|
},
|
||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ export class AffineCodeUnit extends ShadowlessElement {
|
|||||||
if (!codeBlock || !vElement) return plainContent;
|
if (!codeBlock || !vElement) return plainContent;
|
||||||
const tokens = codeBlock.highlightTokens$.value;
|
const tokens = codeBlock.highlightTokens$.value;
|
||||||
if (tokens.length === 0) return plainContent;
|
if (tokens.length === 0) return plainContent;
|
||||||
|
const line = tokens[vElement.lineIndex];
|
||||||
|
if (!line) return plainContent;
|
||||||
// copy the tokens to avoid modifying the original tokens
|
// copy the tokens to avoid modifying the original tokens
|
||||||
const lineTokens = structuredClone(tokens[vElement.lineIndex]);
|
const lineTokens = structuredClone(line);
|
||||||
if (lineTokens.length === 0) return plainContent;
|
if (lineTokens.length === 0) return plainContent;
|
||||||
|
|
||||||
const startOffset = vElement.startOffset;
|
const startOffset = vElement.startOffset;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -221,6 +221,12 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override getCSSScaleVal(): number {
|
||||||
|
const baseScale = super.getCSSScaleVal();
|
||||||
|
const extraScale = this.model.props.edgeless?.scale ?? 1;
|
||||||
|
return baseScale * extraScale;
|
||||||
|
}
|
||||||
|
|
||||||
override getRenderingRect() {
|
override getRenderingRect() {
|
||||||
const { xywh, edgeless } = this.model.props;
|
const { xywh, edgeless } = this.model.props;
|
||||||
const { collapse, scale = 1 } = edgeless;
|
const { collapse, scale = 1 } = edgeless;
|
||||||
@@ -255,7 +261,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
|||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
borderRadius: borderRadius + 'px',
|
borderRadius: borderRadius + 'px',
|
||||||
transform: `scale(${scale})`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
||||||
@@ -454,6 +459,28 @@ export const EdgelessNoteInteraction =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isClickOnTitle = false;
|
||||||
|
const titleRect = view
|
||||||
|
.querySelector('edgeless-page-block-title')
|
||||||
|
?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (titleRect) {
|
||||||
|
const titleBound = new Bound(
|
||||||
|
titleRect.x,
|
||||||
|
titleRect.y,
|
||||||
|
titleRect.width,
|
||||||
|
titleRect.height
|
||||||
|
);
|
||||||
|
if (titleBound.isPointInBound([e.clientX, e.clientY])) {
|
||||||
|
isClickOnTitle = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClickOnTitle) {
|
||||||
|
handleNativeRangeAtPoint(e.clientX, e.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (model.children.length === 0) {
|
if (model.children.length === 0) {
|
||||||
const blockId = std.store.addBlock(
|
const blockId = std.store.addBlock(
|
||||||
'affine:paragraph',
|
'affine:paragraph',
|
||||||
@@ -489,6 +516,9 @@ export const EdgelessNoteInteraction =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
} else if (multiSelect && alreadySelected && editing) {
|
||||||
|
// range selection using Shift-click when editing
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
context.default(context);
|
context.default(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
FrameBlockModel,
|
FrameBlockModel,
|
||||||
ImageBlockModel,
|
ImageBlockModel,
|
||||||
isExternalEmbedModel,
|
isExternalEmbedModel,
|
||||||
|
MindmapElementModel,
|
||||||
NoteBlockModel,
|
NoteBlockModel,
|
||||||
ParagraphBlockModel,
|
ParagraphBlockModel,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
@@ -401,7 +402,17 @@ function reorderElements(
|
|||||||
) {
|
) {
|
||||||
if (!models.length) return;
|
if (!models.length) return;
|
||||||
|
|
||||||
for (const model of models) {
|
const normalizedModels = Array.from(
|
||||||
|
new Map(
|
||||||
|
models.map(model => {
|
||||||
|
const reorderTarget =
|
||||||
|
model.group instanceof MindmapElementModel ? model.group : model;
|
||||||
|
return [reorderTarget.id, reorderTarget];
|
||||||
|
})
|
||||||
|
).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const model of normalizedModels) {
|
||||||
const index = ctx.gfx.layer.getReorderedIndex(model, type);
|
const index = ctx.gfx.layer.getReorderedIndex(model, type);
|
||||||
|
|
||||||
// block should be updated in transaction
|
// block should be updated in transaction
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -2,16 +2,24 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
|||||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
import type { IBound } from '@blocksuite/global/gfx';
|
import {
|
||||||
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
|
Bound,
|
||||||
|
getBoundWithRotation,
|
||||||
|
type IBound,
|
||||||
|
intersects,
|
||||||
|
} from '@blocksuite/global/gfx';
|
||||||
import type { BlockStdScope } from '@blocksuite/std';
|
import type { BlockStdScope } from '@blocksuite/std';
|
||||||
import type {
|
import type {
|
||||||
GfxCompatibleInterface,
|
GfxCompatibleInterface,
|
||||||
|
GfxController,
|
||||||
|
GfxLocalElementModel,
|
||||||
GridManager,
|
GridManager,
|
||||||
LayerManager,
|
LayerManager,
|
||||||
SurfaceBlockModel,
|
SurfaceBlockModel,
|
||||||
Viewport,
|
Viewport,
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
|
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||||
|
import { effect } from '@preact/signals-core';
|
||||||
import last from 'lodash-es/last';
|
import last from 'lodash-es/last';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
@@ -40,11 +48,82 @@ type RendererOptions = {
|
|||||||
surfaceModel: SurfaceBlockModel;
|
surfaceModel: SurfaceBlockModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CanvasRenderPassMetrics = {
|
||||||
|
overlayCount: number;
|
||||||
|
placeholderElementCount: number;
|
||||||
|
renderByBoundCallCount: number;
|
||||||
|
renderedElementCount: number;
|
||||||
|
visibleElementCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasMemorySnapshot = {
|
||||||
|
bytes: number;
|
||||||
|
datasetLayerId: string | null;
|
||||||
|
height: number;
|
||||||
|
kind: 'main' | 'stacking';
|
||||||
|
width: number;
|
||||||
|
zIndex: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasRendererDebugMetrics = {
|
||||||
|
canvasLayerCount: number;
|
||||||
|
canvasMemoryBytes: number;
|
||||||
|
canvasMemorySnapshots: CanvasMemorySnapshot[];
|
||||||
|
canvasMemoryMegabytes: number;
|
||||||
|
canvasPixelCount: number;
|
||||||
|
coalescedRefreshCount: number;
|
||||||
|
dirtyLayerRenderCount: number;
|
||||||
|
fallbackElementCount: number;
|
||||||
|
lastRenderDurationMs: number;
|
||||||
|
lastRenderMetrics: CanvasRenderPassMetrics;
|
||||||
|
maxRenderDurationMs: number;
|
||||||
|
pooledStackingCanvasCount: number;
|
||||||
|
refreshCount: number;
|
||||||
|
renderCount: number;
|
||||||
|
stackingCanvasCount: number;
|
||||||
|
totalLayerCount: number;
|
||||||
|
totalRenderDurationMs: number;
|
||||||
|
visibleStackingCanvasCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MutableCanvasRendererDebugMetrics = Omit<
|
||||||
|
CanvasRendererDebugMetrics,
|
||||||
|
| 'canvasLayerCount'
|
||||||
|
| 'canvasMemoryBytes'
|
||||||
|
| 'canvasMemoryMegabytes'
|
||||||
|
| 'canvasPixelCount'
|
||||||
|
| 'canvasMemorySnapshots'
|
||||||
|
| 'pooledStackingCanvasCount'
|
||||||
|
| 'stackingCanvasCount'
|
||||||
|
| 'totalLayerCount'
|
||||||
|
| 'visibleStackingCanvasCount'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type RenderPassStats = CanvasRenderPassMetrics;
|
||||||
|
|
||||||
|
type StackingCanvasState = {
|
||||||
|
bound: Bound | null;
|
||||||
|
layerId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RefreshTarget =
|
||||||
|
| { type: 'all' }
|
||||||
|
| { type: 'main' }
|
||||||
|
| { type: 'element'; element: SurfaceElementModel | GfxLocalElementModel }
|
||||||
|
| {
|
||||||
|
type: 'elements';
|
||||||
|
elements: Array<SurfaceElementModel | GfxLocalElementModel>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STACKING_CANVAS_PADDING = 32;
|
||||||
|
|
||||||
export class CanvasRenderer {
|
export class CanvasRenderer {
|
||||||
private _container!: HTMLElement;
|
private _container!: HTMLElement;
|
||||||
|
|
||||||
private readonly _disposables = new DisposableGroup();
|
private readonly _disposables = new DisposableGroup();
|
||||||
|
|
||||||
|
private readonly _gfx: GfxController;
|
||||||
|
|
||||||
private readonly _turboEnabled: () => boolean;
|
private readonly _turboEnabled: () => boolean;
|
||||||
|
|
||||||
private readonly _overlays = new Set<Overlay>();
|
private readonly _overlays = new Set<Overlay>();
|
||||||
@@ -53,6 +132,37 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
private _stackingCanvas: HTMLCanvasElement[] = [];
|
private _stackingCanvas: HTMLCanvasElement[] = [];
|
||||||
|
|
||||||
|
private readonly _stackingCanvasPool: HTMLCanvasElement[] = [];
|
||||||
|
|
||||||
|
private readonly _stackingCanvasState = new WeakMap<
|
||||||
|
HTMLCanvasElement,
|
||||||
|
StackingCanvasState
|
||||||
|
>();
|
||||||
|
|
||||||
|
private readonly _dirtyStackingCanvasIndexes = new Set<number>();
|
||||||
|
|
||||||
|
private _mainCanvasDirty = true;
|
||||||
|
|
||||||
|
private _needsFullRender = true;
|
||||||
|
|
||||||
|
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
|
||||||
|
refreshCount: 0,
|
||||||
|
coalescedRefreshCount: 0,
|
||||||
|
renderCount: 0,
|
||||||
|
totalRenderDurationMs: 0,
|
||||||
|
lastRenderDurationMs: 0,
|
||||||
|
maxRenderDurationMs: 0,
|
||||||
|
lastRenderMetrics: {
|
||||||
|
renderByBoundCallCount: 0,
|
||||||
|
visibleElementCount: 0,
|
||||||
|
renderedElementCount: 0,
|
||||||
|
placeholderElementCount: 0,
|
||||||
|
overlayCount: 0,
|
||||||
|
},
|
||||||
|
dirtyLayerRenderCount: 0,
|
||||||
|
fallbackElementCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
|
|
||||||
ctx: CanvasRenderingContext2D;
|
ctx: CanvasRenderingContext2D;
|
||||||
@@ -89,6 +199,7 @@ export class CanvasRenderer {
|
|||||||
this.layerManager = options.layerManager;
|
this.layerManager = options.layerManager;
|
||||||
this.grid = options.gridManager;
|
this.grid = options.gridManager;
|
||||||
this.provider = options.provider ?? {};
|
this.provider = options.provider ?? {};
|
||||||
|
this._gfx = this.std.get(GfxControllerIdentifier);
|
||||||
|
|
||||||
this._turboEnabled = () => {
|
this._turboEnabled = () => {
|
||||||
const featureFlagService = options.std.get(FeatureFlagService);
|
const featureFlagService = options.std.get(FeatureFlagService);
|
||||||
@@ -132,15 +243,199 @@ export class CanvasRenderer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _applyStackingCanvasLayout(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
bound: Bound | null,
|
||||||
|
dpr = window.devicePixelRatio
|
||||||
|
) {
|
||||||
|
const state =
|
||||||
|
this._stackingCanvasState.get(canvas) ??
|
||||||
|
({
|
||||||
|
bound: null,
|
||||||
|
layerId: canvas.dataset.layerId ?? null,
|
||||||
|
} satisfies StackingCanvasState);
|
||||||
|
|
||||||
|
if (!bound || bound.w <= 0 || bound.h <= 0) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
canvas.style.left = '0px';
|
||||||
|
canvas.style.top = '0px';
|
||||||
|
canvas.style.width = '0px';
|
||||||
|
canvas.style.height = '0px';
|
||||||
|
canvas.style.transform = '';
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
state.bound = null;
|
||||||
|
state.layerId = canvas.dataset.layerId ?? null;
|
||||||
|
this._stackingCanvasState.set(canvas, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { viewportBounds, zoom, viewScale } = this.viewport;
|
||||||
|
const width = bound.w * zoom;
|
||||||
|
const height = bound.h * zoom;
|
||||||
|
const left = (bound.x - viewportBounds.x) * zoom;
|
||||||
|
const top = (bound.y - viewportBounds.y) * zoom;
|
||||||
|
const actualWidth = Math.max(1, Math.ceil(width * dpr));
|
||||||
|
const actualHeight = Math.max(1, Math.ceil(height * dpr));
|
||||||
|
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
|
||||||
|
|
||||||
|
if (canvas.style.display !== 'block') {
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
}
|
||||||
|
if (canvas.style.left !== '0px') {
|
||||||
|
canvas.style.left = '0px';
|
||||||
|
}
|
||||||
|
if (canvas.style.top !== '0px') {
|
||||||
|
canvas.style.top = '0px';
|
||||||
|
}
|
||||||
|
if (canvas.style.width !== `${width}px`) {
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
}
|
||||||
|
if (canvas.style.height !== `${height}px`) {
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
if (canvas.style.transform !== transform) {
|
||||||
|
canvas.style.transform = transform;
|
||||||
|
}
|
||||||
|
if (canvas.style.transformOrigin !== 'top left') {
|
||||||
|
canvas.style.transformOrigin = 'top left';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvas.width !== actualWidth) {
|
||||||
|
canvas.width = actualWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvas.height !== actualHeight) {
|
||||||
|
canvas.height = actualHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.bound = bound;
|
||||||
|
state.layerId = canvas.dataset.layerId ?? null;
|
||||||
|
this._stackingCanvasState.set(canvas, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clampBoundToViewport(bound: Bound, viewportBounds: Bound) {
|
||||||
|
const minX = Math.max(bound.x, viewportBounds.x);
|
||||||
|
const minY = Math.max(bound.y, viewportBounds.y);
|
||||||
|
const maxX = Math.min(bound.maxX, viewportBounds.maxX);
|
||||||
|
const maxY = Math.min(bound.maxY, viewportBounds.maxY);
|
||||||
|
|
||||||
|
if (maxX <= minX || maxY <= minY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Bound(minX, minY, maxX - minX, maxY - minY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createCanvasForLayer(
|
||||||
|
onCreated?: (canvas: HTMLCanvasElement) => void
|
||||||
|
) {
|
||||||
|
const reused = this._stackingCanvasPool.pop();
|
||||||
|
|
||||||
|
if (reused) {
|
||||||
|
return reused;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = document.createElement('canvas');
|
||||||
|
onCreated?.(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _findLayerIndexByElement(
|
||||||
|
element: SurfaceElementModel | GfxLocalElementModel
|
||||||
|
) {
|
||||||
|
const canvasLayers = this.layerManager.getCanvasLayers();
|
||||||
|
const index = canvasLayers.findIndex(layer =>
|
||||||
|
layer.elements.some(layerElement => layerElement.id === element.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return index === -1 ? null : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getLayerRenderBound(
|
||||||
|
elements: SurfaceElementModel[],
|
||||||
|
viewportBounds: Bound
|
||||||
|
) {
|
||||||
|
let layerBound: Bound | null = null;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const display = (element.display ?? true) && !element.hidden;
|
||||||
|
|
||||||
|
if (!display) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementBound = Bound.from(getBoundWithRotation(element));
|
||||||
|
|
||||||
|
if (!intersects(elementBound, viewportBounds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
layerBound = layerBound ? layerBound.unite(elementBound) : elementBound;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layerBound) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._clampBoundToViewport(
|
||||||
|
layerBound.expand(STACKING_CANVAS_PADDING),
|
||||||
|
viewportBounds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getResolvedStackingCanvasBound(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
bound: Bound | null
|
||||||
|
) {
|
||||||
|
if (!bound || !this._gfx.tool.dragging$.peek()) {
|
||||||
|
return bound;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBound = this._stackingCanvasState.get(canvas)?.bound;
|
||||||
|
|
||||||
|
return previousBound ? previousBound.unite(bound) : bound;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _invalidate(target: RefreshTarget = { type: 'all' }) {
|
||||||
|
if (target.type === 'all') {
|
||||||
|
this._needsFullRender = true;
|
||||||
|
this._mainCanvasDirty = true;
|
||||||
|
this._dirtyStackingCanvasIndexes.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._needsFullRender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'main') {
|
||||||
|
this._mainCanvasDirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements =
|
||||||
|
target.type === 'element' ? [target.element] : target.elements;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const layerIndex = this._findLayerIndexByElement(element);
|
||||||
|
|
||||||
|
if (layerIndex === null || layerIndex >= this._stackingCanvas.length) {
|
||||||
|
this._mainCanvasDirty = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._dirtyStackingCanvasIndexes.add(layerIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetPooledCanvas(canvas: HTMLCanvasElement) {
|
||||||
|
canvas.dataset.layerId = '';
|
||||||
|
this._applyStackingCanvasLayout(canvas, null);
|
||||||
|
}
|
||||||
|
|
||||||
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
|
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
|
||||||
const layer = this.layerManager;
|
const layer = this.layerManager;
|
||||||
const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => {
|
|
||||||
this._stackingCanvas = canvases;
|
|
||||||
|
|
||||||
const sizeUpdater = this._canvasSizeUpdater();
|
|
||||||
|
|
||||||
canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update);
|
|
||||||
};
|
|
||||||
const updateStackingCanvas = () => {
|
const updateStackingCanvas = () => {
|
||||||
/**
|
/**
|
||||||
* we already have a main canvas, so the last layer should be skipped
|
* we already have a main canvas, so the last layer should be skipped
|
||||||
@@ -159,11 +454,7 @@ export class CanvasRenderer {
|
|||||||
const created = i < currentCanvases.length;
|
const created = i < currentCanvases.length;
|
||||||
const canvas = created
|
const canvas = created
|
||||||
? currentCanvases[i]
|
? currentCanvases[i]
|
||||||
: document.createElement('canvas');
|
: this._createCanvasForLayer(onCreated);
|
||||||
|
|
||||||
if (!created) {
|
|
||||||
onCreated?.(canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
|
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
|
||||||
canvas.style.zIndex = layer.zIndex.toString();
|
canvas.style.zIndex = layer.zIndex.toString();
|
||||||
@@ -171,7 +462,6 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._stackingCanvas = canvases;
|
this._stackingCanvas = canvases;
|
||||||
updateStackingCanvasSize(canvases);
|
|
||||||
|
|
||||||
if (currentCanvases.length !== canvases.length) {
|
if (currentCanvases.length !== canvases.length) {
|
||||||
const diff = canvases.length - currentCanvases.length;
|
const diff = canvases.length - currentCanvases.length;
|
||||||
@@ -189,12 +479,16 @@ export class CanvasRenderer {
|
|||||||
payload.added = canvases.slice(-diff);
|
payload.added = canvases.slice(-diff);
|
||||||
} else {
|
} else {
|
||||||
payload.removed = currentCanvases.slice(diff);
|
payload.removed = currentCanvases.slice(diff);
|
||||||
|
payload.removed.forEach(canvas => {
|
||||||
|
this._resetPooledCanvas(canvas);
|
||||||
|
this._stackingCanvasPool.push(canvas);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stackingCanvasUpdated.next(payload);
|
this.stackingCanvasUpdated.next(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.refresh();
|
this.refresh({ type: 'all' });
|
||||||
};
|
};
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
@@ -211,7 +505,7 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.viewport.viewportUpdated.subscribe(() => {
|
this.viewport.viewportUpdated.subscribe(() => {
|
||||||
this.refresh();
|
this.refresh({ type: 'all' });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -222,7 +516,6 @@ export class CanvasRenderer {
|
|||||||
sizeUpdatedRafId = null;
|
sizeUpdatedRafId = null;
|
||||||
this._resetSize();
|
this._resetSize();
|
||||||
this._render();
|
this._render();
|
||||||
this.refresh();
|
|
||||||
}, this._container);
|
}, this._container);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -233,69 +526,212 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||||
this.usePlaceholder = shouldRenderPlaceholders;
|
this.usePlaceholder = shouldRenderPlaceholders;
|
||||||
this.refresh();
|
this.refresh({ type: 'all' });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let wasDragging = false;
|
||||||
|
this._disposables.add(
|
||||||
|
effect(() => {
|
||||||
|
const isDragging = this._gfx.tool.dragging$.value;
|
||||||
|
|
||||||
|
if (wasDragging && !isDragging) {
|
||||||
|
this.refresh({ type: 'all' });
|
||||||
|
}
|
||||||
|
|
||||||
|
wasDragging = isDragging;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.usePlaceholder = false;
|
this.usePlaceholder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _createRenderPassStats(): RenderPassStats {
|
||||||
|
return {
|
||||||
|
renderByBoundCallCount: 0,
|
||||||
|
visibleElementCount: 0,
|
||||||
|
renderedElementCount: 0,
|
||||||
|
placeholderElementCount: 0,
|
||||||
|
overlayCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getCanvasMemorySnapshots(): CanvasMemorySnapshot[] {
|
||||||
|
return [this.canvas, ...this._stackingCanvas].map((canvas, index) => {
|
||||||
|
return {
|
||||||
|
kind: index === 0 ? 'main' : 'stacking',
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
bytes: canvas.width * canvas.height * 4,
|
||||||
|
zIndex: canvas.style.zIndex,
|
||||||
|
datasetLayerId: canvas.dataset.layerId ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _render() {
|
private _render() {
|
||||||
|
const renderStart = performance.now();
|
||||||
const { viewportBounds, zoom } = this.viewport;
|
const { viewportBounds, zoom } = this.viewport;
|
||||||
const { ctx } = this;
|
const { ctx } = this;
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = window.devicePixelRatio;
|
||||||
const scale = zoom * dpr;
|
const scale = zoom * dpr;
|
||||||
const matrix = new DOMMatrix().scaleSelf(scale);
|
const matrix = new DOMMatrix().scaleSelf(scale);
|
||||||
|
const renderStats = this._createRenderPassStats();
|
||||||
|
const fullRender = this._needsFullRender;
|
||||||
|
const stackingIndexesToRender = fullRender
|
||||||
|
? this._stackingCanvas.map((_, idx) => idx)
|
||||||
|
: [...this._dirtyStackingCanvasIndexes];
|
||||||
/**
|
/**
|
||||||
* if a layer does not have a corresponding canvas
|
* if a layer does not have a corresponding canvas
|
||||||
* its element will be add to this array and drawing on the
|
* its element will be add to this array and drawing on the
|
||||||
* main canvas
|
* main canvas
|
||||||
*/
|
*/
|
||||||
let fallbackElement: SurfaceElementModel[] = [];
|
let fallbackElement: SurfaceElementModel[] = [];
|
||||||
|
const allCanvasLayers = this.layerManager.getCanvasLayers();
|
||||||
|
const viewportBound = Bound.from(viewportBounds);
|
||||||
|
|
||||||
this.layerManager.getCanvasLayers().forEach((layer, idx) => {
|
for (const idx of stackingIndexesToRender) {
|
||||||
if (!this._stackingCanvas[idx]) {
|
const layer = allCanvasLayers[idx];
|
||||||
fallbackElement = fallbackElement.concat(layer.elements);
|
const canvas = this._stackingCanvas[idx];
|
||||||
return;
|
|
||||||
|
if (!layer || !canvas) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = this._stackingCanvas[idx];
|
const layerRenderBound = this._getLayerRenderBound(
|
||||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
layer.elements,
|
||||||
const rc = new RoughCanvas(ctx.canvas);
|
viewportBound
|
||||||
|
);
|
||||||
|
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
|
||||||
|
canvas,
|
||||||
|
layerRenderBound
|
||||||
|
);
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
this._applyStackingCanvasLayout(canvas, resolvedLayerRenderBound);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resolvedLayerRenderBound ||
|
||||||
|
canvas.width === 0 ||
|
||||||
|
canvas.height === 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerCtx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||||
|
const layerRc = new RoughCanvas(layerCtx.canvas);
|
||||||
|
|
||||||
|
layerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
layerCtx.save();
|
||||||
|
layerCtx.setTransform(matrix);
|
||||||
|
|
||||||
|
this._renderByBound(
|
||||||
|
layerCtx,
|
||||||
|
matrix,
|
||||||
|
layerRc,
|
||||||
|
resolvedLayerRenderBound,
|
||||||
|
layer.elements,
|
||||||
|
false,
|
||||||
|
renderStats
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRender || this._mainCanvasDirty) {
|
||||||
|
allCanvasLayers.forEach((layer, idx) => {
|
||||||
|
if (!this._stackingCanvas[idx]) {
|
||||||
|
fallbackElement = fallbackElement.concat(layer.elements);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.setTransform(matrix);
|
ctx.setTransform(matrix);
|
||||||
|
|
||||||
this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements);
|
this._renderByBound(
|
||||||
});
|
ctx,
|
||||||
|
matrix,
|
||||||
|
new RoughCanvas(ctx.canvas),
|
||||||
|
viewportBounds,
|
||||||
|
fallbackElement,
|
||||||
|
true,
|
||||||
|
renderStats
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
const canvasMemorySnapshots = this._getCanvasMemorySnapshots();
|
||||||
ctx.save();
|
const canvasMemoryBytes = canvasMemorySnapshots.reduce(
|
||||||
|
(sum, snapshot) => sum + snapshot.bytes,
|
||||||
ctx.setTransform(matrix);
|
0
|
||||||
|
|
||||||
this._renderByBound(
|
|
||||||
ctx,
|
|
||||||
matrix,
|
|
||||||
new RoughCanvas(ctx.canvas),
|
|
||||||
viewportBounds,
|
|
||||||
fallbackElement,
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
const layerTypes = this.layerManager.layers.map(layer => layer.type);
|
||||||
|
const renderDurationMs = performance.now() - renderStart;
|
||||||
|
|
||||||
|
this._debugMetrics.renderCount += 1;
|
||||||
|
this._debugMetrics.totalRenderDurationMs += renderDurationMs;
|
||||||
|
this._debugMetrics.lastRenderDurationMs = renderDurationMs;
|
||||||
|
this._debugMetrics.maxRenderDurationMs = Math.max(
|
||||||
|
this._debugMetrics.maxRenderDurationMs,
|
||||||
|
renderDurationMs
|
||||||
|
);
|
||||||
|
this._debugMetrics.lastRenderMetrics = renderStats;
|
||||||
|
this._debugMetrics.fallbackElementCount = fallbackElement.length;
|
||||||
|
this._debugMetrics.dirtyLayerRenderCount = stackingIndexesToRender.length;
|
||||||
|
|
||||||
|
this._lastDebugSnapshot = {
|
||||||
|
canvasMemorySnapshots,
|
||||||
|
canvasMemoryBytes,
|
||||||
|
canvasPixelCount: canvasMemorySnapshots.reduce(
|
||||||
|
(sum, snapshot) => sum + snapshot.width * snapshot.height,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
stackingCanvasCount: this._stackingCanvas.length,
|
||||||
|
canvasLayerCount: layerTypes.filter(type => type === 'canvas').length,
|
||||||
|
totalLayerCount: layerTypes.length,
|
||||||
|
pooledStackingCanvasCount: this._stackingCanvasPool.length,
|
||||||
|
visibleStackingCanvasCount: this._stackingCanvas.filter(
|
||||||
|
canvas => canvas.width > 0 && canvas.height > 0
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._needsFullRender = false;
|
||||||
|
this._mainCanvasDirty = false;
|
||||||
|
this._dirtyStackingCanvasIndexes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _lastDebugSnapshot: Pick<
|
||||||
|
CanvasRendererDebugMetrics,
|
||||||
|
| 'canvasMemoryBytes'
|
||||||
|
| 'canvasMemorySnapshots'
|
||||||
|
| 'canvasPixelCount'
|
||||||
|
| 'canvasLayerCount'
|
||||||
|
| 'pooledStackingCanvasCount'
|
||||||
|
| 'stackingCanvasCount'
|
||||||
|
| 'totalLayerCount'
|
||||||
|
| 'visibleStackingCanvasCount'
|
||||||
|
> = {
|
||||||
|
canvasMemoryBytes: 0,
|
||||||
|
canvasMemorySnapshots: [],
|
||||||
|
canvasPixelCount: 0,
|
||||||
|
canvasLayerCount: 0,
|
||||||
|
pooledStackingCanvasCount: 0,
|
||||||
|
stackingCanvasCount: 0,
|
||||||
|
totalLayerCount: 0,
|
||||||
|
visibleStackingCanvasCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
private _renderByBound(
|
private _renderByBound(
|
||||||
ctx: CanvasRenderingContext2D | null,
|
ctx: CanvasRenderingContext2D | null,
|
||||||
matrix: DOMMatrix,
|
matrix: DOMMatrix,
|
||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
bound: IBound,
|
bound: IBound,
|
||||||
surfaceElements?: SurfaceElementModel[],
|
surfaceElements?: SurfaceElementModel[],
|
||||||
overLay: boolean = false
|
overLay: boolean = false,
|
||||||
|
renderStats?: RenderPassStats
|
||||||
) {
|
) {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
renderStats && (renderStats.renderByBoundCallCount += 1);
|
||||||
|
|
||||||
const elements =
|
const elements =
|
||||||
surfaceElements ??
|
surfaceElements ??
|
||||||
(this.grid.search(bound, {
|
(this.grid.search(bound, {
|
||||||
@@ -305,10 +741,12 @@ export class CanvasRenderer {
|
|||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const display = (element.display ?? true) && !element.hidden;
|
const display = (element.display ?? true) && !element.hidden;
|
||||||
if (display && intersects(getBoundWithRotation(element), bound)) {
|
if (display && intersects(getBoundWithRotation(element), bound)) {
|
||||||
|
renderStats && (renderStats.visibleElementCount += 1);
|
||||||
if (
|
if (
|
||||||
this.usePlaceholder &&
|
this.usePlaceholder &&
|
||||||
!(element as GfxCompatibleInterface).forceFullRender
|
!(element as GfxCompatibleInterface).forceFullRender
|
||||||
) {
|
) {
|
||||||
|
renderStats && (renderStats.placeholderElementCount += 1);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
||||||
const drawX = element.x - bound.x;
|
const drawX = element.x - bound.x;
|
||||||
@@ -316,6 +754,7 @@ export class CanvasRenderer {
|
|||||||
ctx.fillRect(drawX, drawY, element.w, element.h);
|
ctx.fillRect(drawX, drawY, element.w, element.h);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
} else {
|
} else {
|
||||||
|
renderStats && (renderStats.renderedElementCount += 1);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
const renderFn = this.std.getOptional<ElementRenderer>(
|
const renderFn = this.std.getOptional<ElementRenderer>(
|
||||||
ElementRendererIdentifier(element.type)
|
ElementRendererIdentifier(element.type)
|
||||||
@@ -333,6 +772,7 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (overLay) {
|
if (overLay) {
|
||||||
|
renderStats && (renderStats.overlayCount += this._overlays.size);
|
||||||
for (const overlay of this._overlays) {
|
for (const overlay of this._overlays) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(-bound.x, -bound.y);
|
ctx.translate(-bound.x, -bound.y);
|
||||||
@@ -348,33 +788,38 @@ export class CanvasRenderer {
|
|||||||
const sizeUpdater = this._canvasSizeUpdater();
|
const sizeUpdater = this._canvasSizeUpdater();
|
||||||
|
|
||||||
sizeUpdater.update(this.canvas);
|
sizeUpdater.update(this.canvas);
|
||||||
|
this._invalidate({ type: 'all' });
|
||||||
this._stackingCanvas.forEach(sizeUpdater.update);
|
|
||||||
this.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _watchSurface(surfaceModel: SurfaceBlockModel) {
|
private _watchSurface(surfaceModel: SurfaceBlockModel) {
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.elementAdded.subscribe(() => this.refresh())
|
surfaceModel.elementAdded.subscribe(() => this.refresh({ type: 'all' }))
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.elementRemoved.subscribe(() => this.refresh())
|
surfaceModel.elementRemoved.subscribe(() => this.refresh({ type: 'all' }))
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.localElementAdded.subscribe(() => this.refresh())
|
surfaceModel.localElementAdded.subscribe(() =>
|
||||||
|
this.refresh({ type: 'all' })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.localElementDeleted.subscribe(() => this.refresh())
|
surfaceModel.localElementDeleted.subscribe(() =>
|
||||||
|
this.refresh({ type: 'all' })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.localElementUpdated.subscribe(() => this.refresh())
|
surfaceModel.localElementUpdated.subscribe(({ model }) => {
|
||||||
|
this.refresh({ type: 'element', element: model });
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.elementUpdated.subscribe(payload => {
|
surfaceModel.elementUpdated.subscribe(payload => {
|
||||||
// ignore externalXYWH update cause it's updated by the renderer
|
// ignore externalXYWH update cause it's updated by the renderer
|
||||||
if (payload.props['externalXYWH']) return;
|
if (payload.props['externalXYWH']) return;
|
||||||
this.refresh();
|
const element = surfaceModel.getElementById(payload.id);
|
||||||
|
this.refresh(element ? { type: 'element', element } : { type: 'all' });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,7 +827,7 @@ export class CanvasRenderer {
|
|||||||
addOverlay(overlay: Overlay) {
|
addOverlay(overlay: Overlay) {
|
||||||
overlay.setRenderer(this);
|
overlay.setRenderer(this);
|
||||||
this._overlays.add(overlay);
|
this._overlays.add(overlay);
|
||||||
this.refresh();
|
this.refresh({ type: 'main' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -394,7 +839,7 @@ export class CanvasRenderer {
|
|||||||
container.append(this.canvas);
|
container.append(this.canvas);
|
||||||
|
|
||||||
this._resetSize();
|
this._resetSize();
|
||||||
this.refresh();
|
this.refresh({ type: 'all' });
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
@@ -453,8 +898,46 @@ export class CanvasRenderer {
|
|||||||
return this.provider.getPropertyValue?.(property) ?? '';
|
return this.provider.getPropertyValue?.(property) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
getDebugMetrics(): CanvasRendererDebugMetrics {
|
||||||
if (this._refreshRafId !== null) return;
|
return {
|
||||||
|
...this._debugMetrics,
|
||||||
|
...this._lastDebugSnapshot,
|
||||||
|
canvasMemoryMegabytes:
|
||||||
|
this._lastDebugSnapshot.canvasMemoryBytes / 1024 / 1024,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDebugMetrics() {
|
||||||
|
this._debugMetrics = {
|
||||||
|
refreshCount: 0,
|
||||||
|
coalescedRefreshCount: 0,
|
||||||
|
renderCount: 0,
|
||||||
|
totalRenderDurationMs: 0,
|
||||||
|
lastRenderDurationMs: 0,
|
||||||
|
maxRenderDurationMs: 0,
|
||||||
|
lastRenderMetrics: this._createRenderPassStats(),
|
||||||
|
dirtyLayerRenderCount: 0,
|
||||||
|
fallbackElementCount: 0,
|
||||||
|
};
|
||||||
|
this._lastDebugSnapshot = {
|
||||||
|
canvasMemoryBytes: 0,
|
||||||
|
canvasMemorySnapshots: [],
|
||||||
|
canvasPixelCount: 0,
|
||||||
|
canvasLayerCount: 0,
|
||||||
|
pooledStackingCanvasCount: 0,
|
||||||
|
stackingCanvasCount: 0,
|
||||||
|
totalLayerCount: 0,
|
||||||
|
visibleStackingCanvasCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(target: RefreshTarget = { type: 'all' }) {
|
||||||
|
this._debugMetrics.refreshCount += 1;
|
||||||
|
this._invalidate(target);
|
||||||
|
if (this._refreshRafId !== null) {
|
||||||
|
this._debugMetrics.coalescedRefreshCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._refreshRafId = requestConnectedFrame(() => {
|
this._refreshRafId = requestConnectedFrame(() => {
|
||||||
this._refreshRafId = null;
|
this._refreshRafId = null;
|
||||||
@@ -469,6 +952,6 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
overlay.setRenderer(null);
|
overlay.setRenderer(null);
|
||||||
this._overlays.delete(overlay);
|
this._overlays.delete(overlay);
|
||||||
this.refresh();
|
this.refresh({ type: 'main' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,30 +354,37 @@ export class DomRenderer {
|
|||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.elementAdded.subscribe(payload => {
|
surfaceModel.elementAdded.subscribe(payload => {
|
||||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
|
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
|
||||||
|
this._markViewportDirty();
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.elementRemoved.subscribe(payload => {
|
surfaceModel.elementRemoved.subscribe(payload => {
|
||||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
|
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
|
||||||
|
this._markViewportDirty();
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.localElementAdded.subscribe(payload => {
|
surfaceModel.localElementAdded.subscribe(payload => {
|
||||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
|
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
|
||||||
|
this._markViewportDirty();
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.localElementDeleted.subscribe(payload => {
|
surfaceModel.localElementDeleted.subscribe(payload => {
|
||||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
|
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
|
||||||
|
this._markViewportDirty();
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
surfaceModel.localElementUpdated.subscribe(payload => {
|
surfaceModel.localElementUpdated.subscribe(payload => {
|
||||||
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
|
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
|
||||||
|
if (payload.props['index'] || payload.props['groupId']) {
|
||||||
|
this._markViewportDirty();
|
||||||
|
}
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -387,6 +394,9 @@ export class DomRenderer {
|
|||||||
// ignore externalXYWH update cause it's updated by the renderer
|
// ignore externalXYWH update cause it's updated by the renderer
|
||||||
if (payload.props['externalXYWH']) return;
|
if (payload.props['externalXYWH']) return;
|
||||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
|
this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
|
||||||
|
if (payload.props['index'] || payload.props['childIds']) {
|
||||||
|
this._markViewportDirty();
|
||||||
|
}
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/sync": "workspace:*",
|
"@blocksuite/sync": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
"@lottiefiles/dotlottie-wc": "^0.9.4",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@toeverything/theme": "^1.1.23",
|
"@toeverything/theme": "^1.1.23",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { getHostName } from '@blocksuite/affine-shared/utils';
|
import {
|
||||||
|
getHostName,
|
||||||
|
isValidUrl,
|
||||||
|
normalizeUrl,
|
||||||
|
} from '@blocksuite/affine-shared/utils';
|
||||||
import { PropTypes, requiredProperties } from '@blocksuite/std';
|
import { PropTypes, requiredProperties } from '@blocksuite/std';
|
||||||
import { css, LitElement } from 'lit';
|
import { css, LitElement } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
@@ -44,15 +48,27 @@ export class LinkPreview extends LitElement {
|
|||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const { url } = this;
|
const { url } = this;
|
||||||
|
const normalizedUrl = normalizeUrl(url);
|
||||||
|
const safeUrl =
|
||||||
|
normalizedUrl && isValidUrl(normalizedUrl) ? normalizedUrl : null;
|
||||||
|
const hostName = getHostName(safeUrl ?? url);
|
||||||
|
|
||||||
|
if (!safeUrl) {
|
||||||
|
return html`
|
||||||
|
<span class="affine-link-preview">
|
||||||
|
<span>${hostName}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<a
|
<a
|
||||||
class="affine-link-preview"
|
class="affine-link-preview"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href=${url}
|
href=${safeUrl}
|
||||||
>
|
>
|
||||||
<span>${getHostName(url)}</span>
|
<span>${hostName}</span>
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ export class RecordField extends SignalWatcher(
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-content .affine-database-number {
|
.field-content affine-database-number-cell .number {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
justify-content: start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-content:hover {
|
.field-content:hover {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul',
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/ext-loader',
|
reportsDirectory: '../../../.coverage/ext-loader',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import type { BrushElementModel } from '@blocksuite/affine-model';
|
import type { BrushElementModel } from '@blocksuite/affine-model';
|
||||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||||
|
|
||||||
|
import { renderBrushLikeDom } from './shared';
|
||||||
|
|
||||||
export const BrushDomRendererExtension = DomElementRendererExtension(
|
export const BrushDomRendererExtension = DomElementRendererExtension(
|
||||||
'brush',
|
'brush',
|
||||||
(
|
(
|
||||||
@@ -12,58 +14,11 @@ export const BrushDomRendererExtension = DomElementRendererExtension(
|
|||||||
domElement: HTMLElement,
|
domElement: HTMLElement,
|
||||||
renderer: DomRenderer
|
renderer: DomRenderer
|
||||||
) => {
|
) => {
|
||||||
const { zoom } = renderer.viewport;
|
renderBrushLikeDom({
|
||||||
const [, , w, h] = model.deserializedXYWH;
|
model,
|
||||||
|
domElement,
|
||||||
// Early return if invalid dimensions
|
renderer,
|
||||||
if (w <= 0 || h <= 0) {
|
color: renderer.getColorValue(model.color, DefaultTheme.black, true),
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Early return if no commands
|
|
||||||
if (!model.commands) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous content
|
|
||||||
domElement.innerHTML = '';
|
|
||||||
|
|
||||||
// Get color value
|
|
||||||
const color = renderer.getColorValue(model.color, DefaultTheme.black, true);
|
|
||||||
|
|
||||||
// Create SVG element
|
|
||||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
||||||
svg.style.position = 'absolute';
|
|
||||||
svg.style.left = '0';
|
|
||||||
svg.style.top = '0';
|
|
||||||
svg.style.width = `${w * zoom}px`;
|
|
||||||
svg.style.height = `${h * zoom}px`;
|
|
||||||
svg.style.overflow = 'visible';
|
|
||||||
svg.style.pointerEvents = 'none';
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
||||||
|
|
||||||
// Apply rotation transform
|
|
||||||
if (model.rotate !== 0) {
|
|
||||||
svg.style.transform = `rotate(${model.rotate}deg)`;
|
|
||||||
svg.style.transformOrigin = 'center';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create path element for the brush stroke
|
|
||||||
const pathElement = document.createElementNS(
|
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'path'
|
|
||||||
);
|
|
||||||
pathElement.setAttribute('d', model.commands);
|
|
||||||
pathElement.setAttribute('fill', color);
|
|
||||||
pathElement.setAttribute('stroke', 'none');
|
|
||||||
|
|
||||||
svg.append(pathElement);
|
|
||||||
domElement.replaceChildren(svg);
|
|
||||||
|
|
||||||
// Set element size and position
|
|
||||||
domElement.style.width = `${w * zoom}px`;
|
|
||||||
domElement.style.height = `${h * zoom}px`;
|
|
||||||
domElement.style.overflow = 'visible';
|
|
||||||
domElement.style.pointerEvents = 'none';
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import type { HighlighterElementModel } from '@blocksuite/affine-model';
|
import type { HighlighterElementModel } from '@blocksuite/affine-model';
|
||||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||||
|
|
||||||
|
import { renderBrushLikeDom } from './shared';
|
||||||
|
|
||||||
export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
||||||
'highlighter',
|
'highlighter',
|
||||||
(
|
(
|
||||||
@@ -12,62 +14,15 @@ export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
|||||||
domElement: HTMLElement,
|
domElement: HTMLElement,
|
||||||
renderer: DomRenderer
|
renderer: DomRenderer
|
||||||
) => {
|
) => {
|
||||||
const { zoom } = renderer.viewport;
|
renderBrushLikeDom({
|
||||||
const [, , w, h] = model.deserializedXYWH;
|
model,
|
||||||
|
domElement,
|
||||||
// Early return if invalid dimensions
|
renderer,
|
||||||
if (w <= 0 || h <= 0) {
|
color: renderer.getColorValue(
|
||||||
return;
|
model.color,
|
||||||
}
|
DefaultTheme.hightlighterColor,
|
||||||
|
true
|
||||||
// Early return if no commands
|
),
|
||||||
if (!model.commands) {
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous content
|
|
||||||
domElement.innerHTML = '';
|
|
||||||
|
|
||||||
// Get color value
|
|
||||||
const color = renderer.getColorValue(
|
|
||||||
model.color,
|
|
||||||
DefaultTheme.hightlighterColor,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create SVG element
|
|
||||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
||||||
svg.style.position = 'absolute';
|
|
||||||
svg.style.left = '0';
|
|
||||||
svg.style.top = '0';
|
|
||||||
svg.style.width = `${w * zoom}px`;
|
|
||||||
svg.style.height = `${h * zoom}px`;
|
|
||||||
svg.style.overflow = 'visible';
|
|
||||||
svg.style.pointerEvents = 'none';
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
||||||
|
|
||||||
// Apply rotation transform
|
|
||||||
if (model.rotate !== 0) {
|
|
||||||
svg.style.transform = `rotate(${model.rotate}deg)`;
|
|
||||||
svg.style.transformOrigin = 'center';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create path element for the highlighter stroke
|
|
||||||
const pathElement = document.createElementNS(
|
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'path'
|
|
||||||
);
|
|
||||||
pathElement.setAttribute('d', model.commands);
|
|
||||||
pathElement.setAttribute('fill', color);
|
|
||||||
pathElement.setAttribute('stroke', 'none');
|
|
||||||
|
|
||||||
svg.append(pathElement);
|
|
||||||
domElement.replaceChildren(svg);
|
|
||||||
|
|
||||||
// Set element size and position
|
|
||||||
domElement.style.width = `${w * zoom}px`;
|
|
||||||
domElement.style.height = `${h * zoom}px`;
|
|
||||||
domElement.style.overflow = 'visible';
|
|
||||||
domElement.style.pointerEvents = 'none';
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
82
blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { DomRenderer } from '@blocksuite/affine-block-surface';
|
||||||
|
import type {
|
||||||
|
BrushElementModel,
|
||||||
|
HighlighterElementModel,
|
||||||
|
} from '@blocksuite/affine-model';
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
type BrushLikeModel = BrushElementModel | HighlighterElementModel;
|
||||||
|
|
||||||
|
type RetainedBrushDom = {
|
||||||
|
path: SVGPathElement;
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retainedBrushDom = new WeakMap<HTMLElement, RetainedBrushDom>();
|
||||||
|
|
||||||
|
function clearBrushLikeDom(domElement: HTMLElement) {
|
||||||
|
retainedBrushDom.delete(domElement);
|
||||||
|
domElement.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetainedBrushDom(domElement: HTMLElement) {
|
||||||
|
const existing = retainedBrushDom.get(domElement);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||||
|
svg.style.position = 'absolute';
|
||||||
|
svg.style.left = '0';
|
||||||
|
svg.style.top = '0';
|
||||||
|
svg.style.overflow = 'visible';
|
||||||
|
svg.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
const path = document.createElementNS(SVG_NS, 'path');
|
||||||
|
path.setAttribute('stroke', 'none');
|
||||||
|
svg.append(path);
|
||||||
|
|
||||||
|
const retained = { svg, path };
|
||||||
|
retainedBrushDom.set(domElement, retained);
|
||||||
|
domElement.replaceChildren(svg);
|
||||||
|
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBrushLikeDom({
|
||||||
|
color,
|
||||||
|
domElement,
|
||||||
|
model,
|
||||||
|
renderer,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
domElement: HTMLElement;
|
||||||
|
model: BrushLikeModel;
|
||||||
|
renderer: DomRenderer;
|
||||||
|
}) {
|
||||||
|
const { zoom } = renderer.viewport;
|
||||||
|
const [, , w, h] = model.deserializedXYWH;
|
||||||
|
|
||||||
|
if (w <= 0 || h <= 0 || !model.commands) {
|
||||||
|
clearBrushLikeDom(domElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path, svg } = getRetainedBrushDom(domElement);
|
||||||
|
|
||||||
|
svg.style.width = `${w * zoom}px`;
|
||||||
|
svg.style.height = `${h * zoom}px`;
|
||||||
|
svg.style.transform = model.rotate === 0 ? '' : `rotate(${model.rotate}deg)`;
|
||||||
|
svg.style.transformOrigin = model.rotate === 0 ? '' : 'center';
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||||
|
|
||||||
|
path.setAttribute('d', model.commands);
|
||||||
|
path.setAttribute('fill', color);
|
||||||
|
|
||||||
|
domElement.style.width = `${w * zoom}px`;
|
||||||
|
domElement.style.height = `${h * zoom}px`;
|
||||||
|
domElement.style.overflow = 'visible';
|
||||||
|
domElement.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
|
|||||||
import { isConnectorWithLabel } from '../connector-manager';
|
import { isConnectorWithLabel } from '../connector-manager';
|
||||||
import { DEFAULT_ARROW_SIZE } from './utils';
|
import { DEFAULT_ARROW_SIZE } from './utils';
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
interface PathBounds {
|
interface PathBounds {
|
||||||
minX: number;
|
minX: number;
|
||||||
minY: number;
|
minY: number;
|
||||||
@@ -21,6 +23,15 @@ interface PathBounds {
|
|||||||
maxY: number;
|
maxY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RetainedConnectorDom = {
|
||||||
|
defs: SVGDefsElement;
|
||||||
|
label: HTMLDivElement | null;
|
||||||
|
path: SVGPathElement;
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retainedConnectorDom = new WeakMap<HTMLElement, RetainedConnectorDom>();
|
||||||
|
|
||||||
function calculatePathBounds(path: PointLocation[]): PathBounds {
|
function calculatePathBounds(path: PointLocation[]): PathBounds {
|
||||||
if (path.length === 0) {
|
if (path.length === 0) {
|
||||||
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||||
@@ -81,10 +92,7 @@ function createArrowMarker(
|
|||||||
strokeWidth: number,
|
strokeWidth: number,
|
||||||
isStart: boolean = false
|
isStart: boolean = false
|
||||||
): SVGMarkerElement {
|
): SVGMarkerElement {
|
||||||
const marker = document.createElementNS(
|
const marker = document.createElementNS(SVG_NS, 'marker');
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'marker'
|
|
||||||
);
|
|
||||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||||
|
|
||||||
marker.id = id;
|
marker.id = id;
|
||||||
@@ -98,10 +106,7 @@ function createArrowMarker(
|
|||||||
|
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'Arrow': {
|
case 'Arrow': {
|
||||||
const path = document.createElementNS(
|
const path = document.createElementNS(SVG_NS, 'path');
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'path'
|
|
||||||
);
|
|
||||||
path.setAttribute(
|
path.setAttribute(
|
||||||
'd',
|
'd',
|
||||||
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z'
|
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z'
|
||||||
@@ -112,10 +117,7 @@ function createArrowMarker(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Triangle': {
|
case 'Triangle': {
|
||||||
const path = document.createElementNS(
|
const path = document.createElementNS(SVG_NS, 'path');
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'path'
|
|
||||||
);
|
|
||||||
path.setAttribute(
|
path.setAttribute(
|
||||||
'd',
|
'd',
|
||||||
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z'
|
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z'
|
||||||
@@ -126,10 +128,7 @@ function createArrowMarker(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Circle': {
|
case 'Circle': {
|
||||||
const circle = document.createElementNS(
|
const circle = document.createElementNS(SVG_NS, 'circle');
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'circle'
|
|
||||||
);
|
|
||||||
circle.setAttribute('cx', '10');
|
circle.setAttribute('cx', '10');
|
||||||
circle.setAttribute('cy', '10');
|
circle.setAttribute('cy', '10');
|
||||||
circle.setAttribute('r', '4');
|
circle.setAttribute('r', '4');
|
||||||
@@ -139,10 +138,7 @@ function createArrowMarker(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Diamond': {
|
case 'Diamond': {
|
||||||
const path = document.createElementNS(
|
const path = document.createElementNS(SVG_NS, 'path');
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'path'
|
|
||||||
);
|
|
||||||
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
|
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
|
||||||
path.setAttribute('fill', color);
|
path.setAttribute('fill', color);
|
||||||
path.setAttribute('stroke', color);
|
path.setAttribute('stroke', color);
|
||||||
@@ -154,13 +150,64 @@ function createArrowMarker(
|
|||||||
return marker;
|
return marker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearRetainedConnectorDom(element: HTMLElement) {
|
||||||
|
retainedConnectorDom.delete(element);
|
||||||
|
element.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetainedConnectorDom(element: HTMLElement): RetainedConnectorDom {
|
||||||
|
const existing = retainedConnectorDom.get(element);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||||
|
svg.style.position = 'absolute';
|
||||||
|
svg.style.overflow = 'visible';
|
||||||
|
svg.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
const defs = document.createElementNS(SVG_NS, 'defs');
|
||||||
|
const path = document.createElementNS(SVG_NS, 'path');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
path.setAttribute('stroke-linecap', 'round');
|
||||||
|
path.setAttribute('stroke-linejoin', 'round');
|
||||||
|
|
||||||
|
svg.append(defs, path);
|
||||||
|
element.replaceChildren(svg);
|
||||||
|
|
||||||
|
const retained = {
|
||||||
|
svg,
|
||||||
|
defs,
|
||||||
|
path,
|
||||||
|
label: null,
|
||||||
|
};
|
||||||
|
retainedConnectorDom.set(element, retained);
|
||||||
|
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateLabelElement(retained: RetainedConnectorDom) {
|
||||||
|
if (retained.label) {
|
||||||
|
return retained.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.createElement('div');
|
||||||
|
retained.svg.insertAdjacentElement('afterend', label);
|
||||||
|
retained.label = label;
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
function renderConnectorLabel(
|
function renderConnectorLabel(
|
||||||
model: ConnectorElementModel,
|
model: ConnectorElementModel,
|
||||||
container: HTMLElement,
|
retained: RetainedConnectorDom,
|
||||||
renderer: DomRenderer,
|
renderer: DomRenderer,
|
||||||
zoom: number
|
zoom: number
|
||||||
) {
|
) {
|
||||||
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
|
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
|
||||||
|
retained.label?.remove();
|
||||||
|
retained.label = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +223,7 @@ function renderConnectorLabel(
|
|||||||
},
|
},
|
||||||
} = model;
|
} = model;
|
||||||
|
|
||||||
// Create label element
|
const labelElement = getOrCreateLabelElement(retained);
|
||||||
const labelElement = document.createElement('div');
|
|
||||||
labelElement.style.position = 'absolute';
|
labelElement.style.position = 'absolute';
|
||||||
labelElement.style.left = `${lx * zoom}px`;
|
labelElement.style.left = `${lx * zoom}px`;
|
||||||
labelElement.style.top = `${ly * zoom}px`;
|
labelElement.style.top = `${ly * zoom}px`;
|
||||||
@@ -210,11 +256,7 @@ function renderConnectorLabel(
|
|||||||
labelElement.style.wordWrap = 'break-word';
|
labelElement.style.wordWrap = 'break-word';
|
||||||
|
|
||||||
// Add text content
|
// Add text content
|
||||||
if (model.text) {
|
labelElement.textContent = model.text ? model.text.toString() : '';
|
||||||
labelElement.textContent = model.text.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
container.append(labelElement);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,14 +283,13 @@ export const connectorBaseDomRenderer = (
|
|||||||
stroke,
|
stroke,
|
||||||
} = model;
|
} = model;
|
||||||
|
|
||||||
// Clear previous content
|
|
||||||
element.innerHTML = '';
|
|
||||||
|
|
||||||
// Early return if no path points
|
|
||||||
if (!points || points.length < 2) {
|
if (!points || points.length < 2) {
|
||||||
|
clearRetainedConnectorDom(element);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retained = getRetainedConnectorDom(element);
|
||||||
|
|
||||||
// Calculate bounds for the SVG viewBox
|
// Calculate bounds for the SVG viewBox
|
||||||
const pathBounds = calculatePathBounds(points);
|
const pathBounds = calculatePathBounds(points);
|
||||||
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
|
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
|
||||||
@@ -257,8 +298,7 @@ export const connectorBaseDomRenderer = (
|
|||||||
const offsetX = pathBounds.minX - padding;
|
const offsetX = pathBounds.minX - padding;
|
||||||
const offsetY = pathBounds.minY - padding;
|
const offsetY = pathBounds.minY - padding;
|
||||||
|
|
||||||
// Create SVG element
|
const { defs, path, svg } = retained;
|
||||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
||||||
svg.style.position = 'absolute';
|
svg.style.position = 'absolute';
|
||||||
svg.style.left = `${offsetX * zoom}px`;
|
svg.style.left = `${offsetX * zoom}px`;
|
||||||
svg.style.top = `${offsetY * zoom}px`;
|
svg.style.top = `${offsetY * zoom}px`;
|
||||||
@@ -268,49 +308,43 @@ export const connectorBaseDomRenderer = (
|
|||||||
svg.style.pointerEvents = 'none';
|
svg.style.pointerEvents = 'none';
|
||||||
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`);
|
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`);
|
||||||
|
|
||||||
// Create defs for markers
|
|
||||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
||||||
svg.append(defs);
|
|
||||||
|
|
||||||
const strokeColor = renderer.getColorValue(
|
const strokeColor = renderer.getColorValue(
|
||||||
stroke,
|
stroke,
|
||||||
DefaultTheme.connectorColor,
|
DefaultTheme.connectorColor,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create markers for endpoints
|
const markers: SVGMarkerElement[] = [];
|
||||||
let startMarkerId = '';
|
let startMarkerId = '';
|
||||||
let endMarkerId = '';
|
let endMarkerId = '';
|
||||||
|
|
||||||
if (frontEndpointStyle !== 'None') {
|
if (frontEndpointStyle !== 'None') {
|
||||||
startMarkerId = `start-marker-${model.id}`;
|
startMarkerId = `start-marker-${model.id}`;
|
||||||
const startMarker = createArrowMarker(
|
markers.push(
|
||||||
startMarkerId,
|
createArrowMarker(
|
||||||
frontEndpointStyle,
|
startMarkerId,
|
||||||
strokeColor,
|
frontEndpointStyle,
|
||||||
strokeWidth,
|
strokeColor,
|
||||||
true
|
strokeWidth,
|
||||||
|
true
|
||||||
|
)
|
||||||
);
|
);
|
||||||
defs.append(startMarker);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rearEndpointStyle !== 'None') {
|
if (rearEndpointStyle !== 'None') {
|
||||||
endMarkerId = `end-marker-${model.id}`;
|
endMarkerId = `end-marker-${model.id}`;
|
||||||
const endMarker = createArrowMarker(
|
markers.push(
|
||||||
endMarkerId,
|
createArrowMarker(
|
||||||
rearEndpointStyle,
|
endMarkerId,
|
||||||
strokeColor,
|
rearEndpointStyle,
|
||||||
strokeWidth,
|
strokeColor,
|
||||||
false
|
strokeWidth,
|
||||||
|
false
|
||||||
|
)
|
||||||
);
|
);
|
||||||
defs.append(endMarker);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create path element
|
defs.replaceChildren(...markers);
|
||||||
const pathElement = document.createElementNS(
|
|
||||||
'http://www.w3.org/2000/svg',
|
|
||||||
'path'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Adjust points relative to the SVG coordinate system
|
// Adjust points relative to the SVG coordinate system
|
||||||
const adjustedPoints = points.map(point => {
|
const adjustedPoints = points.map(point => {
|
||||||
@@ -334,29 +368,25 @@ export const connectorBaseDomRenderer = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pathData = createConnectorPath(adjustedPoints, mode);
|
const pathData = createConnectorPath(adjustedPoints, mode);
|
||||||
pathElement.setAttribute('d', pathData);
|
path.setAttribute('d', pathData);
|
||||||
pathElement.setAttribute('stroke', strokeColor);
|
path.setAttribute('stroke', strokeColor);
|
||||||
pathElement.setAttribute('stroke-width', String(strokeWidth));
|
path.setAttribute('stroke-width', String(strokeWidth));
|
||||||
pathElement.setAttribute('fill', 'none');
|
|
||||||
pathElement.setAttribute('stroke-linecap', 'round');
|
|
||||||
pathElement.setAttribute('stroke-linejoin', 'round');
|
|
||||||
|
|
||||||
// Apply stroke style
|
|
||||||
if (strokeStyle === 'dash') {
|
if (strokeStyle === 'dash') {
|
||||||
pathElement.setAttribute('stroke-dasharray', '12,12');
|
path.setAttribute('stroke-dasharray', '12,12');
|
||||||
|
} else {
|
||||||
|
path.removeAttribute('stroke-dasharray');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply markers
|
|
||||||
if (startMarkerId) {
|
if (startMarkerId) {
|
||||||
pathElement.setAttribute('marker-start', `url(#${startMarkerId})`);
|
path.setAttribute('marker-start', `url(#${startMarkerId})`);
|
||||||
|
} else {
|
||||||
|
path.removeAttribute('marker-start');
|
||||||
}
|
}
|
||||||
if (endMarkerId) {
|
if (endMarkerId) {
|
||||||
pathElement.setAttribute('marker-end', `url(#${endMarkerId})`);
|
path.setAttribute('marker-end', `url(#${endMarkerId})`);
|
||||||
|
} else {
|
||||||
|
path.removeAttribute('marker-end');
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.append(pathElement);
|
|
||||||
element.append(svg);
|
|
||||||
|
|
||||||
// Set element size and position
|
// Set element size and position
|
||||||
element.style.width = `${model.w * zoom}px`;
|
element.style.width = `${model.w * zoom}px`;
|
||||||
element.style.height = `${model.h * zoom}px`;
|
element.style.height = `${model.h * zoom}px`;
|
||||||
@@ -370,7 +400,11 @@ export const connectorDomRenderer = (
|
|||||||
renderer: DomRenderer
|
renderer: DomRenderer
|
||||||
): void => {
|
): void => {
|
||||||
connectorBaseDomRenderer(model, element, renderer);
|
connectorBaseDomRenderer(model, element, renderer);
|
||||||
renderConnectorLabel(model, element, renderer, renderer.viewport.zoom);
|
|
||||||
|
const retained = retainedConnectorDom.get(element);
|
||||||
|
if (!retained) return;
|
||||||
|
|
||||||
|
renderConnectorLabel(model, retained, renderer, renderer.viewport.zoom);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -6,6 +6,37 @@ import { SVGShapeBuilder } from '@blocksuite/global/gfx';
|
|||||||
|
|
||||||
import { manageClassNames, setStyles } from './utils';
|
import { manageClassNames, setStyles } from './utils';
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
type RetainedShapeDom = {
|
||||||
|
polygon: SVGPolygonElement | null;
|
||||||
|
svg: SVGSVGElement | null;
|
||||||
|
text: HTMLDivElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RetainedShapeSvg = {
|
||||||
|
polygon: SVGPolygonElement;
|
||||||
|
svg: SVGSVGElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retainedShapeDom = new WeakMap<HTMLElement, RetainedShapeDom>();
|
||||||
|
|
||||||
|
function getRetainedShapeDom(element: HTMLElement): RetainedShapeDom {
|
||||||
|
const existing = retainedShapeDom.get(element);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retained = {
|
||||||
|
svg: null,
|
||||||
|
polygon: null,
|
||||||
|
text: null,
|
||||||
|
};
|
||||||
|
retainedShapeDom.set(element, retained);
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
function applyShapeSpecificStyles(
|
function applyShapeSpecificStyles(
|
||||||
model: ShapeElementModel,
|
model: ShapeElementModel,
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
@@ -14,10 +45,6 @@ function applyShapeSpecificStyles(
|
|||||||
// Reset properties that might be set by different shape types
|
// Reset properties that might be set by different shape types
|
||||||
element.style.removeProperty('clip-path');
|
element.style.removeProperty('clip-path');
|
||||||
element.style.removeProperty('border-radius');
|
element.style.removeProperty('border-radius');
|
||||||
// Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based
|
|
||||||
if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') {
|
|
||||||
while (element.firstChild) element.firstChild.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (model.shapeType) {
|
switch (model.shapeType) {
|
||||||
case 'rect': {
|
case 'rect': {
|
||||||
@@ -42,6 +69,54 @@ function applyShapeSpecificStyles(
|
|||||||
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
|
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrCreateSvg(
|
||||||
|
retained: RetainedShapeDom,
|
||||||
|
element: HTMLElement
|
||||||
|
): RetainedShapeSvg {
|
||||||
|
if (retained.svg && retained.polygon) {
|
||||||
|
return {
|
||||||
|
svg: retained.svg,
|
||||||
|
polygon: retained.polygon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('height', '100%');
|
||||||
|
svg.setAttribute('preserveAspectRatio', 'none');
|
||||||
|
|
||||||
|
const polygon = document.createElementNS(SVG_NS, 'polygon');
|
||||||
|
svg.append(polygon);
|
||||||
|
|
||||||
|
retained.svg = svg;
|
||||||
|
retained.polygon = polygon;
|
||||||
|
element.prepend(svg);
|
||||||
|
|
||||||
|
return { svg, polygon };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSvg(retained: RetainedShapeDom) {
|
||||||
|
retained.svg?.remove();
|
||||||
|
retained.svg = null;
|
||||||
|
retained.polygon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateText(retained: RetainedShapeDom, element: HTMLElement) {
|
||||||
|
if (retained.text) {
|
||||||
|
return retained.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
retained.text = text;
|
||||||
|
element.append(text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeText(retained: RetainedShapeDom) {
|
||||||
|
retained.text?.remove();
|
||||||
|
retained.text = null;
|
||||||
|
}
|
||||||
|
|
||||||
function applyBorderStyles(
|
function applyBorderStyles(
|
||||||
model: ShapeElementModel,
|
model: ShapeElementModel,
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
@@ -99,8 +174,7 @@ export const shapeDomRenderer = (
|
|||||||
const { zoom } = renderer.viewport;
|
const { zoom } = renderer.viewport;
|
||||||
const unscaledWidth = model.w;
|
const unscaledWidth = model.w;
|
||||||
const unscaledHeight = model.h;
|
const unscaledHeight = model.h;
|
||||||
|
const retained = getRetainedShapeDom(element);
|
||||||
const newChildren: Element[] = [];
|
|
||||||
|
|
||||||
const fillColor = renderer.getColorValue(
|
const fillColor = renderer.getColorValue(
|
||||||
model.fillColor,
|
model.fillColor,
|
||||||
@@ -124,6 +198,7 @@ export const shapeDomRenderer = (
|
|||||||
// For diamond and triangle, fill and border are handled by inline SVG
|
// For diamond and triangle, fill and border are handled by inline SVG
|
||||||
element.style.border = 'none'; // Ensure no standard CSS border interferes
|
element.style.border = 'none'; // Ensure no standard CSS border interferes
|
||||||
element.style.backgroundColor = 'transparent'; // Host element is transparent
|
element.style.backgroundColor = 'transparent'; // Host element is transparent
|
||||||
|
const { polygon, svg } = getOrCreateSvg(retained, element);
|
||||||
|
|
||||||
const strokeW = model.strokeWidth;
|
const strokeW = model.strokeWidth;
|
||||||
|
|
||||||
@@ -155,37 +230,30 @@ export const shapeDomRenderer = (
|
|||||||
// Determine fill color
|
// Determine fill color
|
||||||
const finalFillColor = model.filled ? fillColor : 'transparent';
|
const finalFillColor = model.filled ? fillColor : 'transparent';
|
||||||
|
|
||||||
// Build SVG safely with DOM-API
|
|
||||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
||||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
||||||
svg.setAttribute('width', '100%');
|
|
||||||
svg.setAttribute('height', '100%');
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
|
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
|
||||||
svg.setAttribute('preserveAspectRatio', 'none');
|
|
||||||
|
|
||||||
const polygon = document.createElementNS(SVG_NS, 'polygon');
|
|
||||||
polygon.setAttribute('points', svgPoints);
|
polygon.setAttribute('points', svgPoints);
|
||||||
polygon.setAttribute('fill', finalFillColor);
|
polygon.setAttribute('fill', finalFillColor);
|
||||||
polygon.setAttribute('stroke', finalStrokeColor);
|
polygon.setAttribute('stroke', finalStrokeColor);
|
||||||
polygon.setAttribute('stroke-width', String(strokeW));
|
polygon.setAttribute('stroke-width', String(strokeW));
|
||||||
if (finalStrokeDasharray !== 'none') {
|
if (finalStrokeDasharray !== 'none') {
|
||||||
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
|
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
|
||||||
|
} else {
|
||||||
|
polygon.removeAttribute('stroke-dasharray');
|
||||||
}
|
}
|
||||||
svg.append(polygon);
|
|
||||||
|
|
||||||
newChildren.push(svg);
|
|
||||||
} else {
|
} else {
|
||||||
// Standard rendering for other shapes (e.g., rect, ellipse)
|
// Standard rendering for other shapes (e.g., rect, ellipse)
|
||||||
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
|
removeSvg(retained);
|
||||||
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
|
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
|
||||||
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
|
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.textDisplay && model.text) {
|
if (model.textDisplay && model.text) {
|
||||||
const str = model.text.toString();
|
const str = model.text.toString();
|
||||||
const textElement = document.createElement('div');
|
const textElement = getOrCreateText(retained, element);
|
||||||
if (isRTL(str)) {
|
if (isRTL(str)) {
|
||||||
textElement.dir = 'rtl';
|
textElement.dir = 'rtl';
|
||||||
|
} else {
|
||||||
|
textElement.removeAttribute('dir');
|
||||||
}
|
}
|
||||||
textElement.style.position = 'absolute';
|
textElement.style.position = 'absolute';
|
||||||
textElement.style.inset = '0';
|
textElement.style.inset = '0';
|
||||||
@@ -210,12 +278,10 @@ export const shapeDomRenderer = (
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
textElement.textContent = str;
|
textElement.textContent = str;
|
||||||
newChildren.push(textElement);
|
} else {
|
||||||
|
removeText(retained);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace existing children to avoid memory leaks
|
|
||||||
element.replaceChildren(...newChildren);
|
|
||||||
|
|
||||||
applyTransformStyles(model, element);
|
applyTransformStyles(model, element);
|
||||||
|
|
||||||
manageClassNames(model, element);
|
manageClassNames(model, element);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -34,7 +34,9 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"playwright": "=1.58.2",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { FootNote } from '@blocksuite/affine-model';
|
|||||||
import { CitationProvider } from '@blocksuite/affine-shared/services';
|
import { CitationProvider } from '@blocksuite/affine-shared/services';
|
||||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
|
import { isValidUrl, normalizeUrl } from '@blocksuite/affine-shared/utils';
|
||||||
import { WithDisposable } from '@blocksuite/global/lit';
|
import { WithDisposable } from '@blocksuite/global/lit';
|
||||||
import {
|
import {
|
||||||
BlockSelection,
|
BlockSelection,
|
||||||
@@ -152,7 +153,9 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly _handleUrlReference = (url: string) => {
|
private readonly _handleUrlReference = (url: string) => {
|
||||||
window.open(url, '_blank');
|
const normalizedUrl = normalizeUrl(url);
|
||||||
|
if (!normalizedUrl || !isValidUrl(normalizedUrl)) return;
|
||||||
|
window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
|
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -8,10 +9,9 @@ export default defineConfig({
|
|||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headless: true,
|
headless: true,
|
||||||
name: 'chromium',
|
instances: [{ browser: 'chromium' }],
|
||||||
provider: 'playwright',
|
provider: playwright(),
|
||||||
isolate: false,
|
isolate: false,
|
||||||
providerOptions: {},
|
|
||||||
},
|
},
|
||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
|
|||||||
override getNearestPoint(point: IVec): IVec {
|
override getNearestPoint(point: IVec): IVec {
|
||||||
const { mode, absolutePath: path } = this;
|
const { mode, absolutePath: path } = this;
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
const { x, y } = this;
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === ConnectorMode.Straight) {
|
if (mode === ConnectorMode.Straight) {
|
||||||
const first = path[0];
|
const first = path[0];
|
||||||
const last = path[path.length - 1];
|
const last = path[path.length - 1];
|
||||||
@@ -213,6 +218,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
|
|||||||
h = bounds.h;
|
h = bounds.h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
point[0] = Vec.clamp(point[0], x, x + w);
|
point[0] = Vec.clamp(point[0], x, x + w);
|
||||||
point[1] = Vec.clamp(point[1], y, y + h);
|
point[1] = Vec.clamp(point[1], y, y + h);
|
||||||
|
|
||||||
@@ -258,6 +267,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
|
|||||||
h = bounds.h;
|
h = bounds.h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
return [x + w / 2, y + h / 2];
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === ConnectorMode.Orthogonal) {
|
if (mode === ConnectorMode.Orthogonal) {
|
||||||
const points = path.map<IVec>(p => [p[0], p[1]]);
|
const points = path.map<IVec>(p => [p[0], p[1]]);
|
||||||
const point = Polyline.pointAt(points, offsetDistance);
|
const point = Polyline.pointAt(points, offsetDistance);
|
||||||
@@ -300,6 +313,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
|
|||||||
|
|
||||||
const { mode, strokeWidth, absolutePath: path } = this;
|
const { mode, strokeWidth, absolutePath: path } = this;
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const point =
|
const point =
|
||||||
mode === ConnectorMode.Curve
|
mode === ConnectorMode.Curve
|
||||||
? getBezierNearestPoint(getBezierParameters(path), currentPoint)
|
? getBezierNearestPoint(getBezierParameters(path), currentPoint)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/pdfmake": "^0.2.12",
|
"@types/pdfmake": "^0.2.12",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"version": "0.26.3"
|
"version": "0.26.3"
|
||||||
}
|
}
|
||||||
|
|||||||
108
blocksuite/affine/shared/src/__tests__/utils/range.unit.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type MockInstance,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import * as PointToRangeUtils from '../../utils/dom/point-to-range';
|
||||||
|
import { handleNativeRangeAtPoint } from '../../utils/dom/point-to-range';
|
||||||
|
|
||||||
|
describe('test handleNativeRangeAtPoint', () => {
|
||||||
|
let caretRangeFromPointSpy: MockInstance<
|
||||||
|
(clientX: number, clientY: number) => Range | null
|
||||||
|
>;
|
||||||
|
let resetNativeSelectionSpy: MockInstance<(range: Range | null) => void>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
caretRangeFromPointSpy = vi.spyOn(
|
||||||
|
PointToRangeUtils.api,
|
||||||
|
'caretRangeFromPoint'
|
||||||
|
);
|
||||||
|
resetNativeSelectionSpy = vi.spyOn(
|
||||||
|
PointToRangeUtils.api,
|
||||||
|
'resetNativeSelection'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if caretRangeFromPoint returns null', () => {
|
||||||
|
caretRangeFromPointSpy.mockReturnValue(null);
|
||||||
|
|
||||||
|
handleNativeRangeAtPoint(10, 10);
|
||||||
|
expect(resetNativeSelectionSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps range untouched if startContainer is a Text node', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = 'hello';
|
||||||
|
|
||||||
|
const text = div.firstChild!;
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(text, 2);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
caretRangeFromPointSpy.mockReturnValue(range);
|
||||||
|
|
||||||
|
handleNativeRangeAtPoint(10, 10);
|
||||||
|
|
||||||
|
expect(range.startContainer).toBe(text);
|
||||||
|
expect(range.startOffset).toBe(2);
|
||||||
|
expect(resetNativeSelectionSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves caret into direct text child when clicking element', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.append('hello');
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(div, 1);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
caretRangeFromPointSpy.mockReturnValue(range);
|
||||||
|
|
||||||
|
handleNativeRangeAtPoint(10, 10);
|
||||||
|
|
||||||
|
expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
|
||||||
|
expect(range.startContainer.textContent).toBe('hello');
|
||||||
|
expect(range.startOffset).toBe(5);
|
||||||
|
expect(resetNativeSelectionSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves caret to last meaningful text inside nested element', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `<span>a</span><span><em>b</em>c</span>`;
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(div, 2);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
caretRangeFromPointSpy.mockReturnValue(range);
|
||||||
|
|
||||||
|
handleNativeRangeAtPoint(10, 10);
|
||||||
|
|
||||||
|
expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
|
||||||
|
expect(range.startContainer.textContent).toBe('c');
|
||||||
|
expect(range.startOffset).toBe(1);
|
||||||
|
expect(resetNativeSelectionSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to searching startContainer when offset element has no text', () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `<span></span><span>ok</span>`;
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(div, 1);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
caretRangeFromPointSpy.mockReturnValue(range);
|
||||||
|
|
||||||
|
handleNativeRangeAtPoint(10, 10);
|
||||||
|
|
||||||
|
expect(range.startContainer.textContent).toBe('ok');
|
||||||
|
expect(range.startOffset).toBe(2);
|
||||||
|
expect(resetNativeSelectionSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
|
import {
|
||||||
|
type AttachmentBlockProps,
|
||||||
|
AttachmentBlockSchema,
|
||||||
|
} from '@blocksuite/affine-model';
|
||||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||||
import {
|
import {
|
||||||
type AssetsManager,
|
type AssetsManager,
|
||||||
@@ -23,6 +26,24 @@ import { AdapterFactoryIdentifier } from './types/adapter';
|
|||||||
|
|
||||||
export type Attachment = File[];
|
export type Attachment = File[];
|
||||||
|
|
||||||
|
type CreateAttachmentBlockSnapshotOptions = {
|
||||||
|
id?: string;
|
||||||
|
props: Partial<AttachmentBlockProps> & Pick<AttachmentBlockProps, 'name'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAttachmentBlockSnapshot({
|
||||||
|
id = nanoid(),
|
||||||
|
props,
|
||||||
|
}: CreateAttachmentBlockSnapshotOptions): BlockSnapshot {
|
||||||
|
return {
|
||||||
|
type: 'block',
|
||||||
|
id,
|
||||||
|
flavour: AttachmentBlockSchema.model.flavour,
|
||||||
|
props,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type AttachmentToSliceSnapshotPayload = {
|
type AttachmentToSliceSnapshotPayload = {
|
||||||
file: Attachment;
|
file: Attachment;
|
||||||
assets?: AssetsManager;
|
assets?: AssetsManager;
|
||||||
@@ -97,8 +118,6 @@ export class AttachmentAdapter extends BaseAdapter<Attachment> {
|
|||||||
if (files.length === 0) return null;
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
const content: SliceSnapshot['content'] = [];
|
const content: SliceSnapshot['content'] = [];
|
||||||
const flavour = AttachmentBlockSchema.model.flavour;
|
|
||||||
|
|
||||||
for (const blob of files) {
|
for (const blob of files) {
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
const { name, size, type } = blob;
|
const { name, size, type } = blob;
|
||||||
@@ -108,22 +127,21 @@ export class AttachmentAdapter extends BaseAdapter<Attachment> {
|
|||||||
mapInto: sourceId => ({ sourceId }),
|
mapInto: sourceId => ({ sourceId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
content.push({
|
content.push(
|
||||||
type: 'block',
|
createAttachmentBlockSnapshot({
|
||||||
flavour,
|
id,
|
||||||
id,
|
props: {
|
||||||
props: {
|
name,
|
||||||
name,
|
size,
|
||||||
size,
|
type,
|
||||||
type,
|
embed: false,
|
||||||
embed: false,
|
style: 'horizontalThin',
|
||||||
style: 'horizontalThin',
|
index: 'a0',
|
||||||
index: 'a0',
|
xywh: '[0,0,0,0]',
|
||||||
xywh: '[0,0,0,0]',
|
rotate: 0,
|
||||||
rotate: 0,
|
},
|
||||||
},
|
})
|
||||||
children: [],
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
function safeDecodePathReference(path: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(path);
|
||||||
|
} catch {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFilePathReference(path: string): string {
|
||||||
|
return safeDecodePathReference(path)
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\.\/+/, '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\/+/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a relative path by resolving all relative path segments
|
* Normalizes a relative path by resolving all relative path segments
|
||||||
* @param basePath The base path (markdown file's directory)
|
* @param basePath The base path (markdown file's directory)
|
||||||
@@ -40,7 +57,7 @@ export function getImageFullPath(
|
|||||||
imageReference: string
|
imageReference: string
|
||||||
): string {
|
): string {
|
||||||
// Decode the image reference in case it contains URL-encoded characters
|
// Decode the image reference in case it contains URL-encoded characters
|
||||||
const decodedReference = decodeURIComponent(imageReference);
|
const decodedReference = safeDecodePathReference(imageReference);
|
||||||
|
|
||||||
// Get the directory of the file path
|
// Get the directory of the file path
|
||||||
const markdownDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
const markdownDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||||
|
|||||||
@@ -88,11 +88,73 @@ export function getCurrentNativeRange(selection = window.getSelection()) {
|
|||||||
return selection.getRangeAt(0);
|
return selection.getRangeAt(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// functions need to be mocked in unit-test
|
||||||
|
export const api = {
|
||||||
|
caretRangeFromPoint,
|
||||||
|
resetNativeSelection,
|
||||||
|
};
|
||||||
|
|
||||||
export function handleNativeRangeAtPoint(x: number, y: number) {
|
export function handleNativeRangeAtPoint(x: number, y: number) {
|
||||||
const range = caretRangeFromPoint(x, y);
|
const range = api.caretRangeFromPoint(x, y);
|
||||||
|
if (range) {
|
||||||
|
normalizeCaretRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
const startContainer = range?.startContainer;
|
const startContainer = range?.startContainer;
|
||||||
// click on rich text
|
// click on rich text
|
||||||
if (startContainer instanceof Node) {
|
if (startContainer instanceof Node) {
|
||||||
resetNativeSelection(range);
|
api.resetNativeSelection(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMeaningfulTextNode(node: Node) {
|
||||||
|
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
return node.textContent && node.textContent?.trim().length > 0
|
||||||
|
? NodeFilter.FILTER_ACCEPT
|
||||||
|
: NodeFilter.FILTER_REJECT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let last = null;
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
last = walker.currentNode;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCaretRange(range: Range) {
|
||||||
|
let { startContainer, startOffset } = range;
|
||||||
|
if (startContainer.nodeType === Node.TEXT_NODE) return;
|
||||||
|
|
||||||
|
// Try to find text in the element at `startOffset`
|
||||||
|
const offsetEl =
|
||||||
|
startOffset > 0
|
||||||
|
? startContainer.childNodes[startOffset - 1]
|
||||||
|
: startContainer.childNodes[0];
|
||||||
|
if (offsetEl) {
|
||||||
|
if (offsetEl.nodeType === Node.TEXT_NODE) {
|
||||||
|
range.setStart(
|
||||||
|
offsetEl,
|
||||||
|
startOffset > 0 ? (offsetEl.textContent?.length ?? 0) : 0
|
||||||
|
);
|
||||||
|
range.collapse(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = lastMeaningfulTextNode(offsetEl);
|
||||||
|
if (text) {
|
||||||
|
range.setStart(text, text.textContent?.length ?? 0);
|
||||||
|
range.collapse(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback, try to find text in startContainer
|
||||||
|
const text = lastMeaningfulTextNode(startContainer);
|
||||||
|
if (text) {
|
||||||
|
range.setStart(text, text.textContent?.length ?? 0);
|
||||||
|
range.collapse(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,30 @@ declare global {
|
|||||||
showOpenFilePicker?: (
|
showOpenFilePicker?: (
|
||||||
options?: OpenFilePickerOptions
|
options?: OpenFilePickerOptions
|
||||||
) => Promise<FileSystemFileHandle[]>;
|
) => Promise<FileSystemFileHandle[]>;
|
||||||
|
// Window API: showDirectoryPicker
|
||||||
|
showDirectoryPicker?: (options?: {
|
||||||
|
id?: string;
|
||||||
|
mode?: 'read' | 'readwrite';
|
||||||
|
startIn?: FileSystemHandle | string;
|
||||||
|
}) => Promise<FileSystemDirectoryHandle>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal polyfill for FileSystemDirectoryHandle to iterate over files
|
||||||
|
interface FileSystemDirectoryHandle {
|
||||||
|
kind: 'directory';
|
||||||
|
name: string;
|
||||||
|
values(): AsyncIterableIterator<
|
||||||
|
FileSystemFileHandle | FileSystemDirectoryHandle
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemFileHandle {
|
||||||
|
kind: 'file';
|
||||||
|
name: string;
|
||||||
|
getFile(): Promise<File>;
|
||||||
|
}
|
||||||
|
|
||||||
// See [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
|
// See [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)
|
||||||
const FileTypes: NonNullable<OpenFilePickerOptions['types']> = [
|
const FileTypes: NonNullable<OpenFilePickerOptions['types']> = [
|
||||||
{
|
{
|
||||||
@@ -121,21 +142,27 @@ type AcceptTypes =
|
|||||||
| 'Docx'
|
| 'Docx'
|
||||||
| 'MindMap';
|
| 'MindMap';
|
||||||
|
|
||||||
export async function openFilesWith(
|
function canUseFileSystemAccessAPI(
|
||||||
acceptType: AcceptTypes = 'Any',
|
api: 'showOpenFilePicker' | 'showDirectoryPicker'
|
||||||
multiple: boolean = true
|
) {
|
||||||
): Promise<File[] | null> {
|
return (
|
||||||
// Feature detection. The API needs to be supported
|
api in window &&
|
||||||
// and the app not run in an iframe.
|
|
||||||
const supportsFileSystemAccess =
|
|
||||||
'showOpenFilePicker' in window &&
|
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
return window.self === window.top;
|
return window.self === window.top;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
})();
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFilesWith(
|
||||||
|
acceptType: AcceptTypes = 'Any',
|
||||||
|
multiple: boolean = true
|
||||||
|
): Promise<File[] | null> {
|
||||||
|
const supportsFileSystemAccess =
|
||||||
|
canUseFileSystemAccessAPI('showOpenFilePicker');
|
||||||
|
|
||||||
// If the File System Access API is supported…
|
// If the File System Access API is supported…
|
||||||
if (supportsFileSystemAccess && window.showOpenFilePicker) {
|
if (supportsFileSystemAccess && window.showOpenFilePicker) {
|
||||||
@@ -194,6 +221,75 @@ export async function openFilesWith(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openDirectory(): Promise<File[] | null> {
|
||||||
|
const supportsFileSystemAccess = canUseFileSystemAccessAPI(
|
||||||
|
'showDirectoryPicker'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (supportsFileSystemAccess && window.showDirectoryPicker) {
|
||||||
|
try {
|
||||||
|
const dirHandle = await window.showDirectoryPicker();
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
const readDirectory = async (
|
||||||
|
directoryHandle: FileSystemDirectoryHandle,
|
||||||
|
path: string
|
||||||
|
) => {
|
||||||
|
for await (const handle of directoryHandle.values()) {
|
||||||
|
const relativePath = path ? `${path}/${handle.name}` : handle.name;
|
||||||
|
if (handle.kind === 'file') {
|
||||||
|
const fileHandle = handle as FileSystemFileHandle;
|
||||||
|
if (fileHandle.getFile) {
|
||||||
|
const file = await fileHandle.getFile();
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', {
|
||||||
|
value: relativePath,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
} else if (handle.kind === 'directory') {
|
||||||
|
await readDirectory(
|
||||||
|
handle as FileSystemDirectoryHandle,
|
||||||
|
relativePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await readDirectory(dirHandle, '');
|
||||||
|
return files;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.classList.add('affine-upload-input');
|
||||||
|
input.style.display = 'none';
|
||||||
|
input.type = 'file';
|
||||||
|
|
||||||
|
input.setAttribute('webkitdirectory', '');
|
||||||
|
input.setAttribute('directory', '');
|
||||||
|
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
input.remove();
|
||||||
|
resolve(input.files ? Array.from(input.files) : null);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('cancel', () => resolve(null));
|
||||||
|
|
||||||
|
if ('showPicker' in HTMLInputElement.prototype) {
|
||||||
|
input.showPicker();
|
||||||
|
} else {
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function openSingleFileWith(
|
export async function openSingleFileWith(
|
||||||
acceptType?: AcceptTypes
|
acceptType?: AcceptTypes
|
||||||
): Promise<File | null> {
|
): Promise<File | null> {
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ export async function printToPdf(
|
|||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
document.body.append(iframe);
|
document.body.append(iframe);
|
||||||
iframe.style.display = 'none';
|
// Use a hidden but rendering-enabled state instead of display: none
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
border: 'none',
|
||||||
|
});
|
||||||
iframe.srcdoc = '<!DOCTYPE html>';
|
iframe.srcdoc = '<!DOCTYPE html>';
|
||||||
iframe.onload = async () => {
|
iframe.onload = async () => {
|
||||||
if (!iframe.contentWindow) {
|
if (!iframe.contentWindow) {
|
||||||
@@ -28,6 +35,44 @@ export async function printToPdf(
|
|||||||
reject(new Error('Root element not defined, unable to print pdf'));
|
reject(new Error('Root element not defined, unable to print pdf'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const doc = iframe.contentWindow.document;
|
||||||
|
|
||||||
|
doc.write(`<!DOCTYPE html><html><head><style>@media print {
|
||||||
|
html, body {
|
||||||
|
height: initial !important;
|
||||||
|
overflow: initial !important;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
color: #000 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
color-scheme: light !important;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
:root, body {
|
||||||
|
--affine-text-primary: #000 !important;
|
||||||
|
--affine-text-secondary: #111 !important;
|
||||||
|
--affine-text-tertiary: #333 !important;
|
||||||
|
--affine-background-primary: #fff !important;
|
||||||
|
--affine-background-secondary: #fff !important;
|
||||||
|
--affine-background-tertiary: #fff !important;
|
||||||
|
}
|
||||||
|
body, [data-theme='dark'] {
|
||||||
|
color: #000 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
body * {
|
||||||
|
color: #000 !important;
|
||||||
|
-webkit-text-fill-color: #000 !important;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--affine-note-shadow-box: none !important;
|
||||||
|
--affine-note-shadow-sticker: none !important;
|
||||||
|
}
|
||||||
|
}</style></head><body></body></html>`);
|
||||||
|
doc.close();
|
||||||
iframe.contentWindow.document
|
iframe.contentWindow.document
|
||||||
.write(`<!DOCTYPE html><html><head><style>@media print {
|
.write(`<!DOCTYPE html><html><head><style>@media print {
|
||||||
html, body {
|
html, body {
|
||||||
@@ -49,6 +94,9 @@ export async function printToPdf(
|
|||||||
--affine-background-primary: #fff !important;
|
--affine-background-primary: #fff !important;
|
||||||
--affine-background-secondary: #fff !important;
|
--affine-background-secondary: #fff !important;
|
||||||
--affine-background-tertiary: #fff !important;
|
--affine-background-tertiary: #fff !important;
|
||||||
|
--affine-background-code-block: #f5f5f5 !important;
|
||||||
|
--affine-quote-color: #e3e3e3 !important;
|
||||||
|
--affine-border-color: #e3e3e3 !important;
|
||||||
}
|
}
|
||||||
body, [data-theme='dark'] {
|
body, [data-theme='dark'] {
|
||||||
color: #000 !important;
|
color: #000 !important;
|
||||||
@@ -68,7 +116,7 @@ export async function printToPdf(
|
|||||||
for (const element of document.styleSheets) {
|
for (const element of document.styleSheets) {
|
||||||
try {
|
try {
|
||||||
for (const cssRule of element.cssRules) {
|
for (const cssRule of element.cssRules) {
|
||||||
const target = iframe.contentWindow.document.styleSheets[0];
|
const target = doc.styleSheets[0];
|
||||||
target.insertRule(cssRule.cssText, target.cssRules.length);
|
target.insertRule(cssRule.cssText, target.cssRules.length);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -83,12 +131,33 @@ export async function printToPdf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursive function to find all canvases, including those in shadow roots
|
||||||
|
const findAllCanvases = (root: Node): HTMLCanvasElement[] => {
|
||||||
|
const canvases: HTMLCanvasElement[] = [];
|
||||||
|
const traverse = (node: Node) => {
|
||||||
|
if (node instanceof HTMLCanvasElement) {
|
||||||
|
canvases.push(node);
|
||||||
|
}
|
||||||
|
if (node instanceof HTMLElement || node instanceof ShadowRoot) {
|
||||||
|
node.childNodes.forEach(traverse);
|
||||||
|
}
|
||||||
|
if (node instanceof HTMLElement && node.shadowRoot) {
|
||||||
|
traverse(node.shadowRoot);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverse(root);
|
||||||
|
return canvases;
|
||||||
|
};
|
||||||
|
|
||||||
// convert all canvas to image
|
// convert all canvas to image
|
||||||
const canvasImgObjectUrlMap = new Map<string, string>();
|
const canvasImgObjectUrlMap = new Map<string, string>();
|
||||||
const allCanvas = rootElement.getElementsByTagName('canvas');
|
const allCanvas = findAllCanvases(rootElement);
|
||||||
let canvasKey = 1;
|
let canvasKey = 1;
|
||||||
|
const canvasToKeyMap = new Map<HTMLCanvasElement, string>();
|
||||||
|
|
||||||
for (const canvas of allCanvas) {
|
for (const canvas of allCanvas) {
|
||||||
canvas.dataset['printToPdfCanvasKey'] = canvasKey.toString();
|
const key = canvasKey.toString();
|
||||||
|
canvasToKeyMap.set(canvas, key);
|
||||||
canvasKey++;
|
canvasKey++;
|
||||||
const canvasImgObjectUrl = await new Promise<Blob | null>(resolve => {
|
const canvasImgObjectUrl = await new Promise<Blob | null>(resolve => {
|
||||||
try {
|
try {
|
||||||
@@ -103,20 +172,42 @@ export async function printToPdf(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
canvasImgObjectUrlMap.set(
|
canvasImgObjectUrlMap.set(key, URL.createObjectURL(canvasImgObjectUrl));
|
||||||
canvas.dataset['printToPdfCanvasKey'],
|
|
||||||
URL.createObjectURL(canvasImgObjectUrl)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedRoot = iframe.contentWindow.document.importNode(
|
// Recursive deep clone that flattens Shadow DOM into Light DOM
|
||||||
rootElement,
|
const deepCloneWithShadows = (node: Node): Node => {
|
||||||
true
|
const clone = doc.importNode(node, false);
|
||||||
) as HTMLDivElement;
|
|
||||||
|
if (
|
||||||
|
clone instanceof HTMLCanvasElement &&
|
||||||
|
node instanceof HTMLCanvasElement
|
||||||
|
) {
|
||||||
|
const key = canvasToKeyMap.get(node);
|
||||||
|
if (key) {
|
||||||
|
clone.dataset['printToPdfCanvasKey'] = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendChildren = (source: Node) => {
|
||||||
|
source.childNodes.forEach(child => {
|
||||||
|
(clone as Element).append(deepCloneWithShadows(child));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node instanceof HTMLElement && node.shadowRoot) {
|
||||||
|
appendChildren(node.shadowRoot);
|
||||||
|
}
|
||||||
|
appendChildren(node);
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importedRoot = deepCloneWithShadows(rootElement) as HTMLDivElement;
|
||||||
|
|
||||||
// force light theme in print iframe
|
// force light theme in print iframe
|
||||||
iframe.contentWindow.document.documentElement.dataset.theme = 'light';
|
doc.documentElement.dataset.theme = 'light';
|
||||||
iframe.contentWindow.document.body.dataset.theme = 'light';
|
doc.body.dataset.theme = 'light';
|
||||||
importedRoot.dataset.theme = 'light';
|
importedRoot.dataset.theme = 'light';
|
||||||
|
|
||||||
// draw saved canvas image to canvas
|
// draw saved canvas image to canvas
|
||||||
@@ -135,17 +226,67 @@ export async function printToPdf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// append to iframe and print
|
// Remove lazy loading from all images and force reload
|
||||||
iframe.contentWindow.document.body.append(importedRoot);
|
const allImages = importedRoot.querySelectorAll('img');
|
||||||
|
allImages.forEach(img => {
|
||||||
|
img.removeAttribute('loading');
|
||||||
|
const src = img.getAttribute('src');
|
||||||
|
if (src) img.setAttribute('src', src);
|
||||||
|
});
|
||||||
|
|
||||||
|
// append to iframe
|
||||||
|
doc.body.append(importedRoot);
|
||||||
|
|
||||||
await options.beforeprint?.(iframe);
|
await options.beforeprint?.(iframe);
|
||||||
|
|
||||||
// browser may take some time to load font
|
// Robust image waiting logic
|
||||||
await new Promise<void>(resolve => {
|
const waitForImages = async (container: HTMLElement) => {
|
||||||
setTimeout(() => {
|
const images: HTMLImageElement[] = [];
|
||||||
resolve();
|
const view = container.ownerDocument.defaultView;
|
||||||
}, 1000);
|
if (!view) return;
|
||||||
});
|
|
||||||
|
const findImages = (root: Node) => {
|
||||||
|
if (root instanceof view.HTMLImageElement) {
|
||||||
|
images.push(root);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
root instanceof view.HTMLElement ||
|
||||||
|
root instanceof view.ShadowRoot
|
||||||
|
) {
|
||||||
|
root.childNodes.forEach(findImages);
|
||||||
|
}
|
||||||
|
if (root instanceof view.HTMLElement && root.shadowRoot) {
|
||||||
|
findImages(root.shadowRoot);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
findImages(container);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
images.map(img => {
|
||||||
|
if (img.complete) {
|
||||||
|
if (img.naturalWidth === 0) {
|
||||||
|
console.warn('Image failed to load:', img.src);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise(resolve => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = resolve;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
await waitForImages(importedRoot);
|
||||||
|
|
||||||
|
// browser may take some time to load font or other resources
|
||||||
|
await (doc.fonts?.ready ??
|
||||||
|
new Promise<void>(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 1000);
|
||||||
|
}));
|
||||||
|
|
||||||
iframe.contentWindow.onafterprint = async () => {
|
iframe.contentWindow.onafterprint = async () => {
|
||||||
iframe.remove();
|
iframe.remove();
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const toURL = (str: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasAllowedScheme = (url: URL) => {
|
||||||
|
const protocol = url.protocol.slice(0, -1).toLowerCase();
|
||||||
|
return ALLOWED_SCHEMES.has(protocol);
|
||||||
|
};
|
||||||
|
|
||||||
function resolveURL(str: string, baseUrl: string, padded = false) {
|
function resolveURL(str: string, baseUrl: string, padded = false) {
|
||||||
const url = toURL(str);
|
const url = toURL(str);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -61,6 +66,7 @@ export function normalizeUrl(str: string) {
|
|||||||
|
|
||||||
// Formatted
|
// Formatted
|
||||||
if (url) {
|
if (url) {
|
||||||
|
if (!hasAllowedScheme(url)) return '';
|
||||||
if (!str.endsWith('/') && url.href.endsWith('/')) {
|
if (!str.endsWith('/') && url.href.endsWith('/')) {
|
||||||
return url.href.substring(0, url.href.length - 1);
|
return url.href.substring(0, url.href.length - 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 1000,
|
testTimeout: 1000,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul', // or 'istanbul'
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/affine-shared',
|
reportsDirectory: '../../../.coverage/affine-shared',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
NotionIcon,
|
NotionIcon,
|
||||||
} from '@blocksuite/affine-components/icons';
|
} from '@blocksuite/affine-components/icons';
|
||||||
import {
|
import {
|
||||||
|
openDirectory,
|
||||||
openFilesWith,
|
openFilesWith,
|
||||||
openSingleFileWith,
|
openSingleFileWith,
|
||||||
} from '@blocksuite/affine-shared/utils';
|
} from '@blocksuite/affine-shared/utils';
|
||||||
@@ -18,11 +19,16 @@ import { query, state } from 'lit/decorators.js';
|
|||||||
import { HtmlTransformer } from '../transformers/html.js';
|
import { HtmlTransformer } from '../transformers/html.js';
|
||||||
import { MarkdownTransformer } from '../transformers/markdown.js';
|
import { MarkdownTransformer } from '../transformers/markdown.js';
|
||||||
import { NotionHtmlTransformer } from '../transformers/notion-html.js';
|
import { NotionHtmlTransformer } from '../transformers/notion-html.js';
|
||||||
|
import { ObsidianTransformer } from '../transformers/obsidian.js';
|
||||||
import { styles } from './styles.js';
|
import { styles } from './styles.js';
|
||||||
|
|
||||||
export type OnSuccessHandler = (
|
export type OnSuccessHandler = (
|
||||||
pageIds: string[],
|
pageIds: string[],
|
||||||
options: { isWorkspaceFile: boolean; importedCount: number }
|
options: {
|
||||||
|
isWorkspaceFile: boolean;
|
||||||
|
importedCount: number;
|
||||||
|
docEmojis?: Map<string, string>;
|
||||||
|
}
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export type OnFailHandler = (message: string) => void;
|
export type OnFailHandler = (message: string) => void;
|
||||||
@@ -140,6 +146,29 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _importObsidian() {
|
||||||
|
const files = await openDirectory();
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const needLoading =
|
||||||
|
files.reduce((acc, f) => acc + f.size, 0) > SHOW_LOADING_SIZE;
|
||||||
|
if (needLoading) {
|
||||||
|
this.hidden = false;
|
||||||
|
this._loading = true;
|
||||||
|
} else {
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
|
const { docIds, docEmojis } = await ObsidianTransformer.importObsidianVault(
|
||||||
|
{
|
||||||
|
collection: this.collection,
|
||||||
|
schema: this.schema,
|
||||||
|
importedFiles: files,
|
||||||
|
extensions: this.extensions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
needLoading && this.abortController.abort();
|
||||||
|
this._onImportSuccess(docIds, { docEmojis });
|
||||||
|
}
|
||||||
|
|
||||||
private _onCloseClick(event: MouseEvent) {
|
private _onCloseClick(event: MouseEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
@@ -151,15 +180,21 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
|||||||
|
|
||||||
private _onImportSuccess(
|
private _onImportSuccess(
|
||||||
pageIds: string[],
|
pageIds: string[],
|
||||||
options: { isWorkspaceFile?: boolean; importedCount?: number } = {}
|
options: {
|
||||||
|
isWorkspaceFile?: boolean;
|
||||||
|
importedCount?: number;
|
||||||
|
docEmojis?: Map<string, string>;
|
||||||
|
} = {}
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
isWorkspaceFile = false,
|
isWorkspaceFile = false,
|
||||||
importedCount: pagesImportedCount = pageIds.length,
|
importedCount: pagesImportedCount = pageIds.length,
|
||||||
|
docEmojis,
|
||||||
} = options;
|
} = options;
|
||||||
this.onSuccess?.(pageIds, {
|
this.onSuccess?.(pageIds, {
|
||||||
isWorkspaceFile,
|
isWorkspaceFile,
|
||||||
importedCount: pagesImportedCount,
|
importedCount: pagesImportedCount,
|
||||||
|
docEmojis,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +293,13 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
|||||||
</affine-tooltip>
|
</affine-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</icon-button>
|
</icon-button>
|
||||||
|
<icon-button
|
||||||
|
class="button-item"
|
||||||
|
text="Obsidian"
|
||||||
|
@click="${this._importObsidian}"
|
||||||
|
>
|
||||||
|
${ExportToMarkdownIcon}
|
||||||
|
</icon-button>
|
||||||
<icon-button class="button-item" text="Coming soon..." disabled>
|
<icon-button class="button-item" text="Coming soon..." disabled>
|
||||||
${NewIcon}
|
${NewIcon}
|
||||||
</icon-button>
|
</icon-button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { DocxTransformer } from './docx.js';
|
|||||||
export { HtmlTransformer } from './html.js';
|
export { HtmlTransformer } from './html.js';
|
||||||
export { MarkdownTransformer } from './markdown.js';
|
export { MarkdownTransformer } from './markdown.js';
|
||||||
export { NotionHtmlTransformer } from './notion-html.js';
|
export { NotionHtmlTransformer } from './notion-html.js';
|
||||||
|
export { ObsidianTransformer } from './obsidian.js';
|
||||||
export { PdfTransformer } from './pdf.js';
|
export { PdfTransformer } from './pdf.js';
|
||||||
export { createAssetsArchive, download } from './utils.js';
|
export { createAssetsArchive, download } from './utils.js';
|
||||||
export { ZipTransformer } from './zip.js';
|
export { ZipTransformer } from './zip.js';
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import { extMimeMap, Transformer } from '@blocksuite/store';
|
|||||||
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';
|
||||||
import { createAssetsArchive, download, parseMatter, Unzip } from './utils.js';
|
import { createAssetsArchive, download, parseMatter, Unzip } from './utils.js';
|
||||||
|
|
||||||
type ParsedFrontmatterMeta = Partial<
|
export type ParsedFrontmatterMeta = Partial<
|
||||||
Pick<DocMeta, 'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite'>
|
Pick<
|
||||||
|
DocMeta,
|
||||||
|
'title' | 'createDate' | 'updatedDate' | 'tags' | 'favorite' | 'trash'
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const FRONTMATTER_KEYS = {
|
const FRONTMATTER_KEYS = {
|
||||||
@@ -150,11 +153,18 @@ function buildMetaFromFrontmatter(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (FRONTMATTER_KEYS.trash.includes(key)) {
|
||||||
|
const trash = parseBoolean(value);
|
||||||
|
if (trash !== undefined) {
|
||||||
|
meta.trash = trash;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFrontmatter(markdown: string): {
|
export function parseFrontmatter(markdown: string): {
|
||||||
content: string;
|
content: string;
|
||||||
meta: ParsedFrontmatterMeta;
|
meta: ParsedFrontmatterMeta;
|
||||||
} {
|
} {
|
||||||
@@ -176,7 +186,7 @@ function parseFrontmatter(markdown: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMetaPatch(
|
export function applyMetaPatch(
|
||||||
collection: Workspace,
|
collection: Workspace,
|
||||||
docId: string,
|
docId: string,
|
||||||
meta: ParsedFrontmatterMeta
|
meta: ParsedFrontmatterMeta
|
||||||
@@ -187,13 +197,14 @@ function applyMetaPatch(
|
|||||||
if (meta.updatedDate !== undefined) metaPatch.updatedDate = meta.updatedDate;
|
if (meta.updatedDate !== undefined) metaPatch.updatedDate = meta.updatedDate;
|
||||||
if (meta.tags) metaPatch.tags = meta.tags;
|
if (meta.tags) metaPatch.tags = meta.tags;
|
||||||
if (meta.favorite !== undefined) metaPatch.favorite = meta.favorite;
|
if (meta.favorite !== undefined) metaPatch.favorite = meta.favorite;
|
||||||
|
if (meta.trash !== undefined) metaPatch.trash = meta.trash;
|
||||||
|
|
||||||
if (Object.keys(metaPatch).length) {
|
if (Object.keys(metaPatch).length) {
|
||||||
collection.meta.setDocMeta(docId, metaPatch);
|
collection.meta.setDocMeta(docId, metaPatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProvider(extensions: ExtensionType[]) {
|
export function getProvider(extensions: ExtensionType[]) {
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
extensions.forEach(ext => {
|
extensions.forEach(ext => {
|
||||||
ext.setup(container);
|
ext.setup(container);
|
||||||
@@ -223,6 +234,103 @@ type ImportMarkdownZipOptions = {
|
|||||||
extensions: ExtensionType[];
|
extensions: ExtensionType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters hidden/system entries that should never participate in imports.
|
||||||
|
*/
|
||||||
|
export function isSystemImportPath(path: string) {
|
||||||
|
return path.includes('__MACOSX') || path.includes('.DS_Store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the doc CRUD bridge used by importer transformers.
|
||||||
|
*/
|
||||||
|
export function createCollectionDocCRUD(collection: Workspace) {
|
||||||
|
return {
|
||||||
|
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
||||||
|
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
||||||
|
delete: (id: string) => collection.removeDoc(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateMarkdownImportJobOptions = {
|
||||||
|
collection: Workspace;
|
||||||
|
schema: Schema;
|
||||||
|
preferredTitle?: string;
|
||||||
|
fullPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a markdown import job with the standard collection middlewares.
|
||||||
|
*/
|
||||||
|
export function createMarkdownImportJob({
|
||||||
|
collection,
|
||||||
|
schema,
|
||||||
|
preferredTitle,
|
||||||
|
fullPath,
|
||||||
|
}: CreateMarkdownImportJobOptions) {
|
||||||
|
return new Transformer({
|
||||||
|
schema,
|
||||||
|
blobCRUD: collection.blobSync,
|
||||||
|
docCRUD: createCollectionDocCRUD(collection),
|
||||||
|
middlewares: [
|
||||||
|
defaultImageProxyMiddleware,
|
||||||
|
fileNameMiddleware(preferredTitle),
|
||||||
|
docLinkBaseURLMiddleware(collection.id),
|
||||||
|
...(fullPath ? [filePathMiddleware(fullPath)] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageImportedAssetOptions = {
|
||||||
|
pendingAssets: AssetMap;
|
||||||
|
pendingPathBlobIdMap: PathBlobIdMap;
|
||||||
|
path: string;
|
||||||
|
content: Blob;
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a non-markdown import file and stages it into the shared asset maps.
|
||||||
|
*/
|
||||||
|
export async function stageImportedAsset({
|
||||||
|
pendingAssets,
|
||||||
|
pendingPathBlobIdMap,
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
fileName,
|
||||||
|
}: StageImportedAssetOptions) {
|
||||||
|
const ext = path.split('.').at(-1) ?? '';
|
||||||
|
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
|
||||||
|
const key = await sha(await content.arrayBuffer());
|
||||||
|
pendingPathBlobIdMap.set(path, key);
|
||||||
|
pendingAssets.set(key, new File([content], fileName, { type: mime }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds previously staged asset files into a transformer job before import.
|
||||||
|
*/
|
||||||
|
export function bindImportedAssetsToJob(
|
||||||
|
job: Transformer,
|
||||||
|
pendingAssets: AssetMap,
|
||||||
|
pendingPathBlobIdMap: PathBlobIdMap
|
||||||
|
) {
|
||||||
|
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||||
|
// Iterate over all assets to be imported
|
||||||
|
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
|
||||||
|
// Get the relative path of the asset to the markdown file
|
||||||
|
// Store the path to blobId map
|
||||||
|
pathBlobIdMap.set(assetPath, key);
|
||||||
|
// Store the asset to assets, the key is the blobId, the value is the file object
|
||||||
|
// In block adapter, it will use the blobId to get the file object
|
||||||
|
const assetFile = pendingAssets.get(key);
|
||||||
|
if (assetFile) {
|
||||||
|
job.assets.set(key, assetFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathBlobIdMap;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports a doc to a Markdown file or a zip archive containing Markdown and assets.
|
* Exports a doc to a Markdown file or a zip archive containing Markdown and assets.
|
||||||
* @param doc The doc to export
|
* @param doc The doc to export
|
||||||
@@ -329,19 +437,10 @@ async function importMarkdownToDoc({
|
|||||||
const { content, meta } = parseFrontmatter(markdown);
|
const { content, meta } = parseFrontmatter(markdown);
|
||||||
const preferredTitle = meta.title ?? fileName;
|
const preferredTitle = meta.title ?? fileName;
|
||||||
const provider = getProvider(extensions);
|
const provider = getProvider(extensions);
|
||||||
const job = new Transformer({
|
const job = createMarkdownImportJob({
|
||||||
|
collection,
|
||||||
schema,
|
schema,
|
||||||
blobCRUD: collection.blobSync,
|
preferredTitle,
|
||||||
docCRUD: {
|
|
||||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
|
||||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
|
||||||
delete: (id: string) => collection.removeDoc(id),
|
|
||||||
},
|
|
||||||
middlewares: [
|
|
||||||
defaultImageProxyMiddleware,
|
|
||||||
fileNameMiddleware(preferredTitle),
|
|
||||||
docLinkBaseURLMiddleware(collection.id),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||||
const page = await mdAdapter.toDoc({
|
const page = await mdAdapter.toDoc({
|
||||||
@@ -381,7 +480,7 @@ async function importMarkdownZip({
|
|||||||
// Iterate over all files in the zip
|
// Iterate over all files in the zip
|
||||||
for (const { path, content: blob } of unzip) {
|
for (const { path, content: blob } of unzip) {
|
||||||
// Skip the files that are not markdown files
|
// Skip the files that are not markdown files
|
||||||
if (path.includes('__MACOSX') || path.includes('.DS_Store')) {
|
if (isSystemImportPath(path)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,12 +494,13 @@ async function importMarkdownZip({
|
|||||||
fullPath: path,
|
fullPath: path,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// If the file is not a markdown file, store it to pendingAssets
|
await stageImportedAsset({
|
||||||
const ext = path.split('.').at(-1) ?? '';
|
pendingAssets,
|
||||||
const mime = extMimeMap.get(ext) ?? '';
|
pendingPathBlobIdMap,
|
||||||
const key = await sha(await blob.arrayBuffer());
|
path,
|
||||||
pendingPathBlobIdMap.set(path, key);
|
content: blob,
|
||||||
pendingAssets.set(key, new File([blob], fileName, { type: mime }));
|
fileName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,34 +511,13 @@ async function importMarkdownZip({
|
|||||||
const markdown = await contentBlob.text();
|
const markdown = await contentBlob.text();
|
||||||
const { content, meta } = parseFrontmatter(markdown);
|
const { content, meta } = parseFrontmatter(markdown);
|
||||||
const preferredTitle = meta.title ?? fileNameWithoutExt;
|
const preferredTitle = meta.title ?? fileNameWithoutExt;
|
||||||
const job = new Transformer({
|
const job = createMarkdownImportJob({
|
||||||
|
collection,
|
||||||
schema,
|
schema,
|
||||||
blobCRUD: collection.blobSync,
|
preferredTitle,
|
||||||
docCRUD: {
|
fullPath,
|
||||||
create: (id: string) => collection.createDoc(id).getStore({ id }),
|
|
||||||
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
|
|
||||||
delete: (id: string) => collection.removeDoc(id),
|
|
||||||
},
|
|
||||||
middlewares: [
|
|
||||||
defaultImageProxyMiddleware,
|
|
||||||
fileNameMiddleware(preferredTitle),
|
|
||||||
docLinkBaseURLMiddleware(collection.id),
|
|
||||||
filePathMiddleware(fullPath),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const assets = job.assets;
|
bindImportedAssetsToJob(job, pendingAssets, pendingPathBlobIdMap);
|
||||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
|
||||||
// Iterate over all assets to be imported
|
|
||||||
for (const [assetPath, key] of pendingPathBlobIdMap.entries()) {
|
|
||||||
// Get the relative path of the asset to the markdown file
|
|
||||||
// Store the path to blobId map
|
|
||||||
pathBlobIdMap.set(assetPath, key);
|
|
||||||
// Store the asset to assets, the key is the blobId, the value is the file object
|
|
||||||
// In block adapter, it will use the blobId to get the file object
|
|
||||||
if (pendingAssets.get(key)) {
|
|
||||||
assets.set(key, pendingAssets.get(key)!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||||
const doc = await mdAdapter.toDoc({
|
const doc = await mdAdapter.toDoc({
|
||||||
|
|||||||
@@ -0,0 +1,834 @@
|
|||||||
|
import { FootNoteReferenceParamsSchema } from '@blocksuite/affine-model';
|
||||||
|
import {
|
||||||
|
BlockMarkdownAdapterExtension,
|
||||||
|
createAttachmentBlockSnapshot,
|
||||||
|
FULL_FILE_PATH_KEY,
|
||||||
|
getImageFullPath,
|
||||||
|
MarkdownAdapter,
|
||||||
|
type MarkdownAST,
|
||||||
|
MarkdownASTToDeltaExtension,
|
||||||
|
normalizeFilePathReference,
|
||||||
|
} from '@blocksuite/affine-shared/adapters';
|
||||||
|
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
|
import type {
|
||||||
|
DeltaInsert,
|
||||||
|
ExtensionType,
|
||||||
|
Schema,
|
||||||
|
Workspace,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
import { extMimeMap, nanoid } from '@blocksuite/store';
|
||||||
|
import type { Html, Text } from 'mdast';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyMetaPatch,
|
||||||
|
bindImportedAssetsToJob,
|
||||||
|
createMarkdownImportJob,
|
||||||
|
getProvider,
|
||||||
|
isSystemImportPath,
|
||||||
|
parseFrontmatter,
|
||||||
|
stageImportedAsset,
|
||||||
|
} from './markdown.js';
|
||||||
|
import type {
|
||||||
|
AssetMap,
|
||||||
|
MarkdownFileImportEntry,
|
||||||
|
PathBlobIdMap,
|
||||||
|
} from './type.js';
|
||||||
|
|
||||||
|
const CALLOUT_TYPE_MAP: Record<string, string> = {
|
||||||
|
note: '💡',
|
||||||
|
info: 'ℹ️',
|
||||||
|
tip: '🔥',
|
||||||
|
hint: '✅',
|
||||||
|
important: '‼️',
|
||||||
|
warning: '⚠️',
|
||||||
|
caution: '⚠️',
|
||||||
|
attention: '⚠️',
|
||||||
|
danger: '⚠️',
|
||||||
|
error: '🚨',
|
||||||
|
bug: '🐛',
|
||||||
|
example: '📌',
|
||||||
|
quote: '💬',
|
||||||
|
cite: '💬',
|
||||||
|
abstract: '📋',
|
||||||
|
summary: '📋',
|
||||||
|
todo: '☑️',
|
||||||
|
success: '✅',
|
||||||
|
check: '✅',
|
||||||
|
done: '✅',
|
||||||
|
failure: '❌',
|
||||||
|
fail: '❌',
|
||||||
|
missing: '❌',
|
||||||
|
question: '❓',
|
||||||
|
help: '❓',
|
||||||
|
faq: '❓',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMBIGUOUS_PAGE_LOOKUP = '__ambiguous__';
|
||||||
|
const DEFAULT_CALLOUT_EMOJI = '💡';
|
||||||
|
const OBSIDIAN_TEXT_FOOTNOTE_URL_PREFIX = 'data:text/plain;charset=utf-8,';
|
||||||
|
const OBSIDIAN_ATTACHMENT_EMBED_TAG = 'obsidian-attachment';
|
||||||
|
|
||||||
|
function normalizeLookupKey(value: string): string {
|
||||||
|
return normalizeFilePathReference(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMarkdownExtension(value: string): string {
|
||||||
|
return value.replace(/\.md$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(value: string): string {
|
||||||
|
return normalizeFilePathReference(value).split('/').pop() ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObsidianTarget(rawTarget: string): {
|
||||||
|
path: string;
|
||||||
|
fragment: string | null;
|
||||||
|
} {
|
||||||
|
const normalizedTarget = normalizeFilePathReference(rawTarget);
|
||||||
|
const match = normalizedTarget.match(/^([^#^]+)([#^].*)?$/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: match?.[1]?.trim() ?? normalizedTarget,
|
||||||
|
fragment: match?.[2] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitleAndEmoji(rawTitle: string): {
|
||||||
|
title: string;
|
||||||
|
emoji: string | null;
|
||||||
|
} {
|
||||||
|
const SINGLE_LEADING_EMOJI_RE =
|
||||||
|
/^[\s\u200b]*((?:[\p{Emoji_Presentation}\p{Extended_Pictographic}\u200b]|\u200d|\ufe0f)+)/u;
|
||||||
|
|
||||||
|
let currentTitle = rawTitle;
|
||||||
|
let extractedEmojiClusters = '';
|
||||||
|
let emojiMatch;
|
||||||
|
|
||||||
|
while ((emojiMatch = currentTitle.match(SINGLE_LEADING_EMOJI_RE))) {
|
||||||
|
const matchedCluster = emojiMatch[1].trim();
|
||||||
|
extractedEmojiClusters +=
|
||||||
|
(extractedEmojiClusters ? ' ' : '') + matchedCluster;
|
||||||
|
currentTitle = currentTitle.slice(emojiMatch[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: currentTitle.trim(),
|
||||||
|
emoji: extractedEmojiClusters || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function preprocessTitleHeader(markdown: string): string {
|
||||||
|
return markdown.replace(
|
||||||
|
/^(\s*#\s+)(.*)$/m,
|
||||||
|
(_, headerPrefix, titleContent) => {
|
||||||
|
const { title: cleanTitle } = extractTitleAndEmoji(titleContent);
|
||||||
|
return `${headerPrefix}${cleanTitle}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preprocessObsidianCallouts(markdown: string): string {
|
||||||
|
return markdown.replace(
|
||||||
|
/^(> *)\[!([^\]\n]+)\]([+-]?)([^\n]*)/gm,
|
||||||
|
(_, prefix, type, _fold, rest) => {
|
||||||
|
const calloutToken =
|
||||||
|
CALLOUT_TYPE_MAP[type.trim().toLowerCase()] ?? DEFAULT_CALLOUT_EMOJI;
|
||||||
|
const title = rest.trim();
|
||||||
|
return title
|
||||||
|
? `${prefix}[!${calloutToken}] ${title}`
|
||||||
|
: `${prefix}[!${calloutToken}]`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStructuredFootnoteDefinition(content: string): boolean {
|
||||||
|
try {
|
||||||
|
return FootNoteReferenceParamsSchema.safeParse(JSON.parse(content.trim()))
|
||||||
|
.success;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFootnoteTextContent(content: string): {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
} {
|
||||||
|
const lines = content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const title = lines[0] ?? content.trim();
|
||||||
|
const description = lines.slice(1).join('\n').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextFootnoteDefinition(content: string): string {
|
||||||
|
const normalizedContent = content.trim();
|
||||||
|
const { title, description } = splitFootnoteTextContent(normalizedContent);
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'url',
|
||||||
|
url: encodeURIComponent(
|
||||||
|
`${OBSIDIAN_TEXT_FOOTNOTE_URL_PREFIX}${encodeURIComponent(
|
||||||
|
normalizedContent
|
||||||
|
)}`
|
||||||
|
),
|
||||||
|
title,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFootnoteDefLine(line: string): {
|
||||||
|
identifier: string;
|
||||||
|
content: string;
|
||||||
|
} | null {
|
||||||
|
if (!line.startsWith('[^')) return null;
|
||||||
|
|
||||||
|
const closeBracketIndex = line.indexOf(']:', 2);
|
||||||
|
if (closeBracketIndex <= 2) return null;
|
||||||
|
|
||||||
|
const identifier = line.slice(2, closeBracketIndex);
|
||||||
|
if (!identifier || identifier.includes(']')) return null;
|
||||||
|
|
||||||
|
let contentStart = closeBracketIndex + 2;
|
||||||
|
while (
|
||||||
|
contentStart < line.length &&
|
||||||
|
(line[contentStart] === ' ' || line[contentStart] === '\t')
|
||||||
|
) {
|
||||||
|
contentStart += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
content: line.slice(contentStart),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractObsidianFootnotes(markdown: string): {
|
||||||
|
content: string;
|
||||||
|
footnotes: string[];
|
||||||
|
} {
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
const output: string[] = [];
|
||||||
|
const footnotes: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const line = lines[index];
|
||||||
|
const definition = parseFootnoteDefLine(line);
|
||||||
|
if (!definition) {
|
||||||
|
output.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { identifier } = definition;
|
||||||
|
const contentLines = [definition.content];
|
||||||
|
|
||||||
|
while (index + 1 < lines.length) {
|
||||||
|
const nextLine = lines[index + 1];
|
||||||
|
if (/^(?: {1,4}|\t)/.test(nextLine)) {
|
||||||
|
contentLines.push(nextLine.replace(/^(?: {1,4}|\t)/, ''));
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextLine.trim() === '' &&
|
||||||
|
index + 2 < lines.length &&
|
||||||
|
/^(?: {1,4}|\t)/.test(lines[index + 2])
|
||||||
|
) {
|
||||||
|
contentLines.push('');
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = contentLines.join('\n').trim();
|
||||||
|
footnotes.push(
|
||||||
|
`[^${identifier}]: ${
|
||||||
|
!content || isStructuredFootnoteDefinition(content)
|
||||||
|
? content
|
||||||
|
: createTextFootnoteDefinition(content)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: output.join('\n'), footnotes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLookupKeys(
|
||||||
|
targetPath: string,
|
||||||
|
currentFilePath?: string
|
||||||
|
): string[] {
|
||||||
|
const parsedTargetPath = normalizeFilePathReference(targetPath);
|
||||||
|
if (!parsedTargetPath) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const addPathVariants = (value: string) => {
|
||||||
|
const normalizedValue = normalizeFilePathReference(value);
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.add(normalizedValue);
|
||||||
|
keys.add(stripMarkdownExtension(normalizedValue));
|
||||||
|
|
||||||
|
const fileName = basename(normalizedValue);
|
||||||
|
keys.add(fileName);
|
||||||
|
keys.add(stripMarkdownExtension(fileName));
|
||||||
|
|
||||||
|
const cleanTitle = extractTitleAndEmoji(
|
||||||
|
stripMarkdownExtension(fileName)
|
||||||
|
).title;
|
||||||
|
if (cleanTitle) {
|
||||||
|
keys.add(cleanTitle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addPathVariants(parsedTargetPath);
|
||||||
|
|
||||||
|
if (currentFilePath) {
|
||||||
|
addPathVariants(getImageFullPath(currentFilePath, parsedTargetPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(keys).map(normalizeLookupKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerPageLookup(
|
||||||
|
pageLookupMap: Map<string, string>,
|
||||||
|
key: string,
|
||||||
|
pageId: string
|
||||||
|
) {
|
||||||
|
const normalizedKey = normalizeLookupKey(key);
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = pageLookupMap.get(normalizedKey);
|
||||||
|
if (existing && existing !== pageId) {
|
||||||
|
pageLookupMap.set(normalizedKey, AMBIGUOUS_PAGE_LOOKUP);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageLookupMap.set(normalizedKey, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageIdFromLookup(
|
||||||
|
pageLookupMap: Pick<ReadonlyMap<string, string>, 'get'>,
|
||||||
|
rawTarget: string,
|
||||||
|
currentFilePath?: string
|
||||||
|
): string | null {
|
||||||
|
const { path } = parseObsidianTarget(rawTarget);
|
||||||
|
for (const key of buildLookupKeys(path, currentFilePath)) {
|
||||||
|
const targetPageId = pageLookupMap.get(key);
|
||||||
|
if (!targetPageId || targetPageId === AMBIGUOUS_PAGE_LOOKUP) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return targetPageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWikilinkDisplayTitle(
|
||||||
|
rawAlias: string | undefined,
|
||||||
|
pageEmoji: string | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (!rawAlias) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title: aliasTitle, emoji: aliasEmoji } =
|
||||||
|
extractTitleAndEmoji(rawAlias);
|
||||||
|
|
||||||
|
if (aliasEmoji && aliasEmoji === pageEmoji) {
|
||||||
|
return aliasTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawAlias;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageAssetPath(path: string): boolean {
|
||||||
|
const extension = path.split('.').at(-1)?.toLowerCase() ?? '';
|
||||||
|
return extMimeMap.get(extension)?.startsWith('image/') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMarkdownPath(path: string): string {
|
||||||
|
return encodeURI(path).replaceAll('(', '%28').replaceAll(')', '%29');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeMarkdownLabel(label: string): string {
|
||||||
|
return label.replace(/[[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObsidianSizeAlias(alias: string | undefined): boolean {
|
||||||
|
return !!alias && /^\d+(?:x\d+)?$/i.test(alias.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmbedLabel(
|
||||||
|
rawAlias: string | undefined,
|
||||||
|
targetPath: string,
|
||||||
|
fallbackToFileName: boolean
|
||||||
|
): string {
|
||||||
|
if (!rawAlias || isObsidianSizeAlias(rawAlias)) {
|
||||||
|
return fallbackToFileName
|
||||||
|
? stripMarkdownExtension(basename(targetPath))
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawAlias.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObsidianAttachmentEmbed = {
|
||||||
|
blobId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createObsidianAttach(embed: ObsidianAttachmentEmbed): string {
|
||||||
|
return `<!-- ${OBSIDIAN_ATTACHMENT_EMBED_TAG} ${encodeURIComponent(
|
||||||
|
JSON.stringify(embed)
|
||||||
|
)} -->`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObsidianAttach(value: string): ObsidianAttachmentEmbed | null {
|
||||||
|
const match = value.match(
|
||||||
|
new RegExp(`^<!-- ${OBSIDIAN_ATTACHMENT_EMBED_TAG} ([^ ]+) -->$`)
|
||||||
|
);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
decodeURIComponent(match[1])
|
||||||
|
) as ObsidianAttachmentEmbed;
|
||||||
|
if (!parsed.blobId || !parsed.fileName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWikiLinkAt(
|
||||||
|
source: string,
|
||||||
|
startIdx: number,
|
||||||
|
embedded: boolean
|
||||||
|
): {
|
||||||
|
raw: string;
|
||||||
|
rawTarget: string;
|
||||||
|
rawAlias?: string;
|
||||||
|
endIdx: number;
|
||||||
|
} | null {
|
||||||
|
const opener = embedded ? '![[' : '[[';
|
||||||
|
if (!source.startsWith(opener, startIdx)) return null;
|
||||||
|
|
||||||
|
const contentStart = startIdx + opener.length;
|
||||||
|
const closeIndex = source.indexOf(']]', contentStart);
|
||||||
|
if (closeIndex === -1) return null;
|
||||||
|
|
||||||
|
const inner = source.slice(contentStart, closeIndex);
|
||||||
|
const separatorIdx = inner.indexOf('|');
|
||||||
|
const rawTarget = separatorIdx === -1 ? inner : inner.slice(0, separatorIdx);
|
||||||
|
const rawAlias =
|
||||||
|
separatorIdx === -1 ? undefined : inner.slice(separatorIdx + 1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawTarget.length === 0 ||
|
||||||
|
rawTarget.includes(']') ||
|
||||||
|
rawTarget.includes('|') ||
|
||||||
|
rawAlias?.includes(']')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: source.slice(startIdx, closeIndex + 2),
|
||||||
|
rawTarget,
|
||||||
|
rawAlias,
|
||||||
|
endIdx: closeIndex + 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWikiLinks(
|
||||||
|
source: string,
|
||||||
|
embedded: boolean,
|
||||||
|
replacer: (match: {
|
||||||
|
raw: string;
|
||||||
|
rawTarget: string;
|
||||||
|
rawAlias?: string;
|
||||||
|
}) => string
|
||||||
|
): string {
|
||||||
|
const opener = embedded ? '![[' : '[[';
|
||||||
|
let cursor = 0;
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
while (cursor < source.length) {
|
||||||
|
const matchStart = source.indexOf(opener, cursor);
|
||||||
|
if (matchStart === -1) {
|
||||||
|
output += source.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
output += source.slice(cursor, matchStart);
|
||||||
|
const match = parseWikiLinkAt(source, matchStart, embedded);
|
||||||
|
if (!match) {
|
||||||
|
output += source.slice(matchStart, matchStart + opener.length);
|
||||||
|
cursor = matchStart + opener.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output += replacer(match);
|
||||||
|
cursor = match.endIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preprocessObsidianEmbeds(
|
||||||
|
markdown: string,
|
||||||
|
filePath: string,
|
||||||
|
pageLookupMap: ReadonlyMap<string, string>,
|
||||||
|
pathBlobIdMap: ReadonlyMap<string, string>
|
||||||
|
): string {
|
||||||
|
return replaceWikiLinks(markdown, true, ({ raw, rawTarget, rawAlias }) => {
|
||||||
|
const targetPageId = resolvePageIdFromLookup(
|
||||||
|
pageLookupMap,
|
||||||
|
rawTarget,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
if (targetPageId) {
|
||||||
|
return `[[${rawTarget}${rawAlias ? `|${rawAlias}` : ''}]]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = parseObsidianTarget(rawTarget);
|
||||||
|
if (!path) return raw;
|
||||||
|
|
||||||
|
const assetPath = getImageFullPath(filePath, path);
|
||||||
|
const encodedPath = encodeMarkdownPath(assetPath);
|
||||||
|
|
||||||
|
if (isImageAssetPath(path)) {
|
||||||
|
const alt = getEmbedLabel(rawAlias, path, false);
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = getEmbedLabel(rawAlias, path, true);
|
||||||
|
const blobId = pathBlobIdMap.get(assetPath);
|
||||||
|
if (!blobId) return `[${escapeMarkdownLabel(label)}](${encodedPath})`;
|
||||||
|
|
||||||
|
const extension = path.split('.').at(-1)?.toLowerCase() ?? '';
|
||||||
|
return createObsidianAttach({
|
||||||
|
blobId,
|
||||||
|
fileName: basename(path),
|
||||||
|
fileType: extMimeMap.get(extension) ?? '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function preprocessObsidianMarkdown(
|
||||||
|
markdown: string,
|
||||||
|
filePath: string,
|
||||||
|
pageLookupMap: ReadonlyMap<string, string>,
|
||||||
|
pathBlobIdMap: ReadonlyMap<string, string>
|
||||||
|
): string {
|
||||||
|
const { content: contentWithoutFootnotes, footnotes: extractedFootnotes } =
|
||||||
|
extractObsidianFootnotes(markdown);
|
||||||
|
const content = preprocessObsidianEmbeds(
|
||||||
|
contentWithoutFootnotes,
|
||||||
|
filePath,
|
||||||
|
pageLookupMap,
|
||||||
|
pathBlobIdMap
|
||||||
|
);
|
||||||
|
const normalizedMarkdown = preprocessTitleHeader(
|
||||||
|
preprocessObsidianCallouts(content)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extractedFootnotes.length === 0) {
|
||||||
|
return normalizedMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedMarkdown = normalizedMarkdown.replace(/\s+$/, '');
|
||||||
|
return `${trimmedMarkdown}\n\n${extractedFootnotes.join('\n\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObsidianAttachmentEmbedNode(node: MarkdownAST): node is Html {
|
||||||
|
return node.type === 'html' && !!parseObsidianAttach(node.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const obsidianAttachmentEmbedMarkdownAdapterMatcher =
|
||||||
|
BlockMarkdownAdapterExtension({
|
||||||
|
flavour: 'obsidian:attachment-embed',
|
||||||
|
toMatch: o => isObsidianAttachmentEmbedNode(o.node),
|
||||||
|
fromMatch: () => false,
|
||||||
|
toBlockSnapshot: {
|
||||||
|
enter: (o, context) => {
|
||||||
|
if (!isObsidianAttachmentEmbedNode(o.node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = parseObsidianAttach(o.node.value);
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetFile = context.assets?.getAssets().get(attachment.blobId);
|
||||||
|
context.walkerContext
|
||||||
|
.openNode(
|
||||||
|
createAttachmentBlockSnapshot({
|
||||||
|
id: nanoid(),
|
||||||
|
props: {
|
||||||
|
name: attachment.fileName,
|
||||||
|
size: assetFile?.size ?? 0,
|
||||||
|
type:
|
||||||
|
attachment.fileType ||
|
||||||
|
assetFile?.type ||
|
||||||
|
'application/octet-stream',
|
||||||
|
sourceId: attachment.blobId,
|
||||||
|
embed: false,
|
||||||
|
style: 'horizontalThin',
|
||||||
|
footnoteIdentifier: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'children'
|
||||||
|
)
|
||||||
|
.closeNode();
|
||||||
|
(o.node as unknown as { type: string }).type =
|
||||||
|
'obsidianAttachmentEmbed';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fromBlockSnapshot: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const obsidianWikilinkToDeltaMatcher = MarkdownASTToDeltaExtension({
|
||||||
|
name: 'obsidian-wikilink',
|
||||||
|
match: ast => ast.type === 'text',
|
||||||
|
toDelta: (ast, context) => {
|
||||||
|
const textNode = ast as Text;
|
||||||
|
if (!textNode.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeContent = textNode.value;
|
||||||
|
const deltas: DeltaInsert<AffineTextAttributes>[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < nodeContent.length) {
|
||||||
|
const matchStart = nodeContent.indexOf('[[', cursor);
|
||||||
|
if (matchStart === -1) {
|
||||||
|
deltas.push({ insert: nodeContent.substring(cursor) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchStart > cursor) {
|
||||||
|
deltas.push({
|
||||||
|
insert: nodeContent.substring(cursor, matchStart),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkMatch = parseWikiLinkAt(nodeContent, matchStart, false);
|
||||||
|
if (!linkMatch) {
|
||||||
|
deltas.push({ insert: '[[' });
|
||||||
|
cursor = matchStart + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPageName = linkMatch.rawTarget.trim();
|
||||||
|
const alias = linkMatch.rawAlias?.trim();
|
||||||
|
const currentFilePath = context.configs.get(FULL_FILE_PATH_KEY);
|
||||||
|
const targetPageId = resolvePageIdFromLookup(
|
||||||
|
{ get: key => context.configs.get(`obsidian:pageId:${key}`) },
|
||||||
|
targetPageName,
|
||||||
|
typeof currentFilePath === 'string' ? currentFilePath : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetPageId) {
|
||||||
|
const pageEmoji = context.configs.get(
|
||||||
|
'obsidian:pageEmoji:' + targetPageId
|
||||||
|
);
|
||||||
|
const displayTitle = resolveWikilinkDisplayTitle(alias, pageEmoji);
|
||||||
|
|
||||||
|
deltas.push({
|
||||||
|
insert: ' ',
|
||||||
|
attributes: {
|
||||||
|
reference: {
|
||||||
|
type: 'LinkedPage',
|
||||||
|
pageId: targetPageId,
|
||||||
|
...(displayTitle ? { title: displayTitle } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deltas.push({ insert: linkMatch.raw });
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = linkMatch.endIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deltas;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ImportObsidianVaultOptions = {
|
||||||
|
collection: Workspace;
|
||||||
|
schema: Schema;
|
||||||
|
importedFiles: File[];
|
||||||
|
extensions: ExtensionType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportObsidianVaultResult = {
|
||||||
|
docIds: string[];
|
||||||
|
docEmojis: Map<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importObsidianVault({
|
||||||
|
collection,
|
||||||
|
schema,
|
||||||
|
importedFiles,
|
||||||
|
extensions,
|
||||||
|
}: ImportObsidianVaultOptions): Promise<ImportObsidianVaultResult> {
|
||||||
|
const provider = getProvider([
|
||||||
|
obsidianWikilinkToDeltaMatcher,
|
||||||
|
obsidianAttachmentEmbedMarkdownAdapterMatcher,
|
||||||
|
...extensions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const docIds: string[] = [];
|
||||||
|
const docEmojis = new Map<string, string>();
|
||||||
|
const pendingAssets: AssetMap = new Map();
|
||||||
|
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
|
||||||
|
const markdownBlobs: MarkdownFileImportEntry[] = [];
|
||||||
|
const pageLookupMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const file of importedFiles) {
|
||||||
|
const filePath = file.webkitRelativePath || file.name;
|
||||||
|
if (isSystemImportPath(filePath)) continue;
|
||||||
|
|
||||||
|
if (file.name.endsWith('.md')) {
|
||||||
|
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const markdown = await file.text();
|
||||||
|
const { content, meta } = parseFrontmatter(markdown);
|
||||||
|
|
||||||
|
const documentTitleCandidate = meta.title ?? fileNameWithoutExt;
|
||||||
|
const { title: preferredTitle, emoji: leadingEmoji } =
|
||||||
|
extractTitleAndEmoji(documentTitleCandidate);
|
||||||
|
|
||||||
|
const newPageId = collection.idGenerator();
|
||||||
|
registerPageLookup(pageLookupMap, filePath, newPageId);
|
||||||
|
registerPageLookup(
|
||||||
|
pageLookupMap,
|
||||||
|
stripMarkdownExtension(filePath),
|
||||||
|
newPageId
|
||||||
|
);
|
||||||
|
registerPageLookup(pageLookupMap, file.name, newPageId);
|
||||||
|
registerPageLookup(pageLookupMap, fileNameWithoutExt, newPageId);
|
||||||
|
registerPageLookup(pageLookupMap, documentTitleCandidate, newPageId);
|
||||||
|
registerPageLookup(pageLookupMap, preferredTitle, newPageId);
|
||||||
|
|
||||||
|
if (leadingEmoji) {
|
||||||
|
docEmojis.set(newPageId, leadingEmoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownBlobs.push({
|
||||||
|
filename: file.name,
|
||||||
|
contentBlob: file,
|
||||||
|
fullPath: filePath,
|
||||||
|
pageId: newPageId,
|
||||||
|
preferredTitle,
|
||||||
|
content,
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await stageImportedAsset({
|
||||||
|
pendingAssets,
|
||||||
|
pendingPathBlobIdMap,
|
||||||
|
path: filePath,
|
||||||
|
content: file,
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingDocMeta of collection.meta.docMetas) {
|
||||||
|
if (existingDocMeta.title) {
|
||||||
|
registerPageLookup(
|
||||||
|
pageLookupMap,
|
||||||
|
existingDocMeta.title,
|
||||||
|
existingDocMeta.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
markdownBlobs.map(async markdownFile => {
|
||||||
|
const {
|
||||||
|
fullPath,
|
||||||
|
pageId: predefinedId,
|
||||||
|
preferredTitle,
|
||||||
|
content,
|
||||||
|
meta,
|
||||||
|
} = markdownFile;
|
||||||
|
|
||||||
|
const job = createMarkdownImportJob({
|
||||||
|
collection,
|
||||||
|
schema,
|
||||||
|
preferredTitle,
|
||||||
|
fullPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [lookupKey, id] of pageLookupMap.entries()) {
|
||||||
|
if (id === AMBIGUOUS_PAGE_LOOKUP) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
job.adapterConfigs.set(`obsidian:pageId:${lookupKey}`, id);
|
||||||
|
}
|
||||||
|
for (const [id, emoji] of docEmojis.entries()) {
|
||||||
|
job.adapterConfigs.set('obsidian:pageEmoji:' + id, emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathBlobIdMap = bindImportedAssetsToJob(
|
||||||
|
job,
|
||||||
|
pendingAssets,
|
||||||
|
pendingPathBlobIdMap
|
||||||
|
);
|
||||||
|
|
||||||
|
const preprocessedMarkdown = preprocessObsidianMarkdown(
|
||||||
|
content,
|
||||||
|
fullPath,
|
||||||
|
pageLookupMap,
|
||||||
|
pathBlobIdMap
|
||||||
|
);
|
||||||
|
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||||
|
const snapshot = await mdAdapter.toDocSnapshot({
|
||||||
|
file: preprocessedMarkdown,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
snapshot.meta.id = predefinedId;
|
||||||
|
const doc = await job.snapshotToDoc(snapshot);
|
||||||
|
if (doc) {
|
||||||
|
applyMetaPatch(collection, doc.id, {
|
||||||
|
...meta,
|
||||||
|
title: preferredTitle,
|
||||||
|
trash: false,
|
||||||
|
});
|
||||||
|
docIds.push(doc.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { docIds, docEmojis };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObsidianTransformer = {
|
||||||
|
importObsidianVault,
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ParsedFrontmatterMeta } from './markdown.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents an imported file entry in the zip archive
|
* Represents an imported file entry in the zip archive
|
||||||
*/
|
*/
|
||||||
@@ -10,6 +12,13 @@ export type ImportedFileEntry = {
|
|||||||
fullPath: string;
|
fullPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MarkdownFileImportEntry = ImportedFileEntry & {
|
||||||
|
pageId: string;
|
||||||
|
preferredTitle: string;
|
||||||
|
content: string;
|
||||||
|
meta: ParsedFrontmatterMeta;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of asset hash to File object for all media files in the zip
|
* Map of asset hash to File object for all media files in the zip
|
||||||
* Key: SHA hash of the file content (blobId)
|
* Key: SHA hash of the file content (blobId)
|
||||||
|
|||||||
@@ -162,10 +162,11 @@ export class AffineToolbarWidget extends WidgetComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setReferenceElementWithElements(gfx: GfxController, elements: GfxModel[]) {
|
setReferenceElementWithElements(gfx: GfxController, elements: GfxModel[]) {
|
||||||
|
const surfaceBounds = getCommonBoundWithRotation(elements);
|
||||||
|
|
||||||
const getBoundingClientRect = () => {
|
const getBoundingClientRect = () => {
|
||||||
const bounds = getCommonBoundWithRotation(elements);
|
|
||||||
const { x: offsetX, y: offsetY } = this.getBoundingClientRect();
|
const { x: offsetX, y: offsetY } = this.getBoundingClientRect();
|
||||||
const [x, y, w, h] = gfx.viewport.toViewBound(bounds).toXYWH();
|
const [x, y, w, h] = gfx.viewport.toViewBound(surfaceBounds).toXYWH();
|
||||||
const rect = new DOMRect(x + offsetX, y + offsetY, w, h);
|
const rect = new DOMRect(x + offsetX, y + offsetY, w, h);
|
||||||
return rect;
|
return rect;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"version": "0.26.3"
|
"version": "0.26.3"
|
||||||
}
|
}
|
||||||
|
|||||||
22
blocksuite/framework/global/src/__tests__/curve.unit.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { getBezierParameters } from '../gfx/curve.js';
|
||||||
|
import { PointLocation } from '../gfx/model/index.js';
|
||||||
|
|
||||||
|
describe('getBezierParameters', () => {
|
||||||
|
test('should handle empty path', () => {
|
||||||
|
expect(() => getBezierParameters([])).not.toThrow();
|
||||||
|
expect(getBezierParameters([])).toEqual([
|
||||||
|
new PointLocation(),
|
||||||
|
new PointLocation(),
|
||||||
|
new PointLocation(),
|
||||||
|
new PointLocation(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle single-point path', () => {
|
||||||
|
const point = new PointLocation([10, 20]);
|
||||||
|
|
||||||
|
expect(getBezierParameters([point])).toEqual([point, point, point, point]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,6 +142,11 @@ export function getBezierNearestPoint(
|
|||||||
export function getBezierParameters(
|
export function getBezierParameters(
|
||||||
points: PointLocation[]
|
points: PointLocation[]
|
||||||
): BezierCurveParameters {
|
): BezierCurveParameters {
|
||||||
|
if (points.length === 0) {
|
||||||
|
const point = new PointLocation();
|
||||||
|
return [point, point, point, point];
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback for degenerate Bezier curve (all points are at the same position)
|
// Fallback for degenerate Bezier curve (all points are at the same position)
|
||||||
if (points.length === 1) {
|
if (points.length === 1) {
|
||||||
const point = points[0];
|
const point = points[0];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul',
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/global',
|
reportsDirectory: '../../../.coverage/global',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"playwright": "=1.58.2",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ export class LayerManager extends GfxExtension {
|
|||||||
private _updateLayer(
|
private _updateLayer(
|
||||||
element: GfxModel | GfxLocalElementModel,
|
element: GfxModel | GfxLocalElementModel,
|
||||||
props?: Record<string, unknown>,
|
props?: Record<string, unknown>,
|
||||||
oldValues?: Record<string, unknown>
|
_oldValues?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
const modelType = this._getModelType(element);
|
const modelType = this._getModelType(element);
|
||||||
const isLocalElem = element instanceof GfxLocalElementModel;
|
const isLocalElem = element instanceof GfxLocalElementModel;
|
||||||
@@ -613,16 +613,7 @@ export class LayerManager extends GfxExtension {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (shouldUpdateGroupChildren) {
|
if (shouldUpdateGroupChildren) {
|
||||||
const group = element as GfxModel & GfxGroupCompatibleInterface;
|
this._reset();
|
||||||
const oldChildIds = childIdsChanged
|
|
||||||
? Array.isArray(oldValues?.['childIds'])
|
|
||||||
? (oldValues['childIds'] as string[])
|
|
||||||
: this._groupChildSnapshot.get(group.id)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const relatedElements = this._getRelatedGroupElements(group, oldChildIds);
|
|
||||||
this._refreshElementsInLayer(relatedElements);
|
|
||||||
this._syncGroupChildSnapshot(group);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,9 @@ export abstract class GfxPrimitiveElementModel<
|
|||||||
}
|
}
|
||||||
|
|
||||||
get deserializedXYWH() {
|
get deserializedXYWH() {
|
||||||
if (!this._lastXYWH || this.xywh !== this._lastXYWH) {
|
const xywh = this.xywh;
|
||||||
const xywh = this.xywh;
|
|
||||||
|
if (!this._lastXYWH || xywh !== this._lastXYWH) {
|
||||||
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
|
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
|
||||||
this._lastXYWH = xywh;
|
this._lastXYWH = xywh;
|
||||||
}
|
}
|
||||||
@@ -386,6 +387,8 @@ export abstract class GfxGroupLikeElementModel<
|
|||||||
{
|
{
|
||||||
private _childIds: string[] = [];
|
private _childIds: string[] = [];
|
||||||
|
|
||||||
|
private _xywhDirty = true;
|
||||||
|
|
||||||
private readonly _mutex = createMutex();
|
private readonly _mutex = createMutex();
|
||||||
|
|
||||||
abstract children: Y.Map<any>;
|
abstract children: Y.Map<any>;
|
||||||
@@ -420,24 +423,9 @@ export abstract class GfxGroupLikeElementModel<
|
|||||||
|
|
||||||
get xywh() {
|
get xywh() {
|
||||||
this._mutex(() => {
|
this._mutex(() => {
|
||||||
const curXYWH =
|
if (this._xywhDirty || !this._local.has('xywh')) {
|
||||||
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
|
this._local.set('xywh', this._getXYWH().serialize());
|
||||||
const newXYWH = this._getXYWH().serialize();
|
this._xywhDirty = false;
|
||||||
|
|
||||||
if (curXYWH !== newXYWH || !this._local.has('xywh')) {
|
|
||||||
this._local.set('xywh', newXYWH);
|
|
||||||
|
|
||||||
if (curXYWH !== newXYWH) {
|
|
||||||
this._onChange({
|
|
||||||
props: {
|
|
||||||
xywh: newXYWH,
|
|
||||||
},
|
|
||||||
oldValues: {
|
|
||||||
xywh: curXYWH,
|
|
||||||
},
|
|
||||||
local: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -457,15 +445,41 @@ export abstract class GfxGroupLikeElementModel<
|
|||||||
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
|
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bound) {
|
|
||||||
this._local.set('xywh', bound.serialize());
|
|
||||||
} else {
|
|
||||||
this._local.delete('xywh');
|
|
||||||
}
|
|
||||||
|
|
||||||
return bound ?? new Bound(0, 0, 0, 0);
|
return bound ?? new Bound(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateXYWH() {
|
||||||
|
this._xywhDirty = true;
|
||||||
|
this._local.delete('deserializedXYWH');
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshXYWH(local: boolean) {
|
||||||
|
this._mutex(() => {
|
||||||
|
const oldXYWH =
|
||||||
|
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
|
||||||
|
const nextXYWH = this._getXYWH().serialize();
|
||||||
|
|
||||||
|
this._xywhDirty = false;
|
||||||
|
|
||||||
|
if (oldXYWH === nextXYWH && this._local.has('xywh')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._local.set('xywh', nextXYWH);
|
||||||
|
this._local.delete('deserializedXYWH');
|
||||||
|
|
||||||
|
this._onChange({
|
||||||
|
props: {
|
||||||
|
xywh: nextXYWH,
|
||||||
|
},
|
||||||
|
oldValues: {
|
||||||
|
xywh: oldXYWH,
|
||||||
|
},
|
||||||
|
local,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
abstract addChild(element: GfxModel): void;
|
abstract addChild(element: GfxModel): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -496,6 +510,7 @@ export abstract class GfxGroupLikeElementModel<
|
|||||||
setChildIds(value: string[], fromLocal: boolean) {
|
setChildIds(value: string[], fromLocal: boolean) {
|
||||||
const oldChildIds = this.childIds;
|
const oldChildIds = this.childIds;
|
||||||
this._childIds = value;
|
this._childIds = value;
|
||||||
|
this.invalidateXYWH();
|
||||||
|
|
||||||
this._onChange({
|
this._onChange({
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export type MiddlewareCtx = {
|
|||||||
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
|
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
|
||||||
|
|
||||||
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||||
|
private static readonly _groupBoundImpactKeys = new Set([
|
||||||
|
'xywh',
|
||||||
|
'rotate',
|
||||||
|
'hidden',
|
||||||
|
]);
|
||||||
|
|
||||||
protected _decoratorState = createDecoratorState();
|
protected _decoratorState = createDecoratorState();
|
||||||
|
|
||||||
protected _elementCtorMap: Record<
|
protected _elementCtorMap: Record<
|
||||||
@@ -308,6 +314,42 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
Object.keys(payload.props).forEach(key => {
|
Object.keys(payload.props).forEach(key => {
|
||||||
model.propsUpdated.next({ key });
|
model.propsUpdated.next({ key });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._refreshParentGroupBoundsForElement(model, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _refreshParentGroupBounds(id: string, local: boolean) {
|
||||||
|
const group = this.getGroup(id);
|
||||||
|
|
||||||
|
if (group instanceof GfxGroupLikeElementModel) {
|
||||||
|
group.refreshXYWH(local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _refreshParentGroupBoundsForElement(
|
||||||
|
model: GfxPrimitiveElementModel,
|
||||||
|
payload: ElementUpdatedData
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
model instanceof GfxGroupLikeElementModel &&
|
||||||
|
('childIds' in payload.props || 'childIds' in payload.oldValues)
|
||||||
|
) {
|
||||||
|
model.refreshXYWH(payload.local);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedKeys = new Set([
|
||||||
|
...Object.keys(payload.props),
|
||||||
|
...Object.keys(payload.oldValues),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.from(affectedKeys).some(key =>
|
||||||
|
SurfaceBlockModel._groupBoundImpactKeys.has(key)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this._refreshParentGroupBounds(model.id, payload.local);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initElementModels() {
|
private _initElementModels() {
|
||||||
@@ -458,6 +500,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.model instanceof BlockModel) {
|
||||||
|
this._refreshParentGroupBounds(payload.id, payload.isLocal);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||||
@@ -482,6 +528,13 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.props.key &&
|
||||||
|
SurfaceBlockModel._groupBoundImpactKeys.has(payload.props.key)
|
||||||
|
) {
|
||||||
|
this._refreshParentGroupBounds(payload.id, payload.isLocal);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ function updateTransform(element: GfxBlockComponent) {
|
|||||||
element.style.transform = element.getCSSTransform();
|
element.style.transform = element.getCSSTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateZIndex(element: GfxBlockComponent) {
|
||||||
|
const zIndex = element.toZIndex();
|
||||||
|
if (element.style.zIndex !== zIndex) {
|
||||||
|
element.style.zIndex = zIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateBlockVisibility(view: GfxBlockComponent) {
|
function updateBlockVisibility(view: GfxBlockComponent) {
|
||||||
if (view.transformState$.value === 'active') {
|
if (view.transformState$.value === 'active') {
|
||||||
view.style.visibility = 'visible';
|
view.style.visibility = 'visible';
|
||||||
@@ -58,14 +65,22 @@ function handleGfxConnection(instance: GfxBlockComponent) {
|
|||||||
instance.store.slots.blockUpdated.subscribe(({ type, id }) => {
|
instance.store.slots.blockUpdated.subscribe(({ type, id }) => {
|
||||||
if (id === instance.model.id && type === 'update') {
|
if (id === instance.model.id && type === 'update') {
|
||||||
updateTransform(instance);
|
updateTransform(instance);
|
||||||
|
updateZIndex(instance);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
instance.disposables.add(
|
||||||
|
instance.gfx.layer.slots.layerUpdated.subscribe(() => {
|
||||||
|
updateZIndex(instance);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
instance.disposables.add(
|
instance.disposables.add(
|
||||||
effect(() => {
|
effect(() => {
|
||||||
updateBlockVisibility(instance);
|
updateBlockVisibility(instance);
|
||||||
updateTransform(instance);
|
updateTransform(instance);
|
||||||
|
updateZIndex(instance);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,17 +120,23 @@ export abstract class GfxBlockComponent<
|
|||||||
|
|
||||||
onBoxSelected(_: BoxSelectionContext) {}
|
onBoxSelected(_: BoxSelectionContext) {}
|
||||||
|
|
||||||
|
getCSSScaleVal(): number {
|
||||||
|
const viewport = this.gfx.viewport;
|
||||||
|
const { zoom, viewScale } = viewport;
|
||||||
|
return zoom / viewScale;
|
||||||
|
}
|
||||||
|
|
||||||
getCSSTransform() {
|
getCSSTransform() {
|
||||||
const viewport = this.gfx.viewport;
|
const viewport = this.gfx.viewport;
|
||||||
const { translateX, translateY, zoom } = viewport;
|
const { translateX, translateY, zoom, viewScale } = viewport;
|
||||||
const bound = Bound.deserialize(this.model.xywh);
|
const bound = Bound.deserialize(this.model.xywh);
|
||||||
|
|
||||||
const scaledX = bound.x * zoom;
|
const scaledX = (bound.x * zoom) / viewScale;
|
||||||
const scaledY = bound.y * zoom;
|
const scaledY = (bound.y * zoom) / viewScale;
|
||||||
const deltaX = scaledX - bound.x;
|
const deltaX = scaledX - bound.x;
|
||||||
const deltaY = scaledY - bound.y;
|
const deltaY = scaledY - bound.y;
|
||||||
|
|
||||||
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
|
return `translate(${translateX / viewScale + deltaX}px, ${translateY / viewScale + deltaY}px) scale(${this.getCSSScaleVal()})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRenderingRect() {
|
getRenderingRect() {
|
||||||
@@ -219,18 +240,12 @@ export function toGfxBlockComponent<
|
|||||||
handleGfxConnection(this);
|
handleGfxConnection(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
getCSSScaleVal(): number {
|
||||||
|
return GfxBlockComponent.prototype.getCSSScaleVal.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
getCSSTransform() {
|
getCSSTransform() {
|
||||||
const viewport = this.gfx.viewport;
|
return GfxBlockComponent.prototype.getCSSTransform.call(this);
|
||||||
const { translateX, translateY, zoom } = viewport;
|
|
||||||
const bound = Bound.deserialize(this.model.xywh);
|
|
||||||
|
|
||||||
const scaledX = bound.x * zoom;
|
|
||||||
const scaledY = bound.y * zoom;
|
|
||||||
const deltaX = scaledX - bound.x;
|
|
||||||
const deltaY = scaledY - bound.y;
|
|
||||||
|
|
||||||
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -8,15 +9,14 @@ export default defineConfig({
|
|||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headless: true,
|
headless: true,
|
||||||
name: 'chromium',
|
instances: [{ browser: 'chromium' }],
|
||||||
provider: 'playwright',
|
provider: playwright(),
|
||||||
isolate: false,
|
isolate: false,
|
||||||
providerOptions: {},
|
|
||||||
},
|
},
|
||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul',
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/std',
|
reportsDirectory: '../../../.coverage/std',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash.clonedeep": "^4.5.9",
|
"@types/lodash.clonedeep": "^4.5.9",
|
||||||
"@types/lodash.merge": "^4.6.9",
|
"@types/lodash.merge": "^4.6.9",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
|||||||
@@ -7,15 +7,11 @@ export * from './transformer';
|
|||||||
export { type IdGenerator, nanoid, uuidv4 } from './utils/id-generator';
|
export { type IdGenerator, nanoid, uuidv4 } from './utils/id-generator';
|
||||||
export * from './yjs';
|
export * from './yjs';
|
||||||
|
|
||||||
const env = (
|
const env = (typeof globalThis !== 'undefined'
|
||||||
typeof globalThis !== 'undefined'
|
? globalThis
|
||||||
? globalThis
|
: typeof window !== 'undefined'
|
||||||
: typeof window !== 'undefined'
|
? window
|
||||||
? window
|
: {}) as unknown as Record<string, boolean>;
|
||||||
: typeof global !== 'undefined'
|
|
||||||
? global
|
|
||||||
: {}
|
|
||||||
) as Record<string, boolean>;
|
|
||||||
const importIdentifier = '__ $BLOCKSUITE_STORE$ __';
|
const importIdentifier = '__ $BLOCKSUITE_STORE$ __';
|
||||||
|
|
||||||
if (env[importIdentifier] === true) {
|
if (env[importIdentifier] === true) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul',
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/store',
|
reportsDirectory: '../../../.coverage/store',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"y-protocols": "^1.0.6"
|
"y-protocols": "^1.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"yjs": "*"
|
"yjs": "*"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
testTimeout: 500,
|
testTimeout: 500,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'istanbul', // or 'c8'
|
provider: 'istanbul',
|
||||||
reporter: ['lcov'],
|
reporter: ['lcov'],
|
||||||
reportsDirectory: '../../../.coverage/sync',
|
reportsDirectory: '../../../.coverage/sync',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test:unit": "vitest --browser.headless --run",
|
"test:unit": "vitest --browser.headless --run",
|
||||||
"test:debug": "PWDEBUG=1 npx vitest"
|
"test:debug": "PWDEBUG=1 npx vitest --browser.headless=false"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"@blocksuite/icons": "^2.2.17",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@lit/context": "^1.1.3",
|
"@lit/context": "^1.1.3",
|
||||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
"@lottiefiles/dotlottie-wc": "^0.9.4",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@toeverything/theme": "^1.1.23",
|
"@toeverything/theme": "^1.1.23",
|
||||||
"@vanilla-extract/css": "^1.17.0",
|
"@vanilla-extract/css": "^1.17.0",
|
||||||
@@ -41,10 +41,12 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||||
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"playwright": "=1.58.2",
|
||||||
"vite": "^7.2.7",
|
"vite": "^7.2.7",
|
||||||
"vite-plugin-istanbul": "^7.2.1",
|
"vite-plugin-istanbul": "^7.2.1",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"version": "0.26.3"
|
"version": "0.26.3"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -6,6 +6,7 @@ import type {
|
|||||||
import { ungroupCommand } from '@blocksuite/affine/gfx/group';
|
import { ungroupCommand } from '@blocksuite/affine/gfx/group';
|
||||||
import type {
|
import type {
|
||||||
GroupElementModel,
|
GroupElementModel,
|
||||||
|
MindmapElementModel,
|
||||||
NoteBlockModel,
|
NoteBlockModel,
|
||||||
} from '@blocksuite/affine/model';
|
} from '@blocksuite/affine/model';
|
||||||
import { generateKeyBetween } from '@blocksuite/affine/std/gfx';
|
import { generateKeyBetween } from '@blocksuite/affine/std/gfx';
|
||||||
@@ -253,6 +254,40 @@ test('blocks should rerender when their z-index changed', async () => {
|
|||||||
assertBlocksContent();
|
assertBlocksContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('block host z-index should update after reordering', async () => {
|
||||||
|
const backId = addNote(doc);
|
||||||
|
const frontId = addNote(doc);
|
||||||
|
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const getBlockHost = (id: string) =>
|
||||||
|
document.querySelector<HTMLElement>(
|
||||||
|
`affine-edgeless-root gfx-viewport > [data-block-id="${id}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
const backHost = getBlockHost(backId);
|
||||||
|
const frontHost = getBlockHost(frontId);
|
||||||
|
|
||||||
|
expect(backHost).not.toBeNull();
|
||||||
|
expect(frontHost).not.toBeNull();
|
||||||
|
expect(Number(backHost!.style.zIndex)).toBeLessThan(
|
||||||
|
Number(frontHost!.style.zIndex)
|
||||||
|
);
|
||||||
|
|
||||||
|
service.crud.updateElement(backId, {
|
||||||
|
index: service.layer.getReorderedIndex(
|
||||||
|
service.crud.getElementById(backId)!,
|
||||||
|
'front'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(Number(backHost!.style.zIndex)).toBeGreaterThan(
|
||||||
|
Number(frontHost!.style.zIndex)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('layer reorder functionality', () => {
|
describe('layer reorder functionality', () => {
|
||||||
let ids: string[] = [];
|
let ids: string[] = [];
|
||||||
|
|
||||||
@@ -428,14 +463,17 @@ describe('group related functionality', () => {
|
|||||||
const elements = [
|
const elements = [
|
||||||
service.crud.addElement('shape', {
|
service.crud.addElement('shape', {
|
||||||
shapeType: 'rect',
|
shapeType: 'rect',
|
||||||
|
xywh: '[0,0,100,100]',
|
||||||
})!,
|
})!,
|
||||||
addNote(doc),
|
addNote(doc),
|
||||||
service.crud.addElement('shape', {
|
service.crud.addElement('shape', {
|
||||||
shapeType: 'rect',
|
shapeType: 'rect',
|
||||||
|
xywh: '[120,0,100,100]',
|
||||||
})!,
|
})!,
|
||||||
addNote(doc),
|
addNote(doc),
|
||||||
service.crud.addElement('shape', {
|
service.crud.addElement('shape', {
|
||||||
shapeType: 'rect',
|
shapeType: 'rect',
|
||||||
|
xywh: '[240,0,100,100]',
|
||||||
})!,
|
})!,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -528,6 +566,35 @@ describe('group related functionality', () => {
|
|||||||
expect(service.layer.layers[1].elements[0]).toBe(group);
|
expect(service.layer.layers[1].elements[0]).toBe(group);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("change mindmap index should update its nodes' layer", async () => {
|
||||||
|
const noteId = addNote(doc);
|
||||||
|
const mindmapId = service.crud.addElement('mindmap', {
|
||||||
|
children: {
|
||||||
|
text: 'root',
|
||||||
|
children: [{ text: 'child' }],
|
||||||
|
},
|
||||||
|
})!;
|
||||||
|
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const note = service.crud.getElementById(noteId)!;
|
||||||
|
const mindmap = service.crud.getElementById(
|
||||||
|
mindmapId
|
||||||
|
)! as MindmapElementModel;
|
||||||
|
const root = mindmap.tree.element;
|
||||||
|
|
||||||
|
expect(service.layer.getZIndex(root)).toBeGreaterThan(
|
||||||
|
service.layer.getZIndex(note)
|
||||||
|
);
|
||||||
|
|
||||||
|
mindmap.index = service.layer.getReorderedIndex(mindmap, 'back');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(service.layer.getZIndex(root)).toBeLessThan(
|
||||||
|
service.layer.getZIndex(note)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('should keep relative index order of elements after group, ungroup, undo, redo', () => {
|
test('should keep relative index order of elements after group, ungroup, undo, redo', () => {
|
||||||
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
|
||||||
const elementIds = [
|
const elementIds = [
|
||||||
@@ -769,6 +836,7 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
|
|||||||
|
|
||||||
service.crud.addElement('shape', {
|
service.crud.addElement('shape', {
|
||||||
shapeType: 'rect',
|
shapeType: 'rect',
|
||||||
|
xywh: '[0,0,100,100]',
|
||||||
})!;
|
})!;
|
||||||
|
|
||||||
addNote(doc);
|
addNote(doc);
|
||||||
@@ -777,6 +845,7 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
|
|||||||
|
|
||||||
service.crud.addElement('shape', {
|
service.crud.addElement('shape', {
|
||||||
shapeType: 'rect',
|
shapeType: 'rect',
|
||||||
|
xywh: '[120,0,100,100]',
|
||||||
})!;
|
})!;
|
||||||
|
|
||||||
editor.mode = 'page';
|
editor.mode = 'page';
|
||||||
@@ -792,10 +861,10 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
|
|||||||
'.indexable-canvas'
|
'.indexable-canvas'
|
||||||
)[0] as HTMLCanvasElement;
|
)[0] as HTMLCanvasElement;
|
||||||
|
|
||||||
expect(indexedCanvas.width).toBe(
|
expect(indexedCanvas.width).toBeLessThanOrEqual(
|
||||||
(surface.renderer as CanvasRenderer).canvas.width
|
(surface.renderer as CanvasRenderer).canvas.width
|
||||||
);
|
);
|
||||||
expect(indexedCanvas.height).toBe(
|
expect(indexedCanvas.height).toBeLessThanOrEqual(
|
||||||
(surface.renderer as CanvasRenderer).canvas.height
|
(surface.renderer as CanvasRenderer).canvas.height
|
||||||
);
|
);
|
||||||
expect(indexedCanvas.width).not.toBe(0);
|
expect(indexedCanvas.width).not.toBe(0);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ConnectorElementModel,
|
ConnectorElementModel,
|
||||||
GroupElementModel,
|
GroupElementModel,
|
||||||
} from '@blocksuite/affine/model';
|
} from '@blocksuite/affine/model';
|
||||||
|
import { serializeXYWH } from '@blocksuite/global/gfx';
|
||||||
import { beforeEach, describe, expect, test } from 'vitest';
|
import { beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { wait } from '../utils/common.js';
|
import { wait } from '../utils/common.js';
|
||||||
@@ -138,6 +139,29 @@ describe('group', () => {
|
|||||||
|
|
||||||
expect(group.childIds).toEqual([id]);
|
expect(group.childIds).toEqual([id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('group xywh should update when child xywh changes', () => {
|
||||||
|
const shapeId = model.addElement({
|
||||||
|
type: 'shape',
|
||||||
|
xywh: serializeXYWH(0, 0, 100, 100),
|
||||||
|
});
|
||||||
|
const groupId = model.addElement({
|
||||||
|
type: 'group',
|
||||||
|
children: {
|
||||||
|
[shapeId]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const group = model.getElementById(groupId) as GroupElementModel;
|
||||||
|
|
||||||
|
expect(group.xywh).toBe(serializeXYWH(0, 0, 100, 100));
|
||||||
|
|
||||||
|
model.updateElement(shapeId, {
|
||||||
|
xywh: serializeXYWH(50, 60, 100, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.xywh).toBe(serializeXYWH(50, 60, 100, 100));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('connector', () => {
|
describe('connector', () => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |