mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf06722b7 | |||
| 925c95ce88 | |||
| 3098b3b14b | |||
| dd1cd77ca0 | |||
| d20dbfd6a2 | |||
| 41145961f9 | |||
| 1f2119e273 | |||
| 6e97aff7ba | |||
| 276b0db625 | |||
| bac346f304 | |||
| 9f33d37add | |||
| 3e42bbf4fa | |||
| b5e5f0708a | |||
| f96bf3dd24 | |||
| c53457691d | |||
| 103ad2a810 | |||
| ef4939009f | |||
| 0f5778ac89 | |||
| e9ef3c50c8 | |||
| 661d5d3831 | |||
| 6f55548661 | |||
| c39fa1ff2d | |||
| 3416de1e4d | |||
| d9cebdfc95 | |||
| 97d9ae3183 | |||
| c8cdc488db | |||
| 542da0b347 | |||
| 7280fe33bc | |||
| f626dbd590 | |||
| 419fc5d5e0 | |||
| 1201f7c350 | |||
| 4b4def3a11 | |||
| 2b22fe4692 | |||
| 659072183c | |||
| e222f06e94 | |||
| 322f2ba986 | |||
| f19a922793 | |||
| a1d150a748 | |||
| ac6d0d35af | |||
| 6b720206c6 | |||
| 76d57aa389 | |||
| db0ff0a9df | |||
| 8cf00738c2 | |||
| 417d31cabe | |||
| fcc45a3f44 | |||
| bcbde16c04 | |||
| 32a94d68dc | |||
| 5813e7dd77 | |||
| ac37d07e74 | |||
| 429e7f495d | |||
| 339f89220a | |||
| 440ff0c342 | |||
| eb9cc22502 | |||
| 4e169ea5c7 | |||
| 9e412f58ec | |||
| 5d234ad6a8 | |||
| 1ad088398f | |||
| 74d5ebad13 | |||
| a1800cf8b2 | |||
| fa66139230 | |||
| 027d163921 | |||
| 39abb936b8 | |||
| 9751cab16c | |||
| 5e97e67ecd | |||
| 7046ad7bf4 | |||
| e90e3e537c | |||
| d64f368623 | |||
| fa8f1a096c | |||
| fb6291cb15 | |||
| 694158eea3 | |||
| 207bd9387e | |||
| 78a9942f19 | |||
| 0ccfacbc29 | |||
| bf6fc66943 | |||
| df482c9cf2 | |||
| 2caf3c86f8 | |||
| 557b1e4dfc | |||
| cc79fa3c6d | |||
| 3428ac478e | |||
| 0009f91d2a | |||
| f7d0f1d5ae | |||
| 0849b342fa | |||
| dc3b95c886 | |||
| 1d66e7e8ca | |||
| c5b0057778 | |||
| a109f069b0 |
@@ -135,17 +135,17 @@
|
||||
},
|
||||
"throttlers.default": {
|
||||
"type": "object",
|
||||
"description": "The config for the default throttler.\n@default {\"ttl\":60,\"limit\":120}",
|
||||
"description": "The config for the default throttler.\n@default {\"ttl\":60000,\"limit\":120}",
|
||||
"default": {
|
||||
"ttl": 60,
|
||||
"ttl": 60000,
|
||||
"limit": 120
|
||||
}
|
||||
},
|
||||
"throttlers.strict": {
|
||||
"type": "object",
|
||||
"description": "The config for the strict throttler.\n@default {\"ttl\":60,\"limit\":20}",
|
||||
"description": "The config for the strict throttler.\n@default {\"ttl\":60000,\"limit\":20}",
|
||||
"default": {
|
||||
"ttl": 60,
|
||||
"ttl": 60000,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"description": "Configuration for permission module",
|
||||
"properties": {
|
||||
"readModel": {
|
||||
"type": "string",
|
||||
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
|
||||
"default": "projection"
|
||||
},
|
||||
"fallbackLegacyLoader": {
|
||||
"type": "boolean",
|
||||
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"type": "object",
|
||||
"description": "Configuration for storages module",
|
||||
@@ -353,7 +369,7 @@
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
|
||||
"description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
@@ -420,10 +436,6 @@
|
||||
"type": "object",
|
||||
"description": "The config for the S3 compatible storage provider.",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
|
||||
@@ -473,6 +485,13 @@
|
||||
"type": "string",
|
||||
"description": "The account id for the cloudflare r2 storage provider."
|
||||
},
|
||||
"jurisdiction": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eu"
|
||||
],
|
||||
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
||||
},
|
||||
"usePresignedURL": {
|
||||
"type": "object",
|
||||
"description": "The presigned url config for the cloudflare r2 storage provider.",
|
||||
@@ -548,7 +567,7 @@
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
|
||||
"description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
@@ -615,10 +634,6 @@
|
||||
"type": "object",
|
||||
"description": "The config for the S3 compatible storage provider.",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
|
||||
@@ -668,6 +683,13 @@
|
||||
"type": "string",
|
||||
"description": "The account id for the cloudflare r2 storage provider."
|
||||
},
|
||||
"jurisdiction": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eu"
|
||||
],
|
||||
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
||||
},
|
||||
"usePresignedURL": {
|
||||
"type": "object",
|
||||
"description": "The presigned url config for the cloudflare r2 storage provider.",
|
||||
@@ -855,11 +877,14 @@
|
||||
"properties": {
|
||||
"google": {
|
||||
"type": "object",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"allowNewAccounts\":true,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"allowNewAccounts": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"clientId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -878,6 +903,7 @@
|
||||
},
|
||||
"default": {
|
||||
"enabled": false,
|
||||
"allowNewAccounts": true,
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"externalWebhookUrl": "",
|
||||
@@ -985,23 +1011,25 @@
|
||||
"description": "Whether to enable the copilot plugin. <br> Document: <a href=\"https://docs.affine.pro/self-host-affine/administer/ai\" target=\"_blank\">https://docs.affine.pro/self-host-affine/administer/ai</a>\n@default false",
|
||||
"default": false
|
||||
},
|
||||
"scenarios": {
|
||||
"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\",\"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"byok.enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable workspace BYOK.\n@default true",
|
||||
"default": true
|
||||
},
|
||||
"byok.allowedProviders": {
|
||||
"type": "array",
|
||||
"description": "The allowlist for workspace BYOK providers.\n@default [\"openai\",\"anthropic\",\"gemini\",\"fal\"]",
|
||||
"default": [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"fal"
|
||||
]
|
||||
},
|
||||
"byok.allowCustomEndpoint": {
|
||||
"type": "boolean",
|
||||
"description": "Whether workspace BYOK custom endpoints are accepted.\n@default false",
|
||||
"default": false
|
||||
},
|
||||
"providers.profiles": {
|
||||
"type": "array",
|
||||
@@ -1079,13 +1107,6 @@
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"providers.perplexity": {
|
||||
"type": "object",
|
||||
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\"}",
|
||||
"default": {
|
||||
"apiKey": ""
|
||||
}
|
||||
},
|
||||
"providers.anthropic": {
|
||||
"type": "object",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.anthropic.com/v1\"}",
|
||||
@@ -1129,11 +1150,6 @@
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"providers.morph": {
|
||||
"type": "object",
|
||||
"description": "The config for the morph provider.\n@default {}",
|
||||
"default": {}
|
||||
},
|
||||
"unsplash": {
|
||||
"type": "object",
|
||||
"description": "The config for the unsplash key.\n@default {\"key\":\"\"}",
|
||||
@@ -1192,7 +1208,7 @@
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
|
||||
"description": "The S3 compatible endpoint (used by aws-s3 provider). Optional; if omitted, endpoint is derived from region."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
@@ -1259,10 +1275,6 @@
|
||||
"type": "object",
|
||||
"description": "The config for the S3 compatible storage provider.",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "The S3 compatible endpoint. Example: \"https://s3.us-east-1.amazonaws.com\" or \"https://<account>.r2.cloudflarestorage.com\"."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The region for the storage provider. Example: \"us-east-1\" or \"auto\" for R2."
|
||||
@@ -1312,6 +1324,13 @@
|
||||
"type": "string",
|
||||
"description": "The account id for the cloudflare r2 storage provider."
|
||||
},
|
||||
"jurisdiction": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eu"
|
||||
],
|
||||
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
|
||||
},
|
||||
"usePresignedURL": {
|
||||
"type": "object",
|
||||
"description": "The presigned url config for the cloudflare r2 storage provider.",
|
||||
|
||||
@@ -114,13 +114,20 @@ jobs:
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
- name: Run i18n codegen
|
||||
run: yarn affine @affine/i18n build
|
||||
run: |
|
||||
yarn affine @affine/i18n build
|
||||
git checkout packages/frontend/i18n/src/i18n-completenesses.json
|
||||
if git status --porcelain | grep -q .; then
|
||||
echo "Run 'yarn affine @affine/i18n build' and make sure all generated i18n changes are submitted"
|
||||
exit 1
|
||||
else
|
||||
echo "All generated i18n changes are submitted"
|
||||
fi
|
||||
- name: Run Type Check
|
||||
run: yarn typecheck
|
||||
- name: Run BS Docs Build
|
||||
run: |
|
||||
yarn affine bs-docs build
|
||||
git checkout packages/frontend/i18n/src/i18n-completenesses.json
|
||||
if git status --porcelain | grep -q .; then
|
||||
echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted"
|
||||
exit 1
|
||||
|
||||
+3
-1
@@ -23,7 +23,7 @@
|
||||
".github/helm",
|
||||
".git",
|
||||
".vscode",
|
||||
".context/**/*.js",
|
||||
".context",
|
||||
".yarnrc.yml",
|
||||
".docker",
|
||||
"**/.storybook",
|
||||
@@ -52,6 +52,8 @@
|
||||
"packages/frontend/apps/ios/App/**",
|
||||
"tests/blocksuite/snapshots",
|
||||
"blocksuite/docs/api/**",
|
||||
"blocksuite/docs-site/.vitepress/.temp/**",
|
||||
"blocksuite/docs-site/api/**",
|
||||
"packages/frontend/admin/src/config.json",
|
||||
"**/test-docs.json",
|
||||
"**/test-blocks.json"
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@
|
||||
.github/helm
|
||||
.git
|
||||
.vscode
|
||||
.context/**/*.js
|
||||
.context
|
||||
.yarnrc.yml
|
||||
.docker
|
||||
**/.storybook
|
||||
@@ -39,6 +39,8 @@ packages/frontend/apps/android/App/**
|
||||
packages/frontend/apps/ios/App/**
|
||||
tests/blocksuite/snapshots
|
||||
blocksuite/docs/api/**
|
||||
blocksuite/docs-site/.vitepress/.temp/**
|
||||
blocksuite/docs-site/api/**
|
||||
packages/frontend/admin/src/config.json
|
||||
**/test-docs.json
|
||||
**/test-blocks.json
|
||||
|
||||
Generated
+922
-40
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -16,6 +16,7 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
affine_common = { path = "./packages/common/native" }
|
||||
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
|
||||
ahash = "0.8"
|
||||
@@ -39,6 +40,7 @@ resolver = "3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
hex = "0.4"
|
||||
homedir = "0.3"
|
||||
image = { version = "0.25.9", default-features = false, features = [
|
||||
"bmp",
|
||||
@@ -53,7 +55,8 @@ resolver = "3"
|
||||
libc = "0.2"
|
||||
libwebp-sys = "0.14.2"
|
||||
little_exif = "0.6.23"
|
||||
llm_adapter = { version = "0.1.4", default-features = false }
|
||||
llm_adapter = { version = "0.2", default-features = false }
|
||||
llm_runtime = { version = "0.2", default-features = false }
|
||||
log = "0.4"
|
||||
loom = { version = "0.7", features = ["checkpoint"] }
|
||||
lru = "0.16"
|
||||
@@ -79,6 +82,7 @@ resolver = "3"
|
||||
ogg = "0.9"
|
||||
once_cell = "1"
|
||||
ordered-float = "5"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
parking_lot = "0.12"
|
||||
path-ext = "0.1.2"
|
||||
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
|
||||
@@ -93,9 +97,11 @@ resolver = "3"
|
||||
readability = { version = "0.3.0", default-features = false }
|
||||
regex = "1.10"
|
||||
rubato = "0.16"
|
||||
schemars = "0.8"
|
||||
screencapturekit = "0.3"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sha3 = "0.10"
|
||||
smol_str = "0.3"
|
||||
sqlx = { version = "0.8", default-features = false, features = [
|
||||
@@ -104,7 +110,6 @@ resolver = "3"
|
||||
"migrate",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"tls-rustls",
|
||||
] }
|
||||
strum_macros = "0.27.0"
|
||||
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
|
||||
@@ -165,3 +170,7 @@ strip = "symbols"
|
||||
# android uniffi bindgen requires symbols
|
||||
[profile.release.package.affine_mobile_native]
|
||||
strip = "none"
|
||||
|
||||
# [patch.crates-io]
|
||||
# llm_adapter = { path = "../llm_adapter/crates/llm_adapter" }
|
||||
# llm_runtime = { path = "../llm_adapter/crates/llm_runtime" }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { bilibiliConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/bilibili.js';
|
||||
import { excalidrawConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/excalidraw.js';
|
||||
import { genericConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/generic.js';
|
||||
import { googleDocsConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-docs.js';
|
||||
import { googleDriveConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-drive.js';
|
||||
import { miroConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/miro.js';
|
||||
import { spotifyConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/spotify.js';
|
||||
|
||||
describe('embed iframe provider config', () => {
|
||||
test('validates final iframe URLs from oEmbed providers', () => {
|
||||
expect(
|
||||
spotifyConfig.validateIframeUrl?.(
|
||||
'https://open.spotify.com/embed/track/0TK2YIli7K1leLovkQiNik'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
spotifyConfig.validateIframeUrl?.(
|
||||
'https://example.com/embed/track/0TK2YIli7K1leLovkQiNik'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('validates provider-specific iframe URL shapes', () => {
|
||||
expect(
|
||||
googleDriveConfig.validateIframeUrl?.(
|
||||
'https://drive.google.com/file/d/file-id/preview?usp=embed_googleplus'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
googleDriveConfig.validateIframeUrl?.(
|
||||
'https://drive.google.com/drive/folders/folder-id?usp=sharing'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
bilibiliConfig.validateIframeUrl?.(
|
||||
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
bilibiliConfig.match(
|
||||
'https://player.bilibili.com/player.html?aid=123&autoplay=0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
bilibiliConfig.buildOEmbedUrl(
|
||||
'https://player.bilibili.com/video/BV1xx411c7mD'
|
||||
)
|
||||
).toBe(
|
||||
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
|
||||
);
|
||||
expect(
|
||||
bilibiliConfig.validateIframeUrl?.(
|
||||
'https://www.bilibili.com/video/BV1xx411c7mD'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
googleDocsConfig.validateIframeUrl?.(
|
||||
'https://docs.google.com/document/d/doc-id/edit?usp=sharing'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
miroConfig.validateIframeUrl?.(
|
||||
'https://miro.com/app/live-embed/board-id/'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
excalidrawConfig.validateIframeUrl?.('https://excalidraw.com/#room-id')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('generic iframe validation excludes affine and non-https URLs', () => {
|
||||
expect(genericConfig.validateIframeUrl?.('https://example.com/embed')).toBe(
|
||||
true
|
||||
);
|
||||
expect(genericConfig.validateIframeUrl?.('http://example.com/embed')).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
genericConfig.validateIframeUrl?.('https://app.affine.pro/embed')
|
||||
).toBe(false);
|
||||
expect(genericConfig.validateIframeUrl?.('https://127.0.0.1/embed')).toBe(
|
||||
false
|
||||
);
|
||||
expect(genericConfig.validateIframeUrl?.('https://localhost/embed')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -39,10 +39,7 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
private readonly _loadTheme = async (
|
||||
highlighter: HighlighterCore
|
||||
): Promise<void> => {
|
||||
// It is possible that by the time the highlighter is ready all instances
|
||||
// have already been unmounted. In that case there is no need to load
|
||||
// themes or update state.
|
||||
if (CodeBlockHighlighter._refCount === 0) {
|
||||
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,7 +48,17 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
|
||||
this._darkThemeKey = (await normalizeGetter(darkTheme)).name;
|
||||
this._lightThemeKey = (await normalizeGetter(lightTheme)).name;
|
||||
|
||||
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await highlighter.loadTheme(darkTheme, lightTheme);
|
||||
|
||||
if (!CodeBlockHighlighter._isHighlighterInUse(highlighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.highlighter$.value = highlighter;
|
||||
};
|
||||
|
||||
@@ -83,30 +90,18 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
}
|
||||
|
||||
override unmounted(): void {
|
||||
CodeBlockHighlighter._refCount--;
|
||||
CodeBlockHighlighter._refCount = Math.max(
|
||||
0,
|
||||
CodeBlockHighlighter._refCount - 1
|
||||
);
|
||||
this.highlighter$.value = null;
|
||||
}
|
||||
|
||||
// Dispose the shared highlighter **after** any in-flight creation finishes.
|
||||
if (CodeBlockHighlighter._refCount !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doDispose = (highlighter: HighlighterCore | null) => {
|
||||
if (highlighter) {
|
||||
highlighter.dispose();
|
||||
}
|
||||
CodeBlockHighlighter._sharedHighlighter = null;
|
||||
CodeBlockHighlighter._highlighterPromise = null;
|
||||
};
|
||||
|
||||
if (CodeBlockHighlighter._sharedHighlighter) {
|
||||
// Highlighter already created – dispose immediately.
|
||||
doDispose(CodeBlockHighlighter._sharedHighlighter);
|
||||
} else if (CodeBlockHighlighter._highlighterPromise) {
|
||||
// Highlighter still being created – wait for it, then dispose.
|
||||
CodeBlockHighlighter._highlighterPromise
|
||||
.then(doDispose)
|
||||
.catch(console.error);
|
||||
}
|
||||
private static _isHighlighterInUse(highlighter: HighlighterCore) {
|
||||
return (
|
||||
CodeBlockHighlighter._refCount > 0 &&
|
||||
CodeBlockHighlighter._sharedHighlighter === highlighter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
return modelPreview;
|
||||
});
|
||||
|
||||
collapsed$: Signal<boolean> = computed(
|
||||
() => !!this.model.props.collapsed$.value
|
||||
);
|
||||
|
||||
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
||||
|
||||
languageName$: Signal<string> = computed(() => {
|
||||
@@ -417,6 +421,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
|
||||
);
|
||||
const shouldRenderPreview = preview && previewContext;
|
||||
const collapsed = this.collapsed$.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -426,6 +431,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
mobile: IS_MOBILE,
|
||||
wrap: this.model.props.wrap,
|
||||
'disable-line-numbers': !showLineNumbers,
|
||||
collapsed,
|
||||
})}
|
||||
>
|
||||
<rich-text
|
||||
@@ -453,9 +459,12 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
}}
|
||||
>
|
||||
</rich-text>
|
||||
${collapsed
|
||||
? html`<div class="code-collapsed-fade" aria-hidden="true"></div>`
|
||||
: nothing}
|
||||
<div
|
||||
style=${styleMap({
|
||||
display: shouldRenderPreview ? undefined : 'none',
|
||||
display: shouldRenderPreview && !collapsed ? undefined : 'none',
|
||||
})}
|
||||
contenteditable="false"
|
||||
class="affine-code-block-preview"
|
||||
@@ -471,6 +480,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
||||
this.store.updateBlock(this.model, { wrap });
|
||||
}
|
||||
|
||||
setCollapsed(collapsed: boolean) {
|
||||
this.store.updateBlock(this.model, { collapsed });
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
@@ -108,6 +109,17 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
||||
this.closeCurrentMenu();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Mirror the collapsed$ signal from the block component into local @state
|
||||
// so this LitElement re-renders when it changes.
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this._collapsed = this.context.blockComponent.collapsed$.value;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-toolbar class="code-toolbar-container" data-without-bg>
|
||||
@@ -136,6 +148,9 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
||||
@state()
|
||||
private accessor _moreMenuOpen = false;
|
||||
|
||||
@state()
|
||||
private accessor _collapsed = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: CodeBlockToolbarContext;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
CancelWrapIcon,
|
||||
CaptionIcon,
|
||||
CollapseCodeIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ExpandCodeIcon,
|
||||
WrapIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
@@ -85,6 +87,38 @@ export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'collapse',
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
blockComponent.setCollapsed(!blockComponent.collapsed$.value);
|
||||
},
|
||||
render: item => {
|
||||
const collapsed = blockComponent.collapsed$.value;
|
||||
const icon = collapsed ? ExpandCodeIcon : CollapseCodeIcon;
|
||||
const label = collapsed ? 'Expand code' : 'Collapse code';
|
||||
return html`
|
||||
<editor-icon-button
|
||||
class="code-toolbar-button collapse"
|
||||
aria-label=${label}
|
||||
.tooltip=${label}
|
||||
.tooltipOffset=${4}
|
||||
.iconSize=${'16px'}
|
||||
.iconContainerPadding=${4}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
${icon}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'caption',
|
||||
label: 'Caption',
|
||||
|
||||
@@ -80,4 +80,35 @@ export const codeBlockStyles = css`
|
||||
affine-code .affine-code-block-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── Collapsed state ──────────────────────────────────────────────── */
|
||||
|
||||
/* Clamp the rich-text to the first 8 lines */
|
||||
.affine-code-block-container.collapsed rich-text {
|
||||
display: block;
|
||||
max-height: calc(8 * var(--affine-line-height));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Reduce bottom padding so the fade sits flush with the border */
|
||||
.affine-code-block-container.collapsed {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Gradient overlay that fades to the block background */
|
||||
.affine-code-block-container .code-collapsed-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--affine-background-code-block)
|
||||
);
|
||||
border-radius: 0 0 10px 10px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
|
||||
dataSource: this.dataSource,
|
||||
headerWidget: this.headerWidget,
|
||||
clipboard: this.std.clipboard,
|
||||
dnd: this.std.dnd,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
DatabaseKanbanViewIcon,
|
||||
DatabaseTableViewIcon,
|
||||
TodayIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertDatabaseBlockCommand } from '../commands';
|
||||
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Calendar View',
|
||||
description: 'Display items by date in a calendar.',
|
||||
searchAlias: ['database', 'calendar'],
|
||||
icon: TodayIcon(),
|
||||
group: '7_Database@1',
|
||||
when: ({ model }) =>
|
||||
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertDatabaseBlockCommand, {
|
||||
viewType: viewPresets.calendarViewMeta.type,
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedDatabaseBlockId }) => {
|
||||
if (insertedDatabaseBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:database',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Kanban View',
|
||||
description: 'Visualize data in a dashboard.',
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type SingleView,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
|
||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { Rect } from '@blocksuite/global/gfx';
|
||||
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
config
|
||||
);
|
||||
});
|
||||
this.std.provider
|
||||
.getAll(CalendarExternalSourceProvider)
|
||||
.forEach(source => {
|
||||
dataSource.serviceSet(
|
||||
CalendarExternalSourceProvider(source.id),
|
||||
source
|
||||
);
|
||||
});
|
||||
});
|
||||
const id = currentViewStorage.getCurrentView(this.model.id);
|
||||
if (id && dataSource.viewManager.viewGet(id)) {
|
||||
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
calendar: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
});
|
||||
|
||||
private readonly viewSelection$ = computed(() => {
|
||||
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
headerWidget: this.headerWidget,
|
||||
onDrag: this.onDrag,
|
||||
clipboard: this.std.clipboard,
|
||||
dnd: this.std.dnd,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
export const databaseBlockViews: ViewMeta[] = [
|
||||
viewPresets.tableViewMeta,
|
||||
viewPresets.kanbanViewMeta,
|
||||
viewPresets.calendarViewMeta,
|
||||
];
|
||||
|
||||
export const databaseBlockViewMap = Object.fromEntries(
|
||||
|
||||
@@ -43,6 +43,11 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
|
||||
font-weight: var(--edgeless-text-font-weight);
|
||||
text-align: var(--edgeless-text-text-align);
|
||||
}
|
||||
|
||||
.edgeless-text-block-container .locked-content a[href] {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _resizeObserver = new ResizeObserver(() => {
|
||||
@@ -304,6 +309,7 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
|
||||
style=${styleMap(containerStyle)}
|
||||
>
|
||||
<div
|
||||
class=${!editing && this.model.isLocked() ? 'locked-content' : ''}
|
||||
style=${styleMap({
|
||||
pointerEvents: editing ? 'auto' : 'none',
|
||||
userSelect: editing ? 'auto' : 'none',
|
||||
|
||||
+22
-4
@@ -35,7 +35,7 @@ const extractBvid = (url: string) => {
|
||||
|
||||
const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
// If the user pasted the embed URL directly, keep it
|
||||
if (validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
if (isValidBiliPlayerUrl(url)) {
|
||||
return url;
|
||||
}
|
||||
const avid = extractAvid(url);
|
||||
@@ -57,13 +57,31 @@ const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const bilibiliConfig = {
|
||||
function isValidBiliPlayerUrl(url: string) {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
return (
|
||||
parsedUrl.pathname === '/player.html' &&
|
||||
(!!parsedUrl.searchParams.get('aid') ||
|
||||
!!parsedUrl.searchParams.get('bvid'))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const bilibiliConfig = {
|
||||
name: 'bilibili',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url)),
|
||||
isValidBiliPlayerUrl(url) ||
|
||||
(validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url))),
|
||||
buildOEmbedUrl: buildBiliPlayerEmbedUrl,
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidBiliPlayerUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: BILIBILI_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: BILIBILI_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@ const excalidrawUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['excalidraw.com'],
|
||||
};
|
||||
|
||||
const excalidrawConfig = {
|
||||
export const excalidrawConfig = {
|
||||
name: 'excalidraw',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, excalidrawUrlValidationOptions),
|
||||
@@ -27,6 +27,8 @@ const excalidrawConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
validateEmbedIframeUrl(iframeUrl, excalidrawUrlValidationOptions),
|
||||
options: {
|
||||
widthInSurface: EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
|
||||
@@ -17,6 +22,11 @@ const AFFINE_DOMAINS = [
|
||||
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||
];
|
||||
|
||||
const genericUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a URL is suitable for generic iframe embedding
|
||||
* Allows HTTPS URLs but excludes AFFiNE domains
|
||||
@@ -27,8 +37,12 @@ function isValidGenericEmbedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Only allow HTTPS for security
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
if (
|
||||
!validateEmbedIframeUrl(url, {
|
||||
...genericUrlValidationOptions,
|
||||
hostnames: [parsedUrl.hostname],
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -49,7 +63,7 @@ function isValidGenericEmbedUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const genericConfig = {
|
||||
export const genericConfig = {
|
||||
name: 'generic',
|
||||
match: (url: string) => isValidGenericEmbedUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -59,6 +73,7 @@ const genericConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGenericEmbedUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+2
-1
@@ -57,7 +57,7 @@ function isValidGoogleDocsUrl(url: string, strictMode = true): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const googleDocsConfig = {
|
||||
export const googleDocsConfig = {
|
||||
name: 'google-docs',
|
||||
match: (url: string) => isValidGoogleDocsUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -67,6 +67,7 @@ const googleDocsConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGoogleDocsUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+26
-1
@@ -113,6 +113,29 @@ function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidGoogleDriveIframeUrl(url: string): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return pathSegments[3] === 'preview';
|
||||
}
|
||||
|
||||
return (
|
||||
parsedUrl.pathname === '/embeddedfolderview' &&
|
||||
!!parsedUrl.searchParams.get('id')
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Invalid Google Drive iframe URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive files
|
||||
* @param fileId File ID
|
||||
@@ -171,7 +194,7 @@ function buildGoogleDriveEmbedUrl(url: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const googleDriveConfig = {
|
||||
export const googleDriveConfig = {
|
||||
name: 'google-drive',
|
||||
match: (url: string) => isValidGoogleDriveUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -183,6 +206,8 @@ const googleDriveConfig = {
|
||||
return buildGoogleDriveEmbedUrl(url);
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
isValidGoogleDriveIframeUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -18,7 +18,7 @@ const miroUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['miro.com'],
|
||||
};
|
||||
|
||||
const miroConfig = {
|
||||
export const miroConfig = {
|
||||
name: 'miro',
|
||||
match: (url: string) => validateEmbedIframeUrl(url, miroUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -31,6 +31,12 @@ const miroConfig = {
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, miroUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
return new URL(iframeUrl).pathname.startsWith('/app/live-embed/');
|
||||
},
|
||||
options: {
|
||||
widthInSurface: MIRO_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: MIRO_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -18,7 +18,12 @@ const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['open.spotify.com', 'spotify.link'],
|
||||
};
|
||||
|
||||
const spotifyConfig = {
|
||||
const spotifyIframeUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['open.spotify.com'],
|
||||
};
|
||||
|
||||
export const spotifyConfig = {
|
||||
name: 'spotify',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
|
||||
@@ -32,6 +37,13 @@ const spotifyConfig = {
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, spotifyIframeUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(iframeUrl);
|
||||
return parsedUrl.pathname.split('/').find(Boolean) === 'embed';
|
||||
},
|
||||
options: {
|
||||
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -141,7 +141,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(link, '_blank');
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
refreshData = async () => {
|
||||
@@ -183,6 +183,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
// update model
|
||||
const iframeUrl = this._getIframeUrl(embedData) ?? currentIframeUrl;
|
||||
if (!this._validateIframeUrl(url, iframeUrl)) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Invalid embed iframe url'
|
||||
);
|
||||
}
|
||||
this.store.updateBlock(this.model, {
|
||||
iframeUrl,
|
||||
title: embedData?.title || previewData?.title,
|
||||
@@ -291,6 +297,19 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _validateIframeUrl = (url: string, iframeUrl?: string) => {
|
||||
if (!iframeUrl) {
|
||||
return false;
|
||||
}
|
||||
const config = this.embedIframeService?.getConfig(url);
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
return config.validateIframeUrl
|
||||
? config.validateIframeUrl(iframeUrl, url)
|
||||
: config.match(iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _handleDoubleClick = () => {
|
||||
this.open();
|
||||
};
|
||||
@@ -329,6 +348,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
private readonly _renderIframe = () => {
|
||||
const { iframeUrl } = this.model.props;
|
||||
if (!iframeUrl || !this._isIframeUrlAllowed(iframeUrl)) {
|
||||
return html`<embed-iframe-error-card
|
||||
.error=${new Error('Invalid iframe URL')}
|
||||
.model=${this.model}
|
||||
.onRetry=${this._handleRetry}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-error-card>`;
|
||||
}
|
||||
const {
|
||||
widthPercent,
|
||||
heightInNote,
|
||||
@@ -368,6 +397,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
: nothing}`;
|
||||
};
|
||||
|
||||
private readonly _isIframeUrlAllowed = (iframeUrl: string) => {
|
||||
return this._validateIframeUrl(this.model.props.url, iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _getSourceHost = () => {
|
||||
const url = this.model.props.url ?? this.model.props.iframeUrl;
|
||||
if (!url) return null;
|
||||
@@ -437,7 +470,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
} else {
|
||||
// update iframe options, to ensure the iframe is rendered with the correct options
|
||||
this._updateIframeOptions(this.model.props.url);
|
||||
this.status$.value = 'success';
|
||||
this.status$.value = this._validateIframeUrl(
|
||||
this.model.props.url,
|
||||
this.model.props.iframeUrl
|
||||
)
|
||||
? 'success'
|
||||
: 'error';
|
||||
}
|
||||
|
||||
// refresh data when original url changes
|
||||
|
||||
@@ -9,6 +9,25 @@ export interface EmbedIframeUrlValidationOptions {
|
||||
hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
|
||||
}
|
||||
|
||||
function isLocalOrIpHostname(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
lower.endsWith('.localhost') ||
|
||||
lower === '0.0.0.0' ||
|
||||
lower === '::' ||
|
||||
lower === '::1'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lower.startsWith('[') && lower.endsWith(']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the url is allowed to embed in the iframe
|
||||
* @param url URL to validate
|
||||
@@ -23,6 +42,15 @@ export function validateEmbedIframeUrl(
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
const { protocols, hostnames } = options;
|
||||
if (
|
||||
parsedUrl.username ||
|
||||
parsedUrl.password ||
|
||||
parsedUrl.port ||
|
||||
isLocalOrIpHostname(parsedUrl.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
protocols.includes(parsedUrl.protocol) &&
|
||||
hostnames.includes(parsedUrl.hostname)
|
||||
|
||||
@@ -9,7 +9,7 @@ export const latexBlockStyles = css`
|
||||
height: 100%;
|
||||
padding: 10px 24px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -121,6 +121,38 @@ export const updateBlockType: Command<
|
||||
}
|
||||
return next({ updatedBlocks: [newModel] });
|
||||
};
|
||||
const transformToLatex: Command<{}, { updatedBlocks: BlockModel[] }> = (
|
||||
_,
|
||||
next
|
||||
) => {
|
||||
if (flavour !== 'affine:latex') return;
|
||||
|
||||
const newModels: BlockModel[] = [];
|
||||
blockModels.forEach(model => {
|
||||
if (
|
||||
!matchModels(model, [
|
||||
ParagraphBlockModel,
|
||||
ListBlockModel,
|
||||
CodeBlockModel,
|
||||
])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latex = model.text?.toString() ?? '';
|
||||
const newId = transformModel(model, 'affine:latex', { latex });
|
||||
if (!newId) {
|
||||
return;
|
||||
}
|
||||
const newModel = doc.getModelById(newId);
|
||||
if (newModel) {
|
||||
newModels.push(newModel);
|
||||
}
|
||||
});
|
||||
|
||||
if (newModels.length === 0) return;
|
||||
return next({ updatedBlocks: newModels });
|
||||
};
|
||||
|
||||
const focusText: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => {
|
||||
const { updatedBlocks } = ctx;
|
||||
@@ -185,6 +217,27 @@ export const updateBlockType: Command<
|
||||
});
|
||||
return next();
|
||||
};
|
||||
const selectBlocks: Command<{ updatedBlocks: BlockModel[] }> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const { updatedBlocks } = ctx;
|
||||
if (!updatedBlocks || updatedBlocks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
host.selection.setGroup(
|
||||
'note',
|
||||
updatedBlocks.map(model =>
|
||||
host.selection.create(BlockSelection, {
|
||||
blockId: model.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
return next();
|
||||
};
|
||||
|
||||
const [result, resultCtx] = std.command
|
||||
.chain()
|
||||
@@ -196,6 +249,7 @@ export const updateBlockType: Command<
|
||||
.try<{ updatedBlocks: BlockModel[] }>(chain => [
|
||||
chain.pipe(mergeToCode),
|
||||
chain.pipe(appendDivider),
|
||||
chain.pipe(transformToLatex),
|
||||
chain.pipe((_, next) => {
|
||||
const newModels: BlockModel[] = [];
|
||||
blockModels.forEach(model => {
|
||||
@@ -227,6 +281,14 @@ export const updateBlockType: Command<
|
||||
])
|
||||
// focus
|
||||
.try(chain => [
|
||||
chain
|
||||
.pipe((_, next) => {
|
||||
if (flavour === 'affine:latex') {
|
||||
return next();
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.pipe(selectBlocks),
|
||||
chain.pipe((_, next) => {
|
||||
if (['affine:code', 'affine:divider'].includes(flavour)) {
|
||||
return next();
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@blocksuite/affine-gfx-pointer": "workspace:*",
|
||||
"@blocksuite/affine-gfx-shape": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-inline-latex": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
|
||||
import {
|
||||
deleteTextCommand,
|
||||
formatBlockCommand,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
LinkedPageIcon,
|
||||
TeXIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
@@ -199,9 +201,9 @@ const alignActionGroup = {
|
||||
const inlineTextActionGroup = {
|
||||
id: 'b.inline-text',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
actions: textFormatConfigs.map(
|
||||
actions: textFormatConfigs.flatMap(
|
||||
({ id, name, action, activeWhen, icon }, score) => {
|
||||
return {
|
||||
const textAction: ToolbarAction = {
|
||||
id,
|
||||
icon,
|
||||
score,
|
||||
@@ -209,6 +211,28 @@ const inlineTextActionGroup = {
|
||||
run: ({ host }) => action(host),
|
||||
active: ({ host }) => activeWhen(host),
|
||||
};
|
||||
|
||||
if (id !== 'underline') {
|
||||
return [textAction];
|
||||
}
|
||||
|
||||
return [
|
||||
textAction,
|
||||
{
|
||||
id: 'inline-latex',
|
||||
icon: TeXIcon(),
|
||||
score: score + 0.5,
|
||||
tooltip: 'Inline Equation',
|
||||
run: ({ host }) => {
|
||||
host.std.command
|
||||
.chain()
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(insertInlineLatex)
|
||||
.run();
|
||||
},
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
),
|
||||
} as const satisfies ToolbarActionGroup;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{ "path": "../../gfx/pointer" },
|
||||
{ "path": "../../gfx/shape" },
|
||||
{ "path": "../../gfx/text" },
|
||||
{ "path": "../../inlines/latex" },
|
||||
{ "path": "../../inlines/preset" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
|
||||
@@ -168,6 +168,8 @@ export class DomRenderer {
|
||||
pendingUpdates: new Map(),
|
||||
};
|
||||
|
||||
private readonly _pendingElements = new Map<string, SurfaceElementModel>();
|
||||
|
||||
private _lastViewportBounds: Bound | null = null;
|
||||
private _lastZoom: number | null = null;
|
||||
private _lastUsePlaceholder: boolean = false;
|
||||
@@ -184,6 +186,8 @@ export class DomRenderer {
|
||||
|
||||
provider: Partial<EnvProvider>;
|
||||
|
||||
private readonly _surfaceModel: SurfaceBlockModel;
|
||||
|
||||
usePlaceholder = false;
|
||||
|
||||
viewport: Viewport;
|
||||
@@ -204,6 +208,7 @@ export class DomRenderer {
|
||||
this.layerManager = options.layerManager;
|
||||
this.grid = options.gridManager;
|
||||
this.provider = options.provider ?? {};
|
||||
this._surfaceModel = options.surfaceModel;
|
||||
|
||||
this._turboEnabled = () => {
|
||||
const featureFlagService = options.std.get(FeatureFlagService);
|
||||
@@ -367,7 +372,11 @@ export class DomRenderer {
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementAdded.subscribe(payload => {
|
||||
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
|
||||
this._markElementDirty(
|
||||
payload.id,
|
||||
UpdateType.ELEMENT_ADDED,
|
||||
payload as unknown as SurfaceElementModel
|
||||
);
|
||||
this._markViewportDirty();
|
||||
this.refresh();
|
||||
})
|
||||
@@ -381,7 +390,11 @@ export class DomRenderer {
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementUpdated.subscribe(payload => {
|
||||
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
|
||||
this._markElementDirty(
|
||||
payload.model.id,
|
||||
UpdateType.ELEMENT_UPDATED,
|
||||
payload.model as unknown as SurfaceElementModel
|
||||
);
|
||||
if (payload.props['index'] || payload.props['groupId']) {
|
||||
this._markViewportDirty();
|
||||
}
|
||||
@@ -522,8 +535,22 @@ export class DomRenderer {
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
private _markElementDirty(elementId: string, updateType: UpdateType) {
|
||||
private _markElementDirty(
|
||||
elementId: string,
|
||||
updateType: UpdateType,
|
||||
elementModel?: SurfaceElementModel
|
||||
) {
|
||||
this._updateState.dirtyElementIds.add(elementId);
|
||||
if (updateType === UpdateType.ELEMENT_REMOVED) {
|
||||
this._pendingElements.delete(elementId);
|
||||
} else {
|
||||
const model =
|
||||
elementModel ?? this._surfaceModel.getElementById(elementId);
|
||||
if (model) {
|
||||
this._pendingElements.set(elementId, model as SurfaceElementModel);
|
||||
}
|
||||
}
|
||||
|
||||
const currentUpdates =
|
||||
this._updateState.pendingUpdates.get(elementId) || [];
|
||||
if (!currentUpdates.includes(updateType)) {
|
||||
@@ -572,6 +599,51 @@ export class DomRenderer {
|
||||
return this._lastUsePlaceholder !== this.usePlaceholder;
|
||||
}
|
||||
|
||||
private _elementInViewport(
|
||||
elementModel: SurfaceElementModel,
|
||||
viewportBounds: Bound
|
||||
) {
|
||||
const display = (elementModel.display ?? true) && !elementModel.hidden;
|
||||
return (
|
||||
display && intersects(getBoundWithRotation(elementModel), viewportBounds)
|
||||
);
|
||||
}
|
||||
|
||||
private _getPendingElementsInViewport(viewportBounds: Bound) {
|
||||
const elements: SurfaceElementModel[] = [];
|
||||
|
||||
for (const [id, elementModel] of this._pendingElements) {
|
||||
this._pendingElements.delete(id);
|
||||
if (this._elementInViewport(elementModel, viewportBounds)) {
|
||||
elements.push(elementModel);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _getElementsInViewport(viewportBounds: Bound) {
|
||||
const elements = this.grid.search(viewportBounds, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[];
|
||||
|
||||
const elementsById = new Map<string, SurfaceElementModel>();
|
||||
for (const elementModel of elements) {
|
||||
if (this._elementInViewport(elementModel, viewportBounds)) {
|
||||
elementsById.set(elementModel.id, elementModel);
|
||||
this._pendingElements.delete(elementModel.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const elementModel of this._getPendingElementsInViewport(
|
||||
viewportBounds
|
||||
)) {
|
||||
elementsById.set(elementModel.id, elementModel);
|
||||
}
|
||||
|
||||
return Array.from(elementsById.values());
|
||||
}
|
||||
|
||||
private _updateLastState() {
|
||||
const { viewportBounds, zoom } = this.viewport;
|
||||
this._lastViewportBounds = {
|
||||
@@ -604,41 +676,33 @@ export class DomRenderer {
|
||||
}
|
||||
|
||||
// Only update dirty elements
|
||||
const elementsFromGrid = this.grid.search(viewportBounds, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[];
|
||||
const elementsInViewport = this._getElementsInViewport(viewportBounds);
|
||||
|
||||
const visibleElementIds = new Set<string>();
|
||||
|
||||
// 1. Update dirty elements
|
||||
for (const elementModel of elementsFromGrid) {
|
||||
const display = (elementModel.display ?? true) && !elementModel.hidden;
|
||||
if (
|
||||
display &&
|
||||
intersects(getBoundWithRotation(elementModel), viewportBounds)
|
||||
) {
|
||||
visibleElementIds.add(elementModel.id);
|
||||
for (const elementModel of elementsInViewport) {
|
||||
visibleElementIds.add(elementModel.id);
|
||||
|
||||
// Only update dirty elements
|
||||
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
!(elementModel as GfxCompatibleInterface).forceFullRender
|
||||
) {
|
||||
this._renderOrUpdatePlaceholder(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
} else {
|
||||
this._renderOrUpdateFullElement(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
}
|
||||
// Only update dirty elements
|
||||
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
!(elementModel as GfxCompatibleInterface).forceFullRender
|
||||
) {
|
||||
this._renderOrUpdatePlaceholder(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
} else {
|
||||
this._renderOrUpdateFullElement(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,59 +741,32 @@ export class DomRenderer {
|
||||
const addedElements: HTMLElement[] = [];
|
||||
const elementsToRemove: HTMLElement[] = [];
|
||||
|
||||
// Step 1: Handle elements whose models are deleted from the surface
|
||||
const prevRenderedElementIds = Array.from(this._elementsMap.keys());
|
||||
for (const id of prevRenderedElementIds) {
|
||||
const modelExists = this.layerManager.layers.some(layer =>
|
||||
layer.elements.some(elem => (elem as SurfaceElementModel).id === id)
|
||||
);
|
||||
if (!modelExists) {
|
||||
const domElem = this._elementsMap.get(id);
|
||||
if (domElem) {
|
||||
domElem.remove();
|
||||
this._elementsMap.delete(id);
|
||||
elementsToRemove.push(domElem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Render elements in the current viewport
|
||||
const elementsFromGrid = this.grid.search(viewportBounds, {
|
||||
filter: ['canvas', 'local'],
|
||||
}) as SurfaceElementModel[];
|
||||
const elementsInViewport = this._getElementsInViewport(viewportBounds);
|
||||
const visibleElementIds = new Set<string>();
|
||||
|
||||
for (const elementModel of elementsFromGrid) {
|
||||
const display = (elementModel.display ?? true) && !elementModel.hidden;
|
||||
if (
|
||||
display &&
|
||||
intersects(getBoundWithRotation(elementModel), viewportBounds)
|
||||
) {
|
||||
visibleElementIds.add(elementModel.id);
|
||||
for (const elementModel of elementsInViewport) {
|
||||
visibleElementIds.add(elementModel.id);
|
||||
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
!(elementModel as GfxCompatibleInterface).forceFullRender
|
||||
) {
|
||||
this._renderOrUpdatePlaceholder(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
} else {
|
||||
// Full render
|
||||
this._renderOrUpdateFullElement(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.usePlaceholder &&
|
||||
!(elementModel as GfxCompatibleInterface).forceFullRender
|
||||
) {
|
||||
this._renderOrUpdatePlaceholder(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
} else {
|
||||
this._renderOrUpdateFullElement(
|
||||
elementModel,
|
||||
viewportBounds,
|
||||
zoom,
|
||||
addedElements
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Remove DOM elements that are in _elementsMap but were not processed in Step 2
|
||||
const currentRenderedElementIds = Array.from(this._elementsMap.keys());
|
||||
for (const id of currentRenderedElementIds) {
|
||||
if (!visibleElementIds.has(id)) {
|
||||
@@ -744,7 +781,6 @@ export class DomRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Notify about changes
|
||||
if (addedElements.length > 0 || elementsToRemove.length > 0) {
|
||||
this.elementsUpdated.next({
|
||||
elements: Array.from(this._elementsMap.values()),
|
||||
|
||||
@@ -33,6 +33,22 @@ export class SelectionController implements ReactiveController {
|
||||
this.host.handleEvent('copy', this.onCopy);
|
||||
this.host.handleEvent('cut', this.onCut);
|
||||
this.host.handleEvent('paste', this.onPaste);
|
||||
this.host.handleEvent('dragStart', context => {
|
||||
if (IS_MOBILE || this.dataManager.readonly$.value) return false;
|
||||
const event = context.get('pointerState').raw;
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest(
|
||||
'[data-width-adjust-column-id], [data-drag-column-id], [data-drag-row-id]'
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
private get dataManager() {
|
||||
return this.host.dataManager;
|
||||
@@ -84,6 +100,17 @@ export class SelectionController implements ReactiveController {
|
||||
if (IS_MOBILE || this.dataManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
this.host.disposables.addFromEvent(this.host, 'pointerdown', event => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (
|
||||
target.closest(
|
||||
'[data-width-adjust-column-id], [data-drag-column-id], [data-drag-row-id]'
|
||||
)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
this.host.disposables.addFromEvent(this.host, 'mousedown', event => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
|
||||
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.inputRef.select();
|
||||
if (!this.data.disableAutoFocus) {
|
||||
this.inputRef.select();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -223,6 +225,7 @@ export const menuInputItems = {
|
||||
onComplete?: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
disableAutoFocus?: boolean;
|
||||
class?: string;
|
||||
style?: Readonly<StyleInfo>;
|
||||
}) =>
|
||||
@@ -237,6 +240,7 @@ export const menuInputItems = {
|
||||
onComplete: config.onComplete,
|
||||
onChange: config.onChange,
|
||||
onBlur: config.onBlur,
|
||||
disableAutoFocus: config.disableAutoFocus,
|
||||
};
|
||||
const style = styleMap({
|
||||
display: 'flex',
|
||||
|
||||
@@ -111,8 +111,10 @@ export class MenuComponent
|
||||
}
|
||||
const onBack = this.menu.options.title?.onBack;
|
||||
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
|
||||
this.menu.close();
|
||||
onBack(this.menu);
|
||||
const result = onBack(this.menu);
|
||||
if (result !== false) {
|
||||
this.menu.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
@@ -214,8 +216,10 @@ export class MenuComponent
|
||||
${title.onBack
|
||||
? html` <div
|
||||
@click="${() => {
|
||||
title.onBack?.(this.menu);
|
||||
this.menu.close();
|
||||
const result = title.onBack?.(this.menu);
|
||||
if (result !== false) {
|
||||
this.menu.close();
|
||||
}
|
||||
}}"
|
||||
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
|
||||
style="display:flex;"
|
||||
@@ -555,6 +559,7 @@ export const popMenu = (
|
||||
],
|
||||
}),
|
||||
offset(4),
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
container: props.container,
|
||||
placement: props.placement,
|
||||
|
||||
@@ -15,7 +15,7 @@ export type MenuOptions = {
|
||||
onClose?: () => void;
|
||||
title?: {
|
||||
text: string;
|
||||
onBack?: (menu: Menu) => void;
|
||||
onBack?: (menu: Menu) => boolean | void;
|
||||
onClose?: () => void;
|
||||
postfix?: () => TemplateResult;
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ export class DatePicker extends WithDisposable(LitElement) {
|
||||
|
||||
private readonly _maxYear = 2099;
|
||||
|
||||
private readonly _minYear = 1970;
|
||||
private readonly _minYear = 1000;
|
||||
|
||||
get _cardStyle() {
|
||||
return {
|
||||
@@ -286,8 +286,18 @@ export class DatePicker extends WithDisposable(LitElement) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _clampCursorYear() {
|
||||
const year = this._cursor.getFullYear();
|
||||
if (year < this._minYear) {
|
||||
this._cursor = new Date(this._minYear, 0, 1);
|
||||
} else if (year > this._maxYear) {
|
||||
this._cursor = new Date(this._maxYear, 11, 31);
|
||||
}
|
||||
}
|
||||
|
||||
private _moveMonth(offset: number) {
|
||||
this._cursor.setMonth(this._cursor.getMonth() + offset);
|
||||
this._clampCursorYear();
|
||||
this._getMatrix();
|
||||
}
|
||||
|
||||
@@ -420,6 +430,7 @@ export class DatePicker extends WithDisposable(LitElement) {
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
this._cursor.setDate(this._cursor.getDate() + 7);
|
||||
}
|
||||
this._clampCursorYear();
|
||||
this._getMatrix();
|
||||
setTimeout(this.focusDateCell.bind(this));
|
||||
}
|
||||
|
||||
@@ -265,6 +265,16 @@ export const CancelWrapIcon = icons.CancelWrapIcon({
|
||||
height: '20',
|
||||
});
|
||||
|
||||
export const CollapseCodeIcon = icons.CollapseIcon({
|
||||
width: '20',
|
||||
height: '20',
|
||||
});
|
||||
|
||||
export const ExpandCodeIcon = icons.ToggleRightIcon({
|
||||
width: '20',
|
||||
height: '20',
|
||||
});
|
||||
|
||||
// Attachment
|
||||
|
||||
export const ViewIcon = icons.ViewIcon({
|
||||
|
||||
@@ -24,8 +24,8 @@ const styles = css`
|
||||
font-size: var(--affine-font-sm);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
color: var(--affine-white);
|
||||
background: var(--affine-tooltip);
|
||||
color: var(--affine-v2-tooltips-foreground, var(--affine-white));
|
||||
background: var(--affine-v2-tooltips-background, var(--affine-tooltip));
|
||||
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
@@ -40,6 +40,9 @@ const styles = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const TOOLTIP_ARROW_COLOR =
|
||||
'var(--affine-v2-tooltips-background, var(--affine-tooltip))';
|
||||
|
||||
// See http://apps.eky.hk/css-triangle-generator/
|
||||
const TRIANGLE_HEIGHT = 6;
|
||||
const triangleMap = {
|
||||
@@ -47,25 +50,25 @@ const triangleMap = {
|
||||
bottom: '-6px',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '6px 5px 0 5px',
|
||||
borderColor: 'var(--affine-tooltip) transparent transparent transparent',
|
||||
borderColor: `${TOOLTIP_ARROW_COLOR} transparent transparent transparent`,
|
||||
},
|
||||
right: {
|
||||
left: '-6px',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '5px 6px 5px 0',
|
||||
borderColor: 'transparent var(--affine-tooltip) transparent transparent',
|
||||
borderColor: `transparent ${TOOLTIP_ARROW_COLOR} transparent transparent`,
|
||||
},
|
||||
bottom: {
|
||||
top: '-6px',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '0 5px 6px 5px',
|
||||
borderColor: 'transparent transparent var(--affine-tooltip) transparent',
|
||||
borderColor: `transparent transparent ${TOOLTIP_ARROW_COLOR} transparent`,
|
||||
},
|
||||
left: {
|
||||
right: '-6px',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '5px 0 5px 6px',
|
||||
borderColor: 'transparent transparent transparent var(--affine-tooltip)',
|
||||
borderColor: `transparent transparent transparent ${TOOLTIP_ARROW_COLOR}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type CalendarEntry,
|
||||
createCalendarMonthLayout,
|
||||
getCalendarDayContentSlots,
|
||||
getCalendarVisibleMonthRange,
|
||||
} from '../view-presets/calendar/index.js';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||
|
||||
describe('calendar month layout', () => {
|
||||
it('buckets single day entries', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Task',
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('splits range external entries across weeks', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Trip',
|
||||
startAt: day('2026-05-09'),
|
||||
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 1, startIndex: 6, span: 1 },
|
||||
{ weekIndex: 2, startIndex: 0, span: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats all-day external midnight end as exclusive', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'All day',
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: day('2026-05-16'),
|
||||
allDay: true,
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('treats row midnight end date as inclusive', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Task',
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: day('2026-05-16'),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 2, startIndex: 5, span: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clips range entries to visible month range', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Long trip',
|
||||
startAt: day('2026-04-01'),
|
||||
endAt: day('2026-06-30'),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments[0]).toMatchObject({
|
||||
weekIndex: 0,
|
||||
startIndex: 0,
|
||||
span: 7,
|
||||
});
|
||||
expect(layout.segments.at(-1)).toMatchObject({
|
||||
weekIndex: layout.weeks.length - 1,
|
||||
startIndex: 0,
|
||||
span: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('pads month view to full weeks', () => {
|
||||
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [],
|
||||
});
|
||||
|
||||
expect(new Date(range.from).getDay()).toBe(0);
|
||||
expect(new Date(range.to).getDay()).toBe(6);
|
||||
expect(layout.days).toHaveLength(layout.weeks.length * 7);
|
||||
});
|
||||
|
||||
it('keeps day buckets on local midnight across DST boundaries', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'DST task',
|
||||
startAt: day('2026-03-09'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-03-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.every(item => {
|
||||
const date = new Date(item.date);
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
);
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-03-09'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('keeps range segment offsets across DST boundaries', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'DST range',
|
||||
startAt: day('2026-03-09'),
|
||||
endAt: new Date('2026-03-10T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-03-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 1, startIndex: 1, span: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps all same-day entries in the day bucket', () => {
|
||||
const entries = Array.from(
|
||||
{ length: 4 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'row',
|
||||
id: `database:row-${index}`,
|
||||
sourceId: 'database',
|
||||
rowId: `row-${index}`,
|
||||
title: `Task ${index}`,
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
}) satisfies CalendarEntry
|
||||
);
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('assigns each overlapping range segment to its own slot', () => {
|
||||
const entries: CalendarEntry[] = [
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'external',
|
||||
id: `external:full-${index}`,
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: `full-${index}`,
|
||||
title: `Full ${index}`,
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: new Date('2026-05-17T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
}) as const
|
||||
),
|
||||
{
|
||||
kind: 'external',
|
||||
id: 'external:short',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: 'short',
|
||||
title: 'Short',
|
||||
startAt: day('2026-05-18'),
|
||||
endAt: new Date('2026-05-19T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
},
|
||||
];
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
|
||||
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
|
||||
|
||||
expect(getCalendarDayContentSlots(may15)).toBe(3);
|
||||
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
|
||||
expect(getCalendarDayContentSlots(may18)).toBe(1);
|
||||
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('counts segment and same-day slots for drag preview placement', () => {
|
||||
const entries: CalendarEntry[] = [
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'external',
|
||||
id: `external:range-${index}`,
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: `range-${index}`,
|
||||
title: `Range ${index}`,
|
||||
startAt: day('2026-05-08'),
|
||||
endAt: new Date('2026-05-09T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
}) as const
|
||||
),
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:moving',
|
||||
sourceId: 'database',
|
||||
rowId: 'moving',
|
||||
title: 'Moving',
|
||||
startAt: day('2026-05-06'),
|
||||
endAt: new Date('2026-05-08T12:00:00').getTime(),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
},
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:single',
|
||||
sourceId: 'database',
|
||||
rowId: 'single',
|
||||
title: 'Single',
|
||||
startAt: day('2026-05-08'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
},
|
||||
];
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
|
||||
|
||||
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
|
||||
});
|
||||
|
||||
it('splits row range entries across weeks with continuation metadata', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Project',
|
||||
startAt: day('2026-05-09'),
|
||||
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{
|
||||
weekIndex: 1,
|
||||
startIndex: 6,
|
||||
span: 1,
|
||||
startsBeforeWeek: false,
|
||||
endsAfterWeek: true,
|
||||
},
|
||||
{
|
||||
weekIndex: 2,
|
||||
startIndex: 0,
|
||||
span: 3,
|
||||
startsBeforeWeek: true,
|
||||
endsAfterWeek: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips range entries completely outside the visible month range', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:outside',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: 'outside',
|
||||
title: 'Outside',
|
||||
startAt: day('2026-06-10'),
|
||||
endAt: day('2026-06-12'),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toEqual([]);
|
||||
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,812 @@
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DataSource } from '../core/data-source/base.js';
|
||||
import {
|
||||
CalendarSingleView,
|
||||
type CalendarStoredViewData,
|
||||
calendarViewModel,
|
||||
} from '../view-presets/calendar/index.js';
|
||||
import {
|
||||
formatEntryTime,
|
||||
openCalendarEntry,
|
||||
} from '../view-presets/calendar/pc/actions.js';
|
||||
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
|
||||
import { viewConverts } from '../view-presets/convert.js';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||
|
||||
const createCalendarView = (options?: {
|
||||
startColumnId?: string;
|
||||
endColumnId?: string;
|
||||
datePropertyType?: string;
|
||||
rows?: string[];
|
||||
filterValue?: string;
|
||||
titleValue?: unknown;
|
||||
linkedDocTitles?: Record<string, string>;
|
||||
visiblePropertyIds?: string[];
|
||||
externalFactories?: Map<unknown, unknown>;
|
||||
}) => {
|
||||
const rows = signal(options?.rows ?? ['row-1']);
|
||||
const columns = signal(['title', 'date', 'end-date', 'status']);
|
||||
const viewData = signal<CalendarStoredViewData>({
|
||||
id: 'view-1',
|
||||
name: 'Calendar',
|
||||
mode: 'calendar',
|
||||
filter: options?.filterValue
|
||||
? {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: { type: 'ref', name: 'status' },
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: options.filterValue }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
date: {
|
||||
startColumnId: options?.startColumnId,
|
||||
endColumnId: options?.endColumnId,
|
||||
},
|
||||
card: {
|
||||
titleColumnId: 'title',
|
||||
visiblePropertyIds: options?.visiblePropertyIds ?? [],
|
||||
},
|
||||
sources: {
|
||||
workspaceCalendar: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const values = new Map<string, unknown>([
|
||||
['row-1:date', day('2026-05-15')],
|
||||
['row-1:end-date', day('2026-05-17')],
|
||||
['row-1:status', 'Done'],
|
||||
['row-1:title', options?.titleValue ?? 'Task'],
|
||||
['row-2:date', day('2026-05-16')],
|
||||
['row-2:end-date', day('2026-05-14')],
|
||||
['row-2:status', 'Todo'],
|
||||
['row-2:title', 'Hidden'],
|
||||
]);
|
||||
const types = new Map<string, string>([
|
||||
['title', 'title'],
|
||||
['date', options?.datePropertyType ?? 'date'],
|
||||
['end-date', 'date'],
|
||||
['status', 'text'],
|
||||
]);
|
||||
|
||||
const dataSource = {
|
||||
rows$: rows,
|
||||
properties$: columns,
|
||||
readonly$: signal(false),
|
||||
featureFlags$: signal({ enable_table_virtual_scroll: false }),
|
||||
provider: {
|
||||
getAll: () => options?.externalFactories ?? new Map(),
|
||||
},
|
||||
viewDataGet: () => viewData.value,
|
||||
viewDataUpdate: (
|
||||
_id: string,
|
||||
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
|
||||
) => {
|
||||
viewData.value = { ...viewData.value, ...updater(viewData.value) };
|
||||
},
|
||||
cellValueGet: (rowId: string, propertyId: string) =>
|
||||
values.get(`${rowId}:${propertyId}`),
|
||||
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
|
||||
values.set(`${rowId}:${propertyId}`, value);
|
||||
},
|
||||
rowAdd: () => {
|
||||
const rowId = `row-${rows.value.length + 1}`;
|
||||
rows.value = [...rows.value, rowId];
|
||||
return rowId;
|
||||
},
|
||||
propertyTypeGet: (propertyId: string) => types.get(propertyId),
|
||||
propertyNameGet: (propertyId: string) => propertyId,
|
||||
propertyDataGet: () => ({}),
|
||||
propertyReadonlyGet: () => false,
|
||||
serviceGet: (key: unknown) => {
|
||||
if (key !== DocDisplayMetaProvider) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: (pageId: string, referenceInfo?: { title?: string }) =>
|
||||
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
|
||||
};
|
||||
},
|
||||
propertyMetaGet: (type: string) => ({
|
||||
type,
|
||||
config: {
|
||||
rawValue: {
|
||||
toJson: ({ value }: { value: unknown }) => {
|
||||
const deltas =
|
||||
typeof value === 'object' && value != null && 'deltas$' in value
|
||||
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||
: undefined;
|
||||
if (!Array.isArray(deltas)) {
|
||||
return value;
|
||||
}
|
||||
return deltas
|
||||
.map(delta => {
|
||||
const item = delta as {
|
||||
insert?: unknown;
|
||||
attributes?: {
|
||||
reference?: {
|
||||
type?: string;
|
||||
pageId?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const pageId = item.attributes?.reference?.pageId;
|
||||
if (
|
||||
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||
typeof pageId === 'string'
|
||||
) {
|
||||
return (
|
||||
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
|
||||
);
|
||||
}
|
||||
return item.insert ?? '';
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
fromJson: ({ value }: { value: unknown }) => value,
|
||||
toString: ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value : '',
|
||||
},
|
||||
jsonValue: {
|
||||
schema: {
|
||||
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||
},
|
||||
isEmpty: () => false,
|
||||
type: () => undefined,
|
||||
},
|
||||
},
|
||||
renderer: {},
|
||||
}),
|
||||
propertyAdd: () => {
|
||||
columns.value = [...columns.value, 'created-date'];
|
||||
types.set('created-date', 'date');
|
||||
return 'created-date';
|
||||
},
|
||||
propertyCanDelete: () => true,
|
||||
propertyCanDuplicate: () => true,
|
||||
propertyTypeCanSet: () => true,
|
||||
} as unknown as DataSource;
|
||||
const manager = {
|
||||
dataSource,
|
||||
readonly$: signal(false),
|
||||
};
|
||||
return {
|
||||
view: new CalendarSingleView(manager as any, 'view-1'),
|
||||
viewData,
|
||||
values,
|
||||
types,
|
||||
columns,
|
||||
};
|
||||
};
|
||||
|
||||
describe('CalendarSingleView', () => {
|
||||
it('creates default view data without selecting a start date', () => {
|
||||
const data = calendarViewModel.model.defaultData({
|
||||
dataSource: {
|
||||
properties$: signal(['title', 'date']),
|
||||
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(data.date).toEqual({});
|
||||
expect(data.card).toEqual({
|
||||
titleColumnId: 'title',
|
||||
visiblePropertyIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('enters setup state without a start date property', () => {
|
||||
const { view } = createCalendarView();
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('enters setup state when start date column is not date', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
datePropertyType: 'text',
|
||||
});
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('enters setup state after date property deletion', () => {
|
||||
const { view, columns } = createCalendarView({ startColumnId: 'date' });
|
||||
|
||||
columns.value = ['title', 'status'];
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('creates row entries after filtering rows', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
rows: ['row-1', 'row-2'],
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
|
||||
});
|
||||
|
||||
it('updates entry date after row date value changes', () => {
|
||||
const { view, values } = createCalendarView({ startColumnId: 'date' });
|
||||
|
||||
values.set('row-1:date', day('2026-05-20'));
|
||||
|
||||
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
|
||||
});
|
||||
|
||||
it('creates row range entries and falls back when end date is invalid', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
rows: ['row-1', 'row-2'],
|
||||
});
|
||||
|
||||
expect(
|
||||
view.rowEntries$.value.map(entry => [
|
||||
entry.rowId,
|
||||
entry.startAt,
|
||||
entry.endAt,
|
||||
])
|
||||
).toEqual([
|
||||
['row-1', day('2026-05-15'), day('2026-05-17')],
|
||||
['row-2', day('2026-05-16'), undefined],
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
|
||||
});
|
||||
|
||||
it('moves row range while preserving duration', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
|
||||
view.moveRowToDate('row-1', day('2026-05-20'));
|
||||
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
|
||||
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
|
||||
});
|
||||
|
||||
it('resizes row range without crossing start and end', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
|
||||
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
|
||||
|
||||
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
|
||||
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
|
||||
});
|
||||
|
||||
it('creates a row with default filter values and target date', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
const rowId = view.createRowOnDate(day('2026-05-25'));
|
||||
|
||||
expect(rowId).toBe('row-2');
|
||||
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||
expect(values.get('row-2:status')).toBe('Done');
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a dated linked-doc row', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
|
||||
const title = values.get('row-2:title') as
|
||||
| { toDelta?: () => unknown[] }
|
||||
| undefined;
|
||||
|
||||
expect(rowId).toBe('row-2');
|
||||
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||
expect(values.get('row-2:status')).toBe('Done');
|
||||
expect(title?.toDelta?.()).toEqual([
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('dismisses the empty month hint on the current calendar view', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(false);
|
||||
|
||||
view.dismissEmptyMonthHint();
|
||||
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||
expect('ui' in viewData.value && viewData.value.ui).toEqual({
|
||||
emptyMonthHintDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates workspace calendar settings when legacy view data has no sources', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
viewData.value = {
|
||||
...viewData.value,
|
||||
sources: undefined as unknown as CalendarStoredViewData['sources'],
|
||||
};
|
||||
|
||||
view.setWorkspaceCalendarEnabled(false);
|
||||
|
||||
expect(viewData.value.sources.workspaceCalendar).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('enters setup state when legacy view data has no date config', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
viewData.value = {
|
||||
...viewData.value,
|
||||
date: undefined as unknown as CalendarStoredViewData['date'],
|
||||
};
|
||||
|
||||
expect(view.dateMapping$.value).toEqual({
|
||||
status: 'setup',
|
||||
propertyId: undefined,
|
||||
});
|
||||
expect(view.endDateMapping$.value).toEqual({
|
||||
status: 'setup',
|
||||
propertyId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('generates card properties from visible property ids', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
visiblePropertyIds: ['status'],
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
|
||||
{
|
||||
propertyId: 'status',
|
||||
value: 'Done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses single linked doc id from title cell', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Linked doc title',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: 'Doc',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Linked doc title', linkedDoc: true },
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
|
||||
});
|
||||
|
||||
it('uses normal title text for multiple linked doc titles', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Doc 1',
|
||||
'doc-2': 'Doc 2',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: 'Doc 1',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'Doc 2',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Doc 1', linkedDoc: true },
|
||||
{ text: 'Doc 2', linkedDoc: true },
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
|
||||
});
|
||||
|
||||
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Doc 1',
|
||||
'doc-2': 'Doc 2',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Doc 1', linkedDoc: true },
|
||||
{ text: 'Doc 2', linkedDoc: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges linked doc placeholders with the following plain title text', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: { type: 'LinkedPage', pageId: 'doc-1' },
|
||||
},
|
||||
},
|
||||
{ insert: 'How to use folder and Tags' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'How to use folder and Tags', linkedDoc: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates date mapping through setup APIs', () => {
|
||||
const { view, viewData, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
|
||||
view.moveRowToDate('row-1', day('2026-05-21'));
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
|
||||
|
||||
view.setDateColumn('date');
|
||||
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||
'date'
|
||||
);
|
||||
|
||||
expect(view.createDateColumn()).toBe('created-date');
|
||||
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||
'created-date'
|
||||
);
|
||||
});
|
||||
|
||||
it('aggregates external source entries without mutating view data', async () => {
|
||||
const externalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'source',
|
||||
externalId: '1',
|
||||
title: 'External',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const anotherExternalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:2',
|
||||
sourceId: 'another-source',
|
||||
externalId: '2',
|
||||
title: 'Another external',
|
||||
startAt: day('2026-05-16'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries: () => [externalEntry],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
'another-source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'another-source',
|
||||
getEntries: () => Promise.resolve([anotherExternalEntry]),
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const viewDataBefore = JSON.stringify(viewData.value);
|
||||
|
||||
await expect(
|
||||
view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
})
|
||||
).resolves.toEqual([externalEntry, anotherExternalEntry]);
|
||||
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
|
||||
});
|
||||
|
||||
it('keeps successful external entries when another source fails', async () => {
|
||||
const externalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'source',
|
||||
externalId: '1',
|
||||
title: 'External',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries: () => [externalEntry],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
'failing-source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'failing-source',
|
||||
getEntries: () => Promise.reject(new Error('denied')),
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
})
|
||||
).resolves.toEqual([externalEntry]);
|
||||
});
|
||||
|
||||
it('does not let stale external entry loads overwrite newer entries', async () => {
|
||||
const oldEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:old',
|
||||
sourceId: 'source',
|
||||
externalId: 'old',
|
||||
title: 'Old',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const newEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:new',
|
||||
sourceId: 'source',
|
||||
externalId: 'new',
|
||||
title: 'New',
|
||||
startAt: day('2026-06-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
let resolveOld!: (entries: [typeof oldEntry]) => void;
|
||||
let resolveNew!: (entries: [typeof newEntry]) => void;
|
||||
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
const newRequest = new Promise<[typeof newEntry]>(resolve => {
|
||||
resolveNew = resolve;
|
||||
});
|
||||
const getEntries = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(oldRequest)
|
||||
.mockReturnValueOnce(newRequest);
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries,
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const firstLoad = view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
});
|
||||
const secondLoad = view.loadExternalEntries({
|
||||
from: day('2026-06-01'),
|
||||
to: day('2026-06-30'),
|
||||
});
|
||||
|
||||
resolveNew([newEntry]);
|
||||
await expect(secondLoad).resolves.toEqual([newEntry]);
|
||||
expect(
|
||||
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||
).toEqual([newEntry]);
|
||||
|
||||
resolveOld([oldEntry]);
|
||||
await expect(firstLoad).resolves.toEqual([oldEntry]);
|
||||
expect(
|
||||
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||
).toEqual([newEntry]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar entry actions', () => {
|
||||
it('formats external event popover time ranges with end time', () => {
|
||||
const label = formatEntryTime({
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Planning',
|
||||
startAt: new Date('2026-05-15T10:00:00').getTime(),
|
||||
endAt: new Date('2026-05-15T11:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
});
|
||||
|
||||
expect(label).toContain(' - ');
|
||||
expect(label).toContain('2026');
|
||||
});
|
||||
|
||||
it('opens row entries through the detail panel hook', () => {
|
||||
const openDetailPanel = vi.fn();
|
||||
const { view } = createCalendarView({ startColumnId: 'date' });
|
||||
const target = {} as HTMLElement;
|
||||
|
||||
openCalendarEntry(
|
||||
{ openDetailPanel } as any,
|
||||
view,
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Doc',
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
},
|
||||
target
|
||||
);
|
||||
|
||||
expect(openDetailPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view, rowId: 'row-1' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar view converts', () => {
|
||||
it('converts header/card semantics without date mapping', () => {
|
||||
const tableToCalendar = viewConverts.find(
|
||||
convert => convert.from === 'table' && convert.to === 'calendar'
|
||||
);
|
||||
const calendarToKanban = viewConverts.find(
|
||||
convert => convert.from === 'calendar' && convert.to === 'kanban'
|
||||
);
|
||||
const filter = { type: 'group', op: 'and', conditions: [] } as const;
|
||||
const sort = { columns: [] };
|
||||
const header = { titleColumn: 'title' };
|
||||
|
||||
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
|
||||
filter,
|
||||
sort,
|
||||
card: { titleColumnId: 'title', visiblePropertyIds: [] },
|
||||
});
|
||||
expect(
|
||||
calendarToKanban?.convert({
|
||||
filter,
|
||||
sort,
|
||||
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
|
||||
date: { startColumnId: 'date' },
|
||||
} as any)
|
||||
).toEqual({ filter, sort, header });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar dnd payload', () => {
|
||||
it('reads calendar entry payloads from blocksuite dnd data', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({
|
||||
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
|
||||
})
|
||||
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
|
||||
});
|
||||
|
||||
it('normalizes affine doc entities for future document drops', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({
|
||||
entity: { type: 'doc', id: 'doc-1' },
|
||||
})
|
||||
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||
});
|
||||
|
||||
it('reads document payloads from blocksuite dnd data', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
|
||||
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { multiSelectPropertyType } from '../property-presets/multi-select/define.js';
|
||||
import { selectPropertyType } from '../property-presets/select/define.js';
|
||||
import { TableHotkeysController } from '../view-presets/table/pc/controller/hotkeys.js';
|
||||
import { TableHotkeysController as VirtualHotkeysController } from '../view-presets/table/pc-virtual/controller/hotkeys.js';
|
||||
import {
|
||||
@@ -7,6 +9,11 @@ import {
|
||||
TableViewRowSelection,
|
||||
} from '../view-presets/table/selection';
|
||||
|
||||
const TAG_COLUMN_TYPES = [
|
||||
selectPropertyType.type,
|
||||
multiSelectPropertyType.type,
|
||||
] as const;
|
||||
|
||||
function createLogic() {
|
||||
const view = {
|
||||
rowsDelete: vi.fn(),
|
||||
@@ -66,7 +73,10 @@ describe('TableHotkeysController', () => {
|
||||
const cell = {
|
||||
rowId: 'r1',
|
||||
dataset: { rowId: 'r1', columnId: 'c1' },
|
||||
column: { valueSetFromString: vi.fn() },
|
||||
column: {
|
||||
valueSetFromString: vi.fn(),
|
||||
type$: { value: 'text' },
|
||||
},
|
||||
};
|
||||
selectionController.getCellContainer.mockReturnValue(cell);
|
||||
selectionController.selection = TableViewAreaSelection.create({
|
||||
@@ -85,6 +95,41 @@ describe('TableHotkeysController', () => {
|
||||
expect(selectionController.selection.isEditing).toBe(true);
|
||||
expect(evt.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(TAG_COLUMN_TYPES)(
|
||||
'stages draft for %s column instead of valueSetFromString',
|
||||
columnType => {
|
||||
const { logic, selectionController } = createLogic();
|
||||
const ctrl = new TableHotkeysController(logic as any);
|
||||
ctrl.hostConnected();
|
||||
const setTagDraft = vi.fn();
|
||||
const cell = {
|
||||
rowId: 'r1',
|
||||
dataset: { rowId: 'r1', columnId: 'c1' },
|
||||
column: {
|
||||
valueSetFromString: vi.fn(),
|
||||
type$: { value: columnType },
|
||||
},
|
||||
setTagDraft,
|
||||
};
|
||||
selectionController.getCellContainer.mockReturnValue(cell);
|
||||
selectionController.selection = TableViewAreaSelection.create({
|
||||
focus: { rowIndex: 0, columnIndex: 0 },
|
||||
isEditing: false,
|
||||
});
|
||||
const evt = {
|
||||
key: 'C',
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
logic.keyDown({ get: () => ({ raw: evt }) });
|
||||
expect(cell.column.valueSetFromString).not.toHaveBeenCalled();
|
||||
expect(setTagDraft).toHaveBeenCalledWith('C');
|
||||
expect(selectionController.selection.isEditing).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('Virtual TableHotkeysController', () => {
|
||||
@@ -95,7 +140,12 @@ describe('Virtual TableHotkeysController', () => {
|
||||
const cell = {
|
||||
rowId: 'r1',
|
||||
dataset: { rowId: 'r1', columnId: 'c1' },
|
||||
column$: { value: { valueSetFromString: vi.fn() } },
|
||||
column$: {
|
||||
value: {
|
||||
valueSetFromString: vi.fn(),
|
||||
type$: { value: 'text' },
|
||||
},
|
||||
},
|
||||
};
|
||||
selectionController.getCellContainer.mockReturnValue(cell);
|
||||
selectionController.selection = TableViewAreaSelection.create({
|
||||
@@ -117,4 +167,41 @@ describe('Virtual TableHotkeysController', () => {
|
||||
expect(selectionController.selection.isEditing).toBe(true);
|
||||
expect(evt.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(TAG_COLUMN_TYPES)(
|
||||
'stages draft for %s column instead of valueSetFromString',
|
||||
columnType => {
|
||||
const { logic, selectionController } = createLogic();
|
||||
const ctrl = new VirtualHotkeysController(logic as any);
|
||||
ctrl.hostConnected();
|
||||
const setTagDraft = vi.fn();
|
||||
const cell = {
|
||||
rowId: 'r1',
|
||||
dataset: { rowId: 'r1', columnId: 'c1' },
|
||||
column$: {
|
||||
value: {
|
||||
valueSetFromString: vi.fn(),
|
||||
type$: { value: columnType },
|
||||
},
|
||||
},
|
||||
setTagDraft,
|
||||
};
|
||||
selectionController.getCellContainer.mockReturnValue(cell);
|
||||
selectionController.selection = TableViewAreaSelection.create({
|
||||
focus: { rowIndex: 1, columnIndex: 0 },
|
||||
isEditing: false,
|
||||
});
|
||||
const evt = {
|
||||
key: 'C',
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
};
|
||||
logic.keyDown({ get: () => ({ raw: evt }) });
|
||||
expect(cell.column$.value.valueSetFromString).not.toHaveBeenCalled();
|
||||
expect(setTagDraft).toHaveBeenCalledWith('C');
|
||||
expect(selectionController.selection.isEditing).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,8 +69,20 @@ export type TagManagerOptions = {
|
||||
options: ReadonlySignal<SelectTag[]>;
|
||||
onOptionsChange: (options: SelectTag[]) => void;
|
||||
onComplete?: () => void;
|
||||
initialDraftText?: string;
|
||||
};
|
||||
|
||||
// parent elements that can consume tag draft
|
||||
const TABLE_CELL_HOST_SELECTOR =
|
||||
'dv-table-view-cell-container, affine-database-virtual-cell-container';
|
||||
|
||||
export function consumeTagDraftFromTableCellHost(
|
||||
fromElement: Element
|
||||
): string | undefined {
|
||||
const host = fromElement.closest(TABLE_CELL_HOST_SELECTOR) as any;
|
||||
return host?.consumeTagDraft?.();
|
||||
}
|
||||
|
||||
class TagManager {
|
||||
changeTag = (option: Partial<SelectTag>) => {
|
||||
this.ops.onOptionsChange(
|
||||
@@ -427,6 +439,15 @@ export class MultiTagSelect extends SignalWatcher(
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const draft = this.initialDraftText;
|
||||
if (draft != null && draft !== '') {
|
||||
this.tagManager.text$.value = draft;
|
||||
this.initialDraftText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected override firstUpdated() {
|
||||
const disposables = this.disposables;
|
||||
this.classList.add(tagSelectContainerStyle);
|
||||
@@ -471,6 +492,9 @@ export class MultiTagSelect extends SignalWatcher(
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value!: ReadonlySignal<string[]>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor initialDraftText: string | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -481,6 +505,9 @@ declare global {
|
||||
|
||||
const popMobileTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
|
||||
const tagManager = new TagManager(ops);
|
||||
if (ops.initialDraftText) {
|
||||
tagManager.text$.value = ops.initialDraftText;
|
||||
}
|
||||
const onInput = (e: InputEvent) => {
|
||||
tagManager.text$.value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
@@ -604,6 +631,7 @@ export const popTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
|
||||
component.onChange = ops.onChange;
|
||||
component.options = ops.options;
|
||||
component.onOptionsChange = ops.onOptionsChange;
|
||||
component.initialDraftText = ops.initialDraftText;
|
||||
component.onComplete = () => {
|
||||
ops.onComplete?.();
|
||||
remove();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type Clipboard,
|
||||
type DndController,
|
||||
type EventName,
|
||||
ShadowlessElement,
|
||||
type UIEventHandler,
|
||||
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
|
||||
|
||||
export type DataViewRendererConfig = {
|
||||
clipboard: Clipboard;
|
||||
dnd?: DndController;
|
||||
onDrag?: (evt: MouseEvent, id: string) => () => void;
|
||||
notification: {
|
||||
toast: (message: string) => void;
|
||||
|
||||
@@ -2,15 +2,10 @@ import {
|
||||
dropdownSubMenuMiddleware,
|
||||
menu,
|
||||
type MenuConfig,
|
||||
type MenuOptions,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
|
||||
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
export const selectGroupByProperty = (
|
||||
export const buildGroupSelectItems = (
|
||||
group: GroupTrait,
|
||||
ops?: {
|
||||
onSelect?: (id?: string) => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
): MenuOptions => {
|
||||
onSelect: (id?: string) => void
|
||||
): MenuConfig[] => {
|
||||
const view = group.view;
|
||||
return {
|
||||
onClose: ops?.onClose,
|
||||
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
|
||||
items: [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
.filter(property => {
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(view.manager.dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
})
|
||||
.map<MenuConfig>(property => {
|
||||
return menu.action({
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === property.id,
|
||||
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(property.id);
|
||||
ops?.onSelect?.(property.id);
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
return [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
.filter(property => {
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(view.manager.dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
})
|
||||
.map<MenuConfig>(property =>
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === property.id,
|
||||
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
ops?.onSelect?.();
|
||||
group.changeGroup(property.id);
|
||||
onSelect(property.id);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
})
|
||||
),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
onSelect(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export const popSelectGroupByProperty = (
|
||||
target: PopupTarget,
|
||||
export const buildGroupSettingItems = (
|
||||
group: GroupTrait,
|
||||
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
const handler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, ops),
|
||||
middleware,
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
export const popGroupSetting = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
onBack: () => void,
|
||||
onClose?: () => void,
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
onGroupByClick: () => void,
|
||||
onGroupRemoved?: () => void
|
||||
): MenuConfig[] => {
|
||||
const view = group.view;
|
||||
const gProp = group.property$.value;
|
||||
if (!gProp) return;
|
||||
if (!gProp) return [];
|
||||
const type = gProp.type$.value;
|
||||
if (!type) return;
|
||||
|
||||
if (!type) return [];
|
||||
const icon = gProp.icon;
|
||||
const menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Group',
|
||||
onBack,
|
||||
onClose,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const subHandler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, {
|
||||
onSelect: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onBack: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onClose,
|
||||
}),
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
return [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
onGroupByClick();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name === key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
@@ -462,179 +452,118 @@ export const popGroupSetting = (
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config
|
||||
.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name ===
|
||||
key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value
|
||||
? 'Oldest first'
|
||||
: 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menuObj => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menuObj.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menu.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
onGroupRemoved?.();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
middleware,
|
||||
});
|
||||
menuHandler.menu.menuElement.style.minHeight = '550px';
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import { popupTargetFromElement } from '@blocksuite/affine-components/context-me
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { popTagSelect } from '../../core/component/tags/multi-tag-select.js';
|
||||
import {
|
||||
consumeTagDraftFromTableCellHost,
|
||||
popTagSelect,
|
||||
} from '../../core/component/tags/multi-tag-select.js';
|
||||
import type { SelectTag } from '../../core/index.js';
|
||||
import { BaseCellRenderer } from '../../core/property/index.js';
|
||||
import { createFromBaseCellRenderer } from '../../core/property/renderer.js';
|
||||
@@ -19,6 +22,7 @@ export class MultiSelectCell extends BaseCellRenderer<
|
||||
> {
|
||||
closePopup?: () => void;
|
||||
private readonly popTagSelect = () => {
|
||||
const initialDraftText = consumeTagDraftFromTableCellHost(this);
|
||||
this.closePopup = popTagSelect(popupTargetFromElement(this), {
|
||||
name: this.cell.property.name$.value,
|
||||
options: this.options$,
|
||||
@@ -29,6 +33,7 @@ export class MultiSelectCell extends BaseCellRenderer<
|
||||
},
|
||||
onComplete: this._editComplete,
|
||||
minWidth: 400,
|
||||
initialDraftText,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { popupTargetFromElement } from '@blocksuite/affine-components/context-me
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { popTagSelect } from '../../core/component/tags/multi-tag-select.js';
|
||||
import {
|
||||
consumeTagDraftFromTableCellHost,
|
||||
popTagSelect,
|
||||
} from '../../core/component/tags/multi-tag-select.js';
|
||||
import type { SelectTag } from '../../core/index.js';
|
||||
import { BaseCellRenderer } from '../../core/property/index.js';
|
||||
import { createFromBaseCellRenderer } from '../../core/property/renderer.js';
|
||||
@@ -20,6 +23,7 @@ export class SelectCell extends BaseCellRenderer<
|
||||
> {
|
||||
closePopup?: () => void;
|
||||
private readonly popTagSelect = () => {
|
||||
const initialDraftText = consumeTagDraftFromTableCellHost(this);
|
||||
this.closePopup = popTagSelect(popupTargetFromElement(this), {
|
||||
name: this.cell.property.name$.value,
|
||||
mode: 'single',
|
||||
@@ -31,6 +35,7 @@ export class SelectCell extends BaseCellRenderer<
|
||||
},
|
||||
onComplete: this._editComplete,
|
||||
minWidth: 400,
|
||||
initialDraftText,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
|
||||
import { type DeltaInsert, Text } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
import { evalFilter } from '../../core/filter/eval.js';
|
||||
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
|
||||
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import { emptyFilterGroup } from '../../core/filter/utils.js';
|
||||
import { fromJson } from '../../core/property/utils';
|
||||
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||
import { type Row, RowBase } from '../../core/view-manager/row.js';
|
||||
import {
|
||||
type SingleView,
|
||||
SingleViewBase,
|
||||
} from '../../core/view-manager/single-view.js';
|
||||
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import { getCalendarExternalSources } from './source.js';
|
||||
import type {
|
||||
CalendarEntry,
|
||||
CalendarEntryRange,
|
||||
CalendarExternalEntry,
|
||||
CalendarExternalSource,
|
||||
CalendarRowEntry,
|
||||
CalendarStoredViewData,
|
||||
CalendarTitleSegment,
|
||||
} from './types.js';
|
||||
|
||||
export type CalendarDateMapping =
|
||||
| {
|
||||
status: 'ready';
|
||||
propertyId: string;
|
||||
}
|
||||
| {
|
||||
status: 'setup';
|
||||
propertyId?: string;
|
||||
};
|
||||
|
||||
const getStartColumnId = (data?: CalendarStoredViewData) =>
|
||||
data?.date?.startColumnId;
|
||||
|
||||
const getEndColumnId = (data?: CalendarStoredViewData) => {
|
||||
return data?.date?.endColumnId;
|
||||
};
|
||||
|
||||
const getDateData = (data: CalendarStoredViewData) => ({
|
||||
...data.date,
|
||||
startColumnId: getStartColumnId(data),
|
||||
});
|
||||
|
||||
const getCardData = (data?: CalendarStoredViewData) => {
|
||||
if (data) {
|
||||
return data.card;
|
||||
}
|
||||
return {
|
||||
visiblePropertyIds: [],
|
||||
};
|
||||
};
|
||||
|
||||
const toTimestamp = (date: number | Date) =>
|
||||
date instanceof Date ? date.getTime() : date;
|
||||
|
||||
const isValidTimestamp = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const createLinkedDocTitle = (docId: string) => {
|
||||
const text = new Text<AffineTextAttributes>();
|
||||
new Doc().getMap('root').set('text', text.yText);
|
||||
text.applyDelta([
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
|
||||
},
|
||||
] satisfies DeltaInsert<AffineTextAttributes>[]);
|
||||
return text;
|
||||
};
|
||||
|
||||
const getTitleDeltas = (value: unknown) =>
|
||||
typeof value === 'object' && value != null && 'deltas$' in value
|
||||
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||
: undefined;
|
||||
|
||||
const getTitleSegments = (
|
||||
value: unknown,
|
||||
title: string,
|
||||
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
|
||||
): CalendarTitleSegment[] | undefined => {
|
||||
const deltas = getTitleDeltas(value);
|
||||
if (!Array.isArray(deltas)) {
|
||||
return;
|
||||
}
|
||||
const segments = deltas.flatMap(delta => {
|
||||
const item = delta as {
|
||||
insert?: unknown;
|
||||
attributes?: {
|
||||
reference?: {
|
||||
type?: string;
|
||||
pageId?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const linkedDoc =
|
||||
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||
typeof item.attributes.reference.pageId === 'string';
|
||||
const referenceTitle = item.attributes?.reference?.title;
|
||||
const resolvedLinkedDocTitle =
|
||||
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
|
||||
? getLinkedDocTitle?.(
|
||||
item.attributes.reference.pageId,
|
||||
typeof referenceTitle === 'string' ? referenceTitle : undefined
|
||||
)
|
||||
: undefined;
|
||||
const text =
|
||||
resolvedLinkedDocTitle ||
|
||||
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
|
||||
? referenceTitle
|
||||
: typeof item.insert === 'string'
|
||||
? item.insert.trim()
|
||||
: '');
|
||||
if (linkedDoc) {
|
||||
return {
|
||||
text,
|
||||
linkedDoc,
|
||||
};
|
||||
}
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
text,
|
||||
};
|
||||
});
|
||||
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
|
||||
(result, segment) => {
|
||||
const previous = result.at(-1);
|
||||
if (
|
||||
previous?.linkedDoc &&
|
||||
!previous.text &&
|
||||
!segment.linkedDoc &&
|
||||
segment.text
|
||||
) {
|
||||
previous.text = segment.text;
|
||||
return result;
|
||||
}
|
||||
result.push(segment);
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
|
||||
return;
|
||||
}
|
||||
if (!normalizedSegments.some(segment => segment.text)) {
|
||||
return title
|
||||
? [...normalizedSegments, { text: title }]
|
||||
: normalizedSegments;
|
||||
}
|
||||
return normalizedSegments;
|
||||
};
|
||||
|
||||
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
|
||||
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
|
||||
|
||||
private externalEntriesRequestId = 0;
|
||||
|
||||
propertiesRaw$ = computed(() => {
|
||||
return this.dataSource.properties$.value.map(id =>
|
||||
this.propertyGetOrCreate(id)
|
||||
);
|
||||
});
|
||||
|
||||
properties$ = this.propertiesRaw$;
|
||||
|
||||
detailProperties$ = computed(() => {
|
||||
return this.propertiesRaw$.value.filter(
|
||||
property => property.type$.value !== 'title'
|
||||
);
|
||||
});
|
||||
|
||||
private readonly filter$ = computed(() => {
|
||||
return this.data$.value?.filter ?? emptyFilterGroup;
|
||||
});
|
||||
|
||||
private readonly sortList$ = computed(() => {
|
||||
return this.data$.value?.sort;
|
||||
});
|
||||
|
||||
emptyMonthHintDismissed$ = computed(() => {
|
||||
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
|
||||
});
|
||||
|
||||
private readonly sortManager = this.traitSet(
|
||||
sortTraitKey,
|
||||
new SortManager(this.sortList$, this, {
|
||||
setSortList: sortList => {
|
||||
this.dataUpdate(data => ({
|
||||
sort: {
|
||||
...data.sort,
|
||||
...sortList,
|
||||
},
|
||||
}));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
filterTrait = this.traitSet(
|
||||
filterTraitKey,
|
||||
new FilterTrait(this.filter$, this, {
|
||||
filterSet: (filter: FilterGroup) => {
|
||||
this.dataUpdate(() => ({ filter }));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
mainProperties$ = computed(() => {
|
||||
const card = getCardData(this.data$.value);
|
||||
return {
|
||||
titleColumn:
|
||||
card.titleColumnId ??
|
||||
this.propertiesRaw$.value.find(
|
||||
property => property.type$.value === 'title'
|
||||
)?.id,
|
||||
};
|
||||
});
|
||||
|
||||
readonly$ = computed(() => {
|
||||
return this.manager.readonly$.value;
|
||||
});
|
||||
|
||||
dateProperties$ = computed(() => {
|
||||
return this.propertiesRaw$.value.filter(
|
||||
property => property.type$.value === 'date'
|
||||
);
|
||||
});
|
||||
|
||||
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||
const propertyId = getStartColumnId(this.data$.value);
|
||||
if (
|
||||
propertyId &&
|
||||
this.dataSource.properties$.value.includes(propertyId) &&
|
||||
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||
) {
|
||||
return {
|
||||
status: 'ready',
|
||||
propertyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'setup',
|
||||
propertyId,
|
||||
};
|
||||
});
|
||||
|
||||
startDateMapping$ = this.dateMapping$;
|
||||
|
||||
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||
const propertyId = getEndColumnId(this.data$.value);
|
||||
if (
|
||||
propertyId &&
|
||||
this.dataSource.properties$.value.includes(propertyId) &&
|
||||
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||
) {
|
||||
return {
|
||||
status: 'ready',
|
||||
propertyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'setup',
|
||||
propertyId,
|
||||
};
|
||||
});
|
||||
|
||||
private readonly visibleCardProperties$ = computed(() => {
|
||||
const card = getCardData(this.data$.value);
|
||||
const visiblePropertyIds = card.visiblePropertyIds ?? [];
|
||||
const titleColumn = card.titleColumnId;
|
||||
return visiblePropertyIds
|
||||
.filter(propertyId => propertyId !== titleColumn)
|
||||
.map(propertyId => this.propertyGetOrCreate(propertyId));
|
||||
});
|
||||
|
||||
rowEntries$ = computed<CalendarRowEntry[]>(() => {
|
||||
const mapping = this.dateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return [];
|
||||
}
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
return this.rows$.value.flatMap(row => {
|
||||
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
if (!isValidTimestamp(startAt)) {
|
||||
return [];
|
||||
}
|
||||
const endAt =
|
||||
endMapping.status === 'ready'
|
||||
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
|
||||
.value
|
||||
: undefined;
|
||||
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
|
||||
const jsonTitle = titleCell.jsonValue$.value;
|
||||
const title =
|
||||
(typeof jsonTitle === 'string'
|
||||
? jsonTitle
|
||||
: titleCell.stringValue$.value) ?? '';
|
||||
const docDisplayMeta = this.manager.dataSource.serviceGet(
|
||||
DocDisplayMetaProvider
|
||||
);
|
||||
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
|
||||
docDisplayMeta?.title(pageId, { title }).value;
|
||||
const titleSegments = getTitleSegments(
|
||||
titleCell.value$.value,
|
||||
title,
|
||||
resolveLinkedDocTitle
|
||||
);
|
||||
const cardProperties = this.visibleCardProperties$.value.flatMap(
|
||||
property => {
|
||||
const cell = this.cellGetOrCreate(row.rowId, property.id);
|
||||
const value = cell.stringValue$.value;
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
propertyId: property.id,
|
||||
value,
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
kind: 'row',
|
||||
id: `database:${row.rowId}`,
|
||||
sourceId: 'database',
|
||||
rowId: row.rowId,
|
||||
title,
|
||||
startAt,
|
||||
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
|
||||
titleSegments,
|
||||
cardProperties,
|
||||
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
|
||||
} satisfies CalendarRowEntry;
|
||||
});
|
||||
});
|
||||
|
||||
entries$ = computed<CalendarEntry[]>(() => {
|
||||
return [...this.rowEntries$.value, ...this.externalEntries$.value];
|
||||
});
|
||||
|
||||
externalSources$ = computed<CalendarExternalSource[]>(() => {
|
||||
const viewData = this.data$.value;
|
||||
if (!viewData) {
|
||||
return [];
|
||||
}
|
||||
return getCalendarExternalSources(this.dataSource, viewData);
|
||||
});
|
||||
|
||||
get type(): string {
|
||||
return this.data$.value?.mode ?? 'calendar';
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
}
|
||||
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
);
|
||||
return evalFilter(this.filter$.value, rowMap);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override rowsMapping(rows: Row[]) {
|
||||
return this.sortManager.sort(super.rowsMapping(rows));
|
||||
}
|
||||
|
||||
propertyGetOrCreate(propertyId: string): CalendarProperty {
|
||||
return new CalendarProperty(this, propertyId);
|
||||
}
|
||||
|
||||
override rowGetOrCreate(rowId: string): CalendarRow {
|
||||
return new CalendarRow(this, rowId);
|
||||
}
|
||||
|
||||
setStartDateColumn(propertyId: string) {
|
||||
this.dataUpdate(data => ({
|
||||
date: {
|
||||
...getDateData(data),
|
||||
startColumnId: propertyId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setDateColumn(propertyId: string) {
|
||||
this.setStartDateColumn(propertyId);
|
||||
}
|
||||
|
||||
setEndDateColumn(propertyId: string | undefined) {
|
||||
this.dataUpdate(data => ({
|
||||
date: {
|
||||
...getDateData(data),
|
||||
endColumnId: propertyId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setWorkspaceCalendarEnabled(enabled: boolean) {
|
||||
this.dataUpdate(data => ({
|
||||
sources: {
|
||||
...data.sources,
|
||||
workspaceCalendar: {
|
||||
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
|
||||
this.dataUpdate(data => ({
|
||||
sources: {
|
||||
...data.sources,
|
||||
workspaceCalendar: {
|
||||
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||
subscriptionIds,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
dismissEmptyMonthHint() {
|
||||
this.dataUpdate(data => ({
|
||||
ui: {
|
||||
...data.ui,
|
||||
emptyMonthHintDismissed: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
getDocDisplayTitle(docId: string) {
|
||||
return (
|
||||
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
|
||||
.value ?? 'Untitled'
|
||||
);
|
||||
}
|
||||
|
||||
createStartDateColumn() {
|
||||
const id = this.propertyAdd('end', {
|
||||
type: 'date',
|
||||
name: 'Date',
|
||||
});
|
||||
if (id) {
|
||||
this.setStartDateColumn(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createDateColumn() {
|
||||
return this.createStartDateColumn();
|
||||
}
|
||||
|
||||
createEndDateColumn() {
|
||||
const id = this.propertyAdd('end', {
|
||||
type: 'date',
|
||||
name: 'End Date',
|
||||
});
|
||||
if (id) {
|
||||
this.setEndDateColumn(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createRowOnDate(date: number | Date) {
|
||||
const mapping = this.startDateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const rowId = this.rowAdd('end');
|
||||
const filter = this.filter$.value;
|
||||
if (filter.conditions.length > 0) {
|
||||
const defaultValues = generateDefaultValues(filter, this.vars$.value);
|
||||
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
|
||||
const property = this.propertyGetOrCreate(propertyId);
|
||||
const propertyMeta = property.meta$.value;
|
||||
if (propertyMeta) {
|
||||
const value = fromJson(propertyMeta.config, {
|
||||
value: jsonValue,
|
||||
data: property.data$.value,
|
||||
dataSource: this.dataSource,
|
||||
});
|
||||
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
|
||||
toTimestamp(date)
|
||||
);
|
||||
this.dismissEmptyMonthHint();
|
||||
return rowId;
|
||||
}
|
||||
|
||||
createLinkedDocRowOnDate(date: number | Date, docId: string) {
|
||||
const rowId = this.createRowOnDate(date);
|
||||
if (!rowId) return;
|
||||
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||
this.cellGetOrCreate(rowId, titleColumn).valueSet(
|
||||
createLinkedDocTitle(docId)
|
||||
);
|
||||
return rowId;
|
||||
}
|
||||
|
||||
moveRowToDate(rowId: string, date: number | Date) {
|
||||
const mapping = this.startDateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const value = toTimestamp(date);
|
||||
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
|
||||
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
|
||||
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
|
||||
value + (oldEndAt - oldStartAt)
|
||||
);
|
||||
}
|
||||
}
|
||||
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
|
||||
}
|
||||
|
||||
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
|
||||
const startMapping = this.startDateMapping$.value;
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
|
||||
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
|
||||
const startAt = startCell.jsonValue$.value;
|
||||
const endAt = endCell.jsonValue$.value;
|
||||
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
|
||||
return;
|
||||
}
|
||||
const value = toTimestamp(date);
|
||||
if (edge === 'start') {
|
||||
startCell.jsonValueSet(Math.min(value, endAt));
|
||||
} else {
|
||||
endCell.jsonValueSet(Math.max(value, startAt));
|
||||
}
|
||||
}
|
||||
|
||||
async loadExternalEntries(range: CalendarEntryRange) {
|
||||
const requestId = ++this.externalEntriesRequestId;
|
||||
const viewData = this.data$.value;
|
||||
if (!viewData) {
|
||||
this.externalEntries$.value = [];
|
||||
return [];
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
this.externalSources$.value.map(source =>
|
||||
Promise.resolve(source.getEntries(range))
|
||||
)
|
||||
);
|
||||
const entries = results.flatMap(result =>
|
||||
result.status === 'fulfilled' ? result.value : []
|
||||
);
|
||||
if (requestId === this.externalEntriesRequestId) {
|
||||
this.externalEntries$.value = entries;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
export class CalendarProperty extends PropertyBase {
|
||||
hide$ = computed(() => false);
|
||||
|
||||
constructor(view: CalendarSingleView, propertyId: string) {
|
||||
super(view as SingleView, propertyId);
|
||||
}
|
||||
|
||||
hideSet(_hide: boolean): void {}
|
||||
|
||||
move(_position: InsertToPosition): void {}
|
||||
}
|
||||
|
||||
export class CalendarRow extends RowBase {
|
||||
constructor(
|
||||
readonly calendarView: CalendarSingleView,
|
||||
rowId: string
|
||||
) {
|
||||
super(calendarView, rowId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { viewType } from '../../core/view/data-view.js';
|
||||
import { CalendarSingleView } from './calendar-view-manager.js';
|
||||
import type { CalendarViewData } from './types.js';
|
||||
|
||||
export const calendarViewType = viewType('calendar');
|
||||
|
||||
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
|
||||
{
|
||||
defaultName: 'Calendar View',
|
||||
dataViewManager: CalendarSingleView,
|
||||
defaultData: viewManager => {
|
||||
return {
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
date: {},
|
||||
card: {
|
||||
titleColumnId: viewManager.dataSource.properties$.value.find(
|
||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||
),
|
||||
visiblePropertyIds: [],
|
||||
},
|
||||
sources: {
|
||||
workspaceCalendar: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
ui: {},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { pcEffects } from './pc/effect.js';
|
||||
|
||||
export function calendarEffects() {
|
||||
pcEffects();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './calendar-view-manager.js';
|
||||
export * from './define.js';
|
||||
export * from './layout.js';
|
||||
export * from './renderer.js';
|
||||
export * from './source.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { CalendarEntry } from './types.js';
|
||||
|
||||
export type CalendarDayLayout = {
|
||||
date: number;
|
||||
inMonth: boolean;
|
||||
entries: CalendarEntry[];
|
||||
segments: CalendarRangeSegment[];
|
||||
};
|
||||
|
||||
export type CalendarRangeSegment = {
|
||||
entry: CalendarEntry;
|
||||
weekIndex: number;
|
||||
startIndex: number;
|
||||
span: number;
|
||||
slot: number;
|
||||
startsBeforeWeek: boolean;
|
||||
endsAfterWeek: boolean;
|
||||
};
|
||||
|
||||
export type CalendarMonthLayout = {
|
||||
from: number;
|
||||
to: number;
|
||||
weeks: CalendarDayLayout[][];
|
||||
days: CalendarDayLayout[];
|
||||
segments: CalendarRangeSegment[];
|
||||
};
|
||||
|
||||
export type CalendarMonthLayoutOptions = {
|
||||
month: number | Date;
|
||||
entries: CalendarEntry[];
|
||||
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
};
|
||||
|
||||
const startOfDay = (date: Date) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
|
||||
const addDays = (date: number, days: number) => {
|
||||
const current = new Date(date);
|
||||
return startOfDay(
|
||||
new Date(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate() + days
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const endOfDay = (date: number) => addDays(date, 1) - 1;
|
||||
|
||||
const toDate = (value: number | Date) =>
|
||||
value instanceof Date ? value : new Date(value);
|
||||
|
||||
export const getCalendarVisibleMonthRange = (
|
||||
month: number | Date,
|
||||
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
|
||||
) => {
|
||||
const cursor = toDate(month);
|
||||
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
|
||||
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
|
||||
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
|
||||
const from = startOfDay(
|
||||
new Date(
|
||||
monthStart.getFullYear(),
|
||||
monthStart.getMonth(),
|
||||
monthStart.getDate() - startOffset
|
||||
)
|
||||
);
|
||||
const to = endOfDay(
|
||||
startOfDay(
|
||||
new Date(
|
||||
monthEnd.getFullYear(),
|
||||
monthEnd.getMonth(),
|
||||
monthEnd.getDate() + endOffset
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
monthStart: startOfDay(monthStart),
|
||||
monthEnd: endOfDay(startOfDay(monthEnd)),
|
||||
};
|
||||
};
|
||||
|
||||
const isRangeEntry = (entry: CalendarEntry) =>
|
||||
entry.endAt != null &&
|
||||
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
|
||||
|
||||
const getRangeEndDay = (entry: CalendarEntry) => {
|
||||
const endAt = entry.endAt ?? entry.startAt;
|
||||
const end = new Date(endAt);
|
||||
if (
|
||||
entry.kind === 'external' &&
|
||||
entry.allDay &&
|
||||
endAt > entry.startAt &&
|
||||
end.getHours() === 0 &&
|
||||
end.getMinutes() === 0 &&
|
||||
end.getSeconds() === 0 &&
|
||||
end.getMilliseconds() === 0
|
||||
) {
|
||||
return addDays(startOfDay(end), -1);
|
||||
}
|
||||
return startOfDay(end);
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
|
||||
days.findIndex(day => day.date === date);
|
||||
|
||||
const assignSegmentSlots = (
|
||||
weeks: CalendarDayLayout[][],
|
||||
segments: CalendarRangeSegment[]
|
||||
) => {
|
||||
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
|
||||
const weekSegments = segments.filter(
|
||||
segment => segment.weekIndex === weekIndex
|
||||
);
|
||||
const slots: boolean[][] = [];
|
||||
for (const segment of weekSegments) {
|
||||
let slot = 0;
|
||||
while (
|
||||
slots[slot]?.some(
|
||||
(occupied, index) =>
|
||||
occupied &&
|
||||
index >= segment.startIndex &&
|
||||
index < segment.startIndex + segment.span
|
||||
)
|
||||
) {
|
||||
slot++;
|
||||
}
|
||||
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
|
||||
for (
|
||||
let index = segment.startIndex;
|
||||
index < segment.startIndex + segment.span;
|
||||
index++
|
||||
) {
|
||||
slotDays[index] = true;
|
||||
}
|
||||
segment.slot = slot;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getCalendarDaySegmentSlots = (
|
||||
day: CalendarDayLayout,
|
||||
ignoredEntryId?: string
|
||||
) => {
|
||||
return Math.max(
|
||||
0,
|
||||
...day.segments
|
||||
.filter(segment => segment.entry.id !== ignoredEntryId)
|
||||
.map(segment => segment.slot + 1)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCalendarDayContentSlots = (
|
||||
day: CalendarDayLayout,
|
||||
ignoredEntryId?: string
|
||||
) => {
|
||||
return (
|
||||
getCalendarDaySegmentSlots(day, ignoredEntryId) +
|
||||
day.entries.filter(entry => entry.id !== ignoredEntryId).length
|
||||
);
|
||||
};
|
||||
|
||||
export const createCalendarMonthLayout = ({
|
||||
month,
|
||||
entries,
|
||||
weekStartsOn = 0,
|
||||
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
|
||||
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
|
||||
const cursor = toDate(month);
|
||||
const days: CalendarDayLayout[] = [];
|
||||
const dayByTime = new Map<number, CalendarDayLayout>();
|
||||
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
|
||||
const day: CalendarDayLayout = {
|
||||
date,
|
||||
inMonth:
|
||||
new Date(date).getMonth() === cursor.getMonth() &&
|
||||
new Date(date).getFullYear() === cursor.getFullYear(),
|
||||
entries: [],
|
||||
segments: [],
|
||||
};
|
||||
days.push(day);
|
||||
dayByTime.set(date, day);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isRangeEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
|
||||
if (day) {
|
||||
day.entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: CalendarRangeSegment[] = [];
|
||||
const rangeEntries = entries.filter(isRangeEntry);
|
||||
const visibleEndDay = startOfDay(new Date(range.to));
|
||||
for (const entry of rangeEntries) {
|
||||
const entryStart = startOfDay(new Date(entry.startAt));
|
||||
const entryEnd = getRangeEndDay(entry);
|
||||
if (entryEnd < range.from || entryStart > visibleEndDay) {
|
||||
continue;
|
||||
}
|
||||
const start = clamp(entryStart, range.from, visibleEndDay);
|
||||
const end = clamp(entryEnd, range.from, visibleEndDay);
|
||||
const startOffset = getDayOffset(days, start);
|
||||
const endOffset = getDayOffset(days, end);
|
||||
if (startOffset < 0 || endOffset < 0) {
|
||||
continue;
|
||||
}
|
||||
let offset = startOffset;
|
||||
while (offset <= endOffset) {
|
||||
const weekIndex = Math.floor(offset / 7);
|
||||
const startIndex = offset % 7;
|
||||
const weekEndOffset = weekIndex * 7 + 6;
|
||||
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
|
||||
const segment = {
|
||||
entry,
|
||||
weekIndex,
|
||||
startIndex,
|
||||
span,
|
||||
slot: 0,
|
||||
startsBeforeWeek: startOffset < weekIndex * 7,
|
||||
endsAfterWeek: endOffset > weekEndOffset,
|
||||
};
|
||||
segments.push(segment);
|
||||
for (let index = 0; index < span; index++) {
|
||||
days[offset + index]?.segments.push(segment);
|
||||
}
|
||||
offset += span;
|
||||
}
|
||||
}
|
||||
|
||||
const weeks: CalendarDayLayout[][] = [];
|
||||
for (let index = 0; index < days.length; index += 7) {
|
||||
weeks.push(days.slice(index, index + 7));
|
||||
}
|
||||
|
||||
assignSegmentSlots(weeks, segments);
|
||||
|
||||
return { from: range.from, to: range.to, weeks, days, segments };
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
CalendarPanelIcon,
|
||||
DateTimeIcon,
|
||||
PinIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { DataViewRootUILogic } from '../../../core/data-view.js';
|
||||
import type { CalendarSingleView } from '../calendar-view-manager.js';
|
||||
import type { CalendarEntry } from '../types.js';
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
});
|
||||
|
||||
export const formatEntryTime = (entry: CalendarEntry) => {
|
||||
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
|
||||
const start = formatter.format(new Date(entry.startAt));
|
||||
if (!entry.endAt) {
|
||||
return start;
|
||||
}
|
||||
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
|
||||
};
|
||||
|
||||
export const openCalendarEntry = (
|
||||
root: DataViewRootUILogic,
|
||||
view: CalendarSingleView,
|
||||
entry: CalendarEntry,
|
||||
target: HTMLElement,
|
||||
options?: { selectEntry?: (entryId: string | undefined) => void }
|
||||
) => {
|
||||
if (entry.kind === 'row') {
|
||||
options?.selectEntry?.(entry.id);
|
||||
root.openDetailPanel({
|
||||
view,
|
||||
rowId: entry.rowId,
|
||||
onClose: () => options?.selectEntry?.(undefined),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
popMenu(popupTargetFromElement(target), {
|
||||
options: {
|
||||
items: [
|
||||
() => html`
|
||||
<div class="calendar-event-popover">
|
||||
<div class="calendar-event-popover-title">${entry.title}</div>
|
||||
<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon"
|
||||
>${CalendarPanelIcon()}</span
|
||||
>
|
||||
<span>${entry.calendarName ?? 'Calendar event'}</span>
|
||||
</div>
|
||||
<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
|
||||
<span>${formatEntryTime(entry)}</span>
|
||||
</div>
|
||||
${entry.location
|
||||
? html`<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${PinIcon()}</span>
|
||||
<span>${entry.location}</span>
|
||||
</div>`
|
||||
: ''}
|
||||
${entry.description
|
||||
? html`<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${TextIcon()}</span>
|
||||
<span class="calendar-event-popover-description"
|
||||
>${entry.description}</span
|
||||
>
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { DndController } from '@blocksuite/std';
|
||||
|
||||
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
|
||||
import { getCalendarDateFromPoint } from './hit-test.js';
|
||||
|
||||
export type CalendarDndEntity =
|
||||
| {
|
||||
type: 'calendar-entry';
|
||||
entryId: string;
|
||||
}
|
||||
| {
|
||||
type: 'doc';
|
||||
docId: string;
|
||||
};
|
||||
|
||||
type CalendarDndData = {
|
||||
bsEntity?: unknown;
|
||||
entity?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
export const getCalendarDndEntity = (
|
||||
data: unknown
|
||||
): CalendarDndEntity | undefined => {
|
||||
if (!isRecord(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bsEntity = (data as CalendarDndData).bsEntity;
|
||||
if (isRecord(bsEntity)) {
|
||||
if (
|
||||
bsEntity.type === 'calendar-entry' &&
|
||||
typeof bsEntity.entryId === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'calendar-entry',
|
||||
entryId: bsEntity.entryId,
|
||||
};
|
||||
}
|
||||
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
|
||||
return {
|
||||
type: 'doc',
|
||||
docId: bsEntity.docId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const entity = (data as CalendarDndData).entity;
|
||||
if (
|
||||
isRecord(entity) &&
|
||||
entity.type === 'doc' &&
|
||||
typeof entity.id === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
docId: entity.id,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
export type CalendarDndCallbacks = {
|
||||
getEntry: (entryId: string) => CalendarEntry | undefined;
|
||||
canDragEntry: () => boolean;
|
||||
canDrop: (entity: CalendarDndEntity) => boolean;
|
||||
onEntryDragStart: (entry: CalendarRowEntry) => void;
|
||||
onEntryDragEnd: () => void;
|
||||
onDropTargetChange: (
|
||||
date: number | undefined,
|
||||
entity?: CalendarDndEntity
|
||||
) => void;
|
||||
onDrop: (entity: CalendarDndEntity, date: number) => void;
|
||||
};
|
||||
|
||||
type ElementCleanup = {
|
||||
element: HTMLElement;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
export class CalendarDnd {
|
||||
private readonly entryCleanups = new Map<string, ElementCleanup>();
|
||||
|
||||
private rootCleanup?: ElementCleanup;
|
||||
|
||||
constructor(
|
||||
private readonly dnd: DndController | undefined,
|
||||
private readonly callbacks: CalendarDndCallbacks
|
||||
) {}
|
||||
|
||||
bindRoot(element?: Element) {
|
||||
if (!this.dnd || !(element instanceof HTMLElement)) {
|
||||
this.cleanupRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rootCleanup?.element === element) {
|
||||
return;
|
||||
}
|
||||
this.cleanupRoot();
|
||||
|
||||
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
|
||||
element,
|
||||
getIsSticky: () => true,
|
||||
setDropData: ({ input }) => ({
|
||||
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
|
||||
}),
|
||||
canDrop: ({ source, input }) => {
|
||||
const entity = getCalendarDndEntity(source.data);
|
||||
const date = getCalendarDateFromPoint(
|
||||
element,
|
||||
input.clientX,
|
||||
input.clientY
|
||||
);
|
||||
return entity && date !== undefined
|
||||
? this.callbacks.canDrop(entity)
|
||||
: false;
|
||||
},
|
||||
onDrag: ({ source, location }) => {
|
||||
this.updateDropTarget(element, source.data, location.current.input);
|
||||
},
|
||||
onDragEnter: ({ source, location }) => {
|
||||
this.updateDropTarget(element, source.data, location.current.input);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
},
|
||||
onDrop: ({ source, location }) => {
|
||||
const entity = getCalendarDndEntity(source.data);
|
||||
const date = getCalendarDateFromPoint(
|
||||
element,
|
||||
location.current.input.clientX,
|
||||
location.current.input.clientY
|
||||
);
|
||||
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||
this.callbacks.onDrop(entity, date);
|
||||
}
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
this.rootCleanup = { element, cleanup };
|
||||
}
|
||||
|
||||
bindEntry(
|
||||
key: string,
|
||||
entry: CalendarEntry,
|
||||
element?: Element,
|
||||
disabled = false
|
||||
) {
|
||||
if (
|
||||
!this.dnd ||
|
||||
!(element instanceof HTMLElement) ||
|
||||
entry.kind !== 'row' ||
|
||||
disabled
|
||||
) {
|
||||
this.cleanupEntry(key);
|
||||
if (element instanceof HTMLElement) {
|
||||
element.setAttribute('draggable', 'false');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.entryCleanups.get(key);
|
||||
if (current?.element === element) {
|
||||
return;
|
||||
}
|
||||
this.cleanupEntry(key);
|
||||
|
||||
const cleanup = this.dnd.draggable<CalendarDndEntity>({
|
||||
element,
|
||||
canDrag: () => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
return currentEntry?.kind === 'row'
|
||||
? this.callbacks.canDragEntry()
|
||||
: false;
|
||||
},
|
||||
setDragData: () => ({
|
||||
type: 'calendar-entry',
|
||||
entryId: entry.id,
|
||||
}),
|
||||
setDragPreview: ({ container, setOffset }) => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
const preview = document.createElement('div');
|
||||
preview.textContent = currentEntry?.title || 'Untitled';
|
||||
preview.style.cssText =
|
||||
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
|
||||
'font-size:12px;white-space:nowrap;overflow:hidden;' +
|
||||
'background:var(--affine-hover-color,#f5f5f5);' +
|
||||
'color:var(--affine-text-primary-color,#333);' +
|
||||
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
|
||||
container.append(preview);
|
||||
setOffset({ x: 10, y: 11 });
|
||||
},
|
||||
onDragStart: () => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
if (currentEntry?.kind === 'row') {
|
||||
this.callbacks.onEntryDragStart(currentEntry);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
this.callbacks.onEntryDragEnd();
|
||||
},
|
||||
});
|
||||
|
||||
this.entryCleanups.set(key, { element, cleanup });
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupRoot();
|
||||
for (const key of this.entryCleanups.keys()) {
|
||||
this.cleanupEntry(key);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupEntry(key: string) {
|
||||
this.entryCleanups.get(key)?.cleanup();
|
||||
this.entryCleanups.delete(key);
|
||||
}
|
||||
|
||||
private cleanupRoot() {
|
||||
this.rootCleanup?.cleanup();
|
||||
this.rootCleanup = undefined;
|
||||
}
|
||||
|
||||
private updateDropTarget(
|
||||
root: HTMLElement,
|
||||
data: unknown,
|
||||
input: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
) {
|
||||
const entity = getCalendarDndEntity(data);
|
||||
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
|
||||
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||
this.callbacks.onDropTargetChange(date, entity);
|
||||
} else {
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CalendarViewUI } from './view.js';
|
||||
|
||||
export function pcEffects() {
|
||||
if (customElements.get('affine-data-view-calendar')) {
|
||||
return;
|
||||
}
|
||||
customElements.define('affine-data-view-calendar', CalendarViewUI);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const getCalendarDateFromPoint = (
|
||||
root: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => {
|
||||
const doc = root.ownerDocument;
|
||||
const hitStack = doc.elementsFromPoint(clientX, clientY);
|
||||
|
||||
for (const element of hitStack) {
|
||||
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
|
||||
if (day && root.contains(day)) {
|
||||
return Number(day.dataset['date']);
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of hitStack) {
|
||||
const week =
|
||||
element.closest<HTMLElement>('.calendar-week') ??
|
||||
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
|
||||
if (week && root.contains(week)) {
|
||||
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
|
||||
for (const day of days) {
|
||||
const rect = day.getBoundingClientRect();
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX < rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY < rect.bottom &&
|
||||
day.dataset['date']
|
||||
) {
|
||||
return Number(day.dataset['date']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1,708 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const calendarViewStyles = css`
|
||||
affine-data-view-calendar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
--calendar-entry-height: 22px;
|
||||
--calendar-entry-gap: 3px;
|
||||
--calendar-entry-slot-height: calc(
|
||||
var(--calendar-entry-height) + var(--calendar-entry-gap)
|
||||
);
|
||||
--calendar-grid-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-border-color) 58%,
|
||||
transparent
|
||||
);
|
||||
--calendar-entry-bg: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 12%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
--calendar-entry-hover-bg: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 18%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
--calendar-entry-text-color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 72%,
|
||||
var(--affine-text-primary-color)
|
||||
);
|
||||
--calendar-external-fallback-color: #b45309;
|
||||
}
|
||||
|
||||
.calendar-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.calendar-shell {
|
||||
position: relative;
|
||||
min-width: 720px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-nav button,
|
||||
.calendar-setup button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--affine-background-primary-color);
|
||||
color: var(--affine-text-primary-color);
|
||||
height: 28px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-nav button svg,
|
||||
.calendar-setup button svg,
|
||||
.calendar-new-row svg,
|
||||
.calendar-empty-month-hint-action svg,
|
||||
.calendar-empty-month-hint-close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-nav .calendar-icon-button {
|
||||
width: 28px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.calendar-nav .calendar-today-button {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-weekdays,
|
||||
.calendar-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-week {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-segments {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: var(--calendar-entry-slot-height);
|
||||
row-gap: 0;
|
||||
column-gap: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-segments .calendar-entry {
|
||||
align-self: start;
|
||||
height: var(--calendar-entry-height);
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.calendar-segments .calendar-entry-preview {
|
||||
align-self: start;
|
||||
pointer-events: none;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
border-top: 1px solid var(--calendar-grid-border-color);
|
||||
border-left: 1px solid var(--calendar-grid-border-color);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
position: relative;
|
||||
min-height: 112px;
|
||||
border-right: 1px solid var(--calendar-grid-border-color);
|
||||
border-bottom: 1px solid var(--calendar-grid-border-color);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.calendar-day.is-outside {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-background-secondary-color) 55%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day:not(.is-outside):hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 2%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-drop-target {
|
||||
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.calendar-day.is-today {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 6%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
margin-bottom: 4px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.calendar-day:not(.is-outside) .calendar-day-number {
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.calendar-day.is-outside .calendar-day-number {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-text-secondary-color) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-today .calendar-day-number {
|
||||
color: var(--affine-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 9%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--calendar-entry-height);
|
||||
margin-top: var(--calendar-entry-gap);
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--calendar-entry-text-color);
|
||||
background: var(--calendar-entry-bg);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-nav button:hover,
|
||||
.calendar-setup button:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.calendar-entry.row:hover {
|
||||
background: var(--calendar-entry-hover-bg);
|
||||
}
|
||||
|
||||
.calendar-entry:focus-visible {
|
||||
outline: 1px solid var(--affine-primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.calendar-entry.external:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.calendar-entry.selected {
|
||||
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 15%,
|
||||
var(--calendar-entry-bg)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry.continues-left {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-entry.continues-right {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry-title.is-empty {
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-entry-title.title-segments {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment {
|
||||
gap: 3px;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-entry-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.calendar-entry-properties {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-property {
|
||||
max-width: 72px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry.external {
|
||||
color: var(--affine-pure-white);
|
||||
background: var(
|
||||
--calendar-external-color,
|
||||
var(--calendar-external-fallback-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.calendar-entry[draggable='true']:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.calendar-resize-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.calendar-resize-handle.left {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.calendar-resize-handle.right {
|
||||
right: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.calendar-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 1px;
|
||||
background: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-resize-handle:hover::after {
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-entry:hover .calendar-resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-entry-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--calendar-entry-height);
|
||||
height: var(--calendar-entry-height);
|
||||
margin-top: var(--calendar-entry-gap);
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
border: 1.5px dashed var(--affine-primary-color);
|
||||
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-entry-preview svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-entry-preview.continues-left {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: none;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.calendar-entry-preview.continues-right {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: none;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.calendar-day-entries > .calendar-entry:first-child,
|
||||
.calendar-day-entries > .calendar-entry-preview:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.calendar-day-entries {
|
||||
padding-top: calc(
|
||||
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-new-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin-top: 3px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
padding: 3px 8px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
opacity 0.1s ease,
|
||||
background 0.1s ease;
|
||||
}
|
||||
|
||||
.calendar-new-row svg,
|
||||
.calendar-empty-month-hint-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row,
|
||||
.calendar-new-row:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 10%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row:disabled,
|
||||
.calendar-day.is-today:hover .calendar-new-row:disabled,
|
||||
.calendar-new-row:disabled {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover .calendar-new-row,
|
||||
.calendar-day.is-today .calendar-new-row:focus-visible {
|
||||
background: var(--affine-primary-color);
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.calendar-day.is-today .calendar-new-row:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 88%,
|
||||
var(--affine-pure-white)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover .calendar-new-row svg,
|
||||
.calendar-day.is-today .calendar-new-row:focus-visible svg {
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.calendar-new-row:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 16%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-background-primary-color) 92%,
|
||||
var(--affine-primary-color)
|
||||
);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-copy {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-body {
|
||||
min-width: 0;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-action,
|
||||
.calendar-empty-month-hint-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 10%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-close {
|
||||
width: 24px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-action:hover,
|
||||
.calendar-empty-month-hint-close:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 16%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-setup-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-setup-wrap .calendar-shell {
|
||||
filter: grayscale(1) blur(1px);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-setup {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-setup button {
|
||||
height: 32px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.calendar-event-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 318px;
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-title {
|
||||
padding: 2px 4px;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 2px 4px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-event-popover-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 16px;
|
||||
height: 20px;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-event-popover-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-description {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import './pc/effect.js';
|
||||
|
||||
import { createIcon } from '../../core/utils/uni-icon.js';
|
||||
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
|
||||
import { calendarViewModel } from './define.js';
|
||||
import { CalendarViewUILogic } from './pc/view.js';
|
||||
|
||||
export const calendarViewMeta = calendarViewModel.createMeta({
|
||||
icon: createIcon('TodayIcon'),
|
||||
pcLogic: () =>
|
||||
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import type { DataSource } from '../../core/data-source/base.js';
|
||||
import type {
|
||||
CalendarExternalSource,
|
||||
CalendarStoredViewData,
|
||||
} from './types.js';
|
||||
|
||||
export type CalendarExternalSourceFactory = {
|
||||
id: string;
|
||||
create(viewData: CalendarStoredViewData): CalendarExternalSource;
|
||||
};
|
||||
|
||||
export const CalendarExternalSourceProvider =
|
||||
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
|
||||
|
||||
export const getCalendarExternalSources = (
|
||||
dataSource: DataSource,
|
||||
viewData: CalendarStoredViewData
|
||||
) =>
|
||||
Array.from(
|
||||
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
|
||||
).map(source => source.create(viewData));
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import type { Sort } from '../../core/sort/types.js';
|
||||
import type { BasicViewDataType } from '../../core/view/data-view.js';
|
||||
|
||||
export type CalendarWorkspaceSourceConfig = {
|
||||
enabled: boolean;
|
||||
subscriptionIds?: string[];
|
||||
};
|
||||
|
||||
export type CalendarUiData = {
|
||||
emptyMonthHintDismissed?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarCardProperty = {
|
||||
propertyId: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CalendarTitleSegment = {
|
||||
text: string;
|
||||
linkedDoc?: boolean;
|
||||
};
|
||||
|
||||
type CalendarViewDataShape = {
|
||||
filter: FilterGroup;
|
||||
sort?: Sort;
|
||||
date: {
|
||||
startColumnId?: string;
|
||||
endColumnId?: string;
|
||||
};
|
||||
card: {
|
||||
titleColumnId?: string;
|
||||
visiblePropertyIds: string[];
|
||||
};
|
||||
sources: {
|
||||
workspaceCalendar?: CalendarWorkspaceSourceConfig;
|
||||
};
|
||||
ui?: CalendarUiData;
|
||||
};
|
||||
|
||||
export type CalendarViewData = BasicViewDataType<
|
||||
'calendar',
|
||||
CalendarViewDataShape
|
||||
>;
|
||||
|
||||
export type CalendarStoredViewData = CalendarViewData;
|
||||
|
||||
export type CalendarEntryBase = {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
startAt: number;
|
||||
endAt?: number;
|
||||
allDay?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarRowEntry = CalendarEntryBase & {
|
||||
kind: 'row';
|
||||
sourceId: 'database';
|
||||
rowId: string;
|
||||
titleSegments?: CalendarTitleSegment[];
|
||||
cardProperties: CalendarCardProperty[];
|
||||
canResizeRange: boolean;
|
||||
};
|
||||
|
||||
export type CalendarExternalEntry = CalendarEntryBase & {
|
||||
kind: 'external';
|
||||
sourceId: string;
|
||||
externalId: string;
|
||||
calendarName?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
canResizeRange: false;
|
||||
};
|
||||
|
||||
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
|
||||
|
||||
export type CalendarEntryRange = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type CalendarExternalSource = {
|
||||
id: string;
|
||||
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
|
||||
openConnectSettings?(): void;
|
||||
getEntries(
|
||||
range: CalendarEntryRange
|
||||
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
|
||||
};
|
||||
|
||||
export type CalendarExternalSourceSubscription = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
@@ -1,13 +1,45 @@
|
||||
import { createViewConvert } from '../core/view/convert.js';
|
||||
import { calendarViewModel } from './calendar/index.js';
|
||||
import { kanbanViewModel } from './kanban/index.js';
|
||||
import { tableViewModel } from './table/index.js';
|
||||
|
||||
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
|
||||
titleColumnId: header?.titleColumn,
|
||||
visiblePropertyIds: [],
|
||||
});
|
||||
|
||||
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
|
||||
titleColumn: card?.titleColumnId,
|
||||
});
|
||||
|
||||
export const viewConverts = [
|
||||
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
header: data.header,
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
header: data.header,
|
||||
groupBy: data.groupBy,
|
||||
})),
|
||||
createViewConvert(tableViewModel, calendarViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
card: headerToCalendarCard(data.header),
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
card: headerToCalendarCard(data.header),
|
||||
})),
|
||||
createViewConvert(calendarViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
header: calendarCardToHeader(data.card),
|
||||
})),
|
||||
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
header: calendarCardToHeader(data.card),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { calendarEffects } from './calendar/effect.js';
|
||||
import { kanbanEffects } from './kanban/effect.js';
|
||||
import { tableEffects } from './table/effect.js';
|
||||
|
||||
export function viewPresetsEffects() {
|
||||
calendarEffects();
|
||||
kanbanEffects();
|
||||
tableEffects();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { calendarViewMeta } from './calendar/index.js';
|
||||
import { kanbanViewMeta } from './kanban/index.js';
|
||||
import { tableViewMeta } from './table/index.js';
|
||||
|
||||
export * from './calendar/index.js';
|
||||
export * from './convert.js';
|
||||
export * from './kanban/index.js';
|
||||
export * from './table/index.js';
|
||||
@@ -8,4 +10,5 @@ export * from './table/index.js';
|
||||
export const viewPresets = {
|
||||
tableViewMeta: tableViewMeta,
|
||||
kanbanViewMeta: kanbanViewMeta,
|
||||
calendarViewMeta: calendarViewMeta,
|
||||
};
|
||||
|
||||
@@ -181,6 +181,19 @@ export class DatabaseCellContainer extends SignalWatcher(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _tagDraft: string | undefined;
|
||||
|
||||
setTagDraft(value: string) {
|
||||
this._tagDraft = value;
|
||||
}
|
||||
|
||||
consumeTagDraft(): string | undefined {
|
||||
const value = this._tagDraft;
|
||||
this._tagDraft = undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
isEditing$ = signal(false);
|
||||
|
||||
rowIndex$ = computed(() => {
|
||||
|
||||
@@ -46,6 +46,18 @@ export class TableViewCellContainer extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor rowId!: string;
|
||||
|
||||
private _tagDraft: string | undefined;
|
||||
|
||||
setTagDraft(value: string) {
|
||||
this._tagDraft = value;
|
||||
}
|
||||
|
||||
consumeTagDraft(): string | undefined {
|
||||
const value = this._tagDraft;
|
||||
this._tagDraft = undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
cell$ = computed(() => {
|
||||
return this.column.cellGetOrCreate(this.rowId);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import type { ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import { multiSelectPropertyType } from '../../property-presets/multi-select/define.js';
|
||||
import { selectPropertyType } from '../../property-presets/select/define.js';
|
||||
import type { TableViewSelectionWithType } from './selection';
|
||||
import { TableViewRowSelection } from './selection';
|
||||
|
||||
export interface TableCell {
|
||||
rowId: string;
|
||||
setTagDraft?(value: string): void;
|
||||
}
|
||||
|
||||
export type ColumnAccessor<T extends TableCell> = (
|
||||
cell: T
|
||||
) => { valueSetFromString(rowId: string, value: string): void } | undefined;
|
||||
const TAG_COLUMN_TYPES = new Set<string>([
|
||||
selectPropertyType.type,
|
||||
multiSelectPropertyType.type,
|
||||
]);
|
||||
|
||||
export type ColumnAccessor<T extends TableCell> = (cell: T) =>
|
||||
| {
|
||||
valueSetFromString(rowId: string, value: string): void;
|
||||
type$: ReadonlySignal<string>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export interface StartEditOptions<T extends TableCell> {
|
||||
event: KeyboardEvent;
|
||||
@@ -48,7 +61,13 @@ export function handleCharStartEdit<T extends TableCell>(
|
||||
);
|
||||
if (cell) {
|
||||
const column = getColumn(cell);
|
||||
column?.valueSetFromString(cell.rowId, event.key);
|
||||
if (column) {
|
||||
if (TAG_COLUMN_TYPES.has(column.type$.value) && cell.setTagDraft) {
|
||||
cell.setTagDraft(event.key);
|
||||
} else {
|
||||
column.valueSetFromString(cell.rowId, event.key);
|
||||
}
|
||||
}
|
||||
updateSelection({ ...selection, isEditing: true });
|
||||
event.preventDefault();
|
||||
return true;
|
||||
|
||||
+492
-369
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type Menu,
|
||||
menu,
|
||||
type MenuButtonData,
|
||||
type MenuConfig,
|
||||
@@ -16,22 +17,22 @@ import {
|
||||
InfoIcon,
|
||||
LayoutIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
SortIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { popPropertiesSetting } from '../../../../core/common/properties.js';
|
||||
import { filterTraitKey } from '../../../../core/filter/trait.js';
|
||||
import {
|
||||
popGroupSetting,
|
||||
popSelectGroupByProperty,
|
||||
buildGroupSelectItems,
|
||||
buildGroupSettingItems,
|
||||
} from '../../../../core/group-by/setting.js';
|
||||
import { groupTraitKey } from '../../../../core/group-by/trait.js';
|
||||
import {
|
||||
type DataViewUILogicBase,
|
||||
emptyFilterGroup,
|
||||
popCreateFilter,
|
||||
renderUniLit,
|
||||
} from '../../../../core/index.js';
|
||||
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
|
||||
import { sortTraitKey } from '../../../../core/sort/manager.js';
|
||||
import { createSortUtils } from '../../../../core/sort/utils.js';
|
||||
import { WidgetBase } from '../../../../core/widget/widget-base.js';
|
||||
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
|
||||
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
|
||||
|
||||
const styles = css`
|
||||
.affine-database-toolbar-item.more-action {
|
||||
@@ -95,379 +94,486 @@ declare global {
|
||||
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
|
||||
}
|
||||
}
|
||||
const createSettingMenus = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
reopen: () => void,
|
||||
closeMenu: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const settingItems: MenuConfig[] = [];
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
popPropertiesSetting(
|
||||
target,
|
||||
{
|
||||
view: view,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (filterTrait) {
|
||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
: filterCount === 1
|
||||
? '1 filter'
|
||||
: `${filterCount} filters`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
if (!filterTrait.filter$.value.conditions.length) {
|
||||
popCreateFilter(
|
||||
target,
|
||||
{
|
||||
vars: view.vars$,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...(filterTrait.filter$.value ?? emptyFilterGroup),
|
||||
conditions: [
|
||||
...filterTrait.filter$.value.conditions,
|
||||
filter,
|
||||
],
|
||||
});
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
},
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (sortTrait) {
|
||||
const sortCount = sortTrait.sortList$.value.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
: sortCount === 1
|
||||
? '1 sort'
|
||||
: `${sortCount} sorts`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const sortList = sortTrait.sortList$.value;
|
||||
const sortUtils = createSortUtils(
|
||||
sortTrait,
|
||||
dataViewLogic.eventTrace
|
||||
);
|
||||
if (!sortList.length) {
|
||||
popCreateSort(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popSortRoot(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
title: {
|
||||
text: 'Sort',
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (groupTrait) {
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const groupBy = groupTrait.property$.value;
|
||||
if (!groupBy) {
|
||||
popSelectGroupByProperty(
|
||||
target,
|
||||
groupTrait,
|
||||
{
|
||||
onSelect: () =>
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]),
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return settingItems;
|
||||
type Page =
|
||||
| 'main'
|
||||
| 'properties'
|
||||
| 'filter'
|
||||
| 'sort'
|
||||
| 'group'
|
||||
| 'group-select'
|
||||
| 'custom';
|
||||
|
||||
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
|
||||
main: 'View settings',
|
||||
properties: 'Properties',
|
||||
filter: 'Filter',
|
||||
sort: 'Sort',
|
||||
group: 'Group',
|
||||
'group-select': 'Group by',
|
||||
};
|
||||
|
||||
export const popViewOptions = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
onClose?: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const reopen = () => {
|
||||
popViewOptions(target, dataViewLogic);
|
||||
};
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
const items: MenuConfig[] = [];
|
||||
items.push(
|
||||
menu.input({
|
||||
initialValue: view.name$.value,
|
||||
placeholder: 'View name',
|
||||
onChange: text => {
|
||||
view.nameSet(text);
|
||||
},
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu => {
|
||||
const viewTypeItems = menu.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menu => {
|
||||
if (!menu.search(meta.model.defaultName)) {
|
||||
return;
|
||||
}
|
||||
const isSelected =
|
||||
meta.type === view.manager.currentView$.value?.type;
|
||||
const iconStyle = styleMap({
|
||||
fontSize: '24px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
const textStyle = styleMap({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<div
|
||||
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
||||
>
|
||||
<div style="${iconStyle}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
</div>
|
||||
<div style="${textStyle}">${meta.model.defaultName}</div>
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
dataViewLogic.clearSelection();
|
||||
},
|
||||
class: {},
|
||||
};
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menu}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: createSettingMenus(target, dataViewLogic, reopen, () =>
|
||||
handler.close()
|
||||
),
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
const currentPage = signal<Page>('main');
|
||||
const pageStack: Page[] = ['main'];
|
||||
|
||||
let menuHandler!: ReturnType<typeof popMenu>;
|
||||
let mainPageHeight: number | null = null;
|
||||
let customPageTitle = '';
|
||||
let customPageItems: () => MenuConfig[] = () => [];
|
||||
|
||||
const isDesktopMenu = () =>
|
||||
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
|
||||
|
||||
const navigate = (page: Page) => {
|
||||
if (!isDesktopMenu()) {
|
||||
pageStack.push(page);
|
||||
currentPage.value = page;
|
||||
return;
|
||||
}
|
||||
if (mainPageHeight === null) {
|
||||
mainPageHeight =
|
||||
menuHandler.menu.menuElement.getBoundingClientRect().height;
|
||||
}
|
||||
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
|
||||
pageStack.push(page);
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (pageStack.length > 1) {
|
||||
pageStack.pop();
|
||||
const dest = pageStack[pageStack.length - 1] ?? 'main';
|
||||
currentPage.value = dest;
|
||||
if (dest === 'main') {
|
||||
menuHandler.menu.menuElement.style.height = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCustomPage = (
|
||||
title: string,
|
||||
getItems: () => MenuConfig[]
|
||||
) => {
|
||||
customPageTitle = title;
|
||||
customPageItems = getItems;
|
||||
navigate('custom');
|
||||
};
|
||||
|
||||
const titleConfig = {
|
||||
get text() {
|
||||
if (currentPage.value === 'custom') return customPageTitle;
|
||||
return (
|
||||
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
|
||||
'View settings'
|
||||
);
|
||||
},
|
||||
get onBack(): ((menu: Menu) => false) | undefined {
|
||||
return currentPage.value !== 'main'
|
||||
? (_: Menu) => {
|
||||
goBack();
|
||||
return false;
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
get postfix() {
|
||||
if (currentPage.value !== 'properties') return undefined;
|
||||
const items = view.propertiesRaw$.value;
|
||||
const isAllShowed = items.every(p => !p.hide$.value);
|
||||
const clickChangeAll = () => {
|
||||
items.forEach(p => {
|
||||
if (p.hideCanSet) p.hideSet(isAllShowed);
|
||||
});
|
||||
};
|
||||
return () =>
|
||||
html`<div
|
||||
class="properties-group-op"
|
||||
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
|
||||
@click="${clickChangeAll}"
|
||||
>
|
||||
${isAllShowed ? 'Hide All' : 'Show All'}
|
||||
</div>`;
|
||||
},
|
||||
get onClose() {
|
||||
return () => menuHandler?.menu.close();
|
||||
},
|
||||
};
|
||||
|
||||
const getPropertiesPageItems = (): MenuConfig[] => [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
class: { 'delete-item': true },
|
||||
}),
|
||||
() =>
|
||||
html`<data-view-properties-setting
|
||||
.view="${view}"
|
||||
></data-view-properties-setting>`,
|
||||
],
|
||||
})
|
||||
);
|
||||
handler = popMenu(target, {
|
||||
}),
|
||||
];
|
||||
|
||||
const getFilterPageItems = (): MenuConfig[] => {
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (!filterTrait) return getMainPageItems();
|
||||
return [
|
||||
menu.group({
|
||||
items: [
|
||||
() =>
|
||||
html`<filter-root-view
|
||||
.onBack="${goBack}"
|
||||
.vars="${view.vars$}"
|
||||
.filterGroup="${filterTrait.filter$}"
|
||||
.onChange="${filterTrait.filterSet}"
|
||||
></filter-root-view>`,
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Add',
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
const value = filterTrait.filter$.value;
|
||||
popCreateFilter(popupTargetFromElement(ele), {
|
||||
vars: view.vars$,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...value,
|
||||
conditions: [...value.conditions, filter],
|
||||
});
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
});
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getSortPageItems = (): MenuConfig[] => {
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (!sortTrait) return getMainPageItems();
|
||||
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
|
||||
return [
|
||||
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
|
||||
menu.action({
|
||||
name: 'Add sort',
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
popCreateSort(popupTargetFromElement(ele), { sortUtils });
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: { 'delete-item': true },
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
sortUtils.removeAll();
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getGroupPageItems = (): MenuConfig[] => {
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (!groupTrait) return getMainPageItems();
|
||||
const gProp = groupTrait.property$.value;
|
||||
if (!gProp) return [];
|
||||
return buildGroupSettingItems(
|
||||
groupTrait,
|
||||
() => navigate('group-select'),
|
||||
() => navigate('main')
|
||||
);
|
||||
};
|
||||
|
||||
const getGroupSelectPageItems = (): MenuConfig[] => {
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (!groupTrait) return getMainPageItems();
|
||||
return buildGroupSelectItems(groupTrait, id => {
|
||||
if (id) {
|
||||
if (pageStack.at(-1) === 'group-select') {
|
||||
pageStack[pageStack.length - 1] = 'group';
|
||||
} else {
|
||||
pageStack.push('group');
|
||||
}
|
||||
currentPage.value = 'group';
|
||||
} else {
|
||||
while (pageStack.length > 1) pageStack.pop();
|
||||
currentPage.value = 'main';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getMainPageItems = (): MenuConfig[] => {
|
||||
const items: MenuConfig[] = [];
|
||||
|
||||
items.push(
|
||||
menu.input({
|
||||
initialValue: view.name$.value,
|
||||
placeholder: 'View name',
|
||||
disableAutoFocus: true,
|
||||
onChange: text => {
|
||||
view.nameSet(text);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menuObj => {
|
||||
const viewTypeItems = menuObj.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menuObj => {
|
||||
if (!menuObj.search(meta.model.defaultName)) {
|
||||
return;
|
||||
}
|
||||
const isSelected =
|
||||
meta.type === view.manager.currentView$.value?.type;
|
||||
const iconStyle = styleMap({
|
||||
fontSize: '24px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
const textStyle = styleMap({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<div
|
||||
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
|
||||
>
|
||||
<div style="${iconStyle}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
</div>
|
||||
<div style="${textStyle}">
|
||||
${meta.model.defaultName}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
dataViewLogic.clearSelection();
|
||||
},
|
||||
class: {},
|
||||
};
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menuObj}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
|
||||
>
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const settingItems: MenuConfig[] = [];
|
||||
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('properties');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (filterTrait) {
|
||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
: filterCount === 1
|
||||
? '1 active'
|
||||
: `${filterCount} active`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('filter');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (sortTrait) {
|
||||
const sortCount = sortTrait.sortList$.value.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
: sortCount === 1
|
||||
? '1 active'
|
||||
: `${sortCount} active`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('sort');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (groupTrait) {
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
const hasGroup = !!groupTrait.property$.value;
|
||||
navigate(hasGroup ? 'group' : 'group-select');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
items.push(menu.group({ items: settingItems }));
|
||||
|
||||
const viewSpecificItems =
|
||||
(
|
||||
dataViewLogic as DataViewUILogicBase & {
|
||||
getViewOptionsSettingItems?: (
|
||||
navigateToSubPage?: (
|
||||
title: string,
|
||||
getItems: () => MenuConfig[]
|
||||
) => void,
|
||||
goBack?: () => void
|
||||
) => MenuConfig[];
|
||||
}
|
||||
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
|
||||
|
||||
if (viewSpecificItems.length) {
|
||||
items.push(menu.group({ items: viewSpecificItems }));
|
||||
}
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate view',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete view',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
class: { 'delete-item': true },
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getPageItems = (): MenuConfig[] => {
|
||||
switch (currentPage.value) {
|
||||
case 'properties':
|
||||
return getPropertiesPageItems();
|
||||
case 'filter':
|
||||
return getFilterPageItems();
|
||||
case 'sort':
|
||||
return getSortPageItems();
|
||||
case 'group':
|
||||
return getGroupPageItems();
|
||||
case 'group-select':
|
||||
return getGroupSelectPageItems();
|
||||
case 'custom':
|
||||
return customPageItems();
|
||||
default:
|
||||
return getMainPageItems();
|
||||
}
|
||||
};
|
||||
|
||||
menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'View settings',
|
||||
onClose: () => handler.close(),
|
||||
},
|
||||
items,
|
||||
onClose: onClose,
|
||||
title: titleConfig,
|
||||
items: [menu.dynamic(getPageItems)],
|
||||
onClose,
|
||||
},
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
||||
@@ -475,6 +581,23 @@ export const popViewOptions = (
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
return handler;
|
||||
if (isDesktopMenu()) {
|
||||
menuHandler.menu.menuElement.style.minWidth = '380px';
|
||||
menuHandler.menu.menuElement.style.maxWidth = '380px';
|
||||
menuHandler.menu.menuElement.style.borderRadius = '10px';
|
||||
menuHandler.menu.menuElement.style.padding = '12px';
|
||||
menuHandler.menu.menuElement.style.gap = '10px';
|
||||
requestAnimationFrame(() => {
|
||||
const bodyEl =
|
||||
menuHandler.menu.menuElement.querySelector<HTMLElement>(
|
||||
'.affine-menu-body'
|
||||
);
|
||||
if (bodyEl) {
|
||||
bodyEl.style.overflowY = 'auto';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.minHeight = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
return menuHandler;
|
||||
};
|
||||
|
||||
@@ -434,6 +434,8 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
const textResizing = this.element.textResizing;
|
||||
const viewport = this.gfx.viewport;
|
||||
const zoom = viewport.zoom;
|
||||
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
|
||||
const { viewportX, viewportY, viewScale } = viewport;
|
||||
const rect = getSelectedRect([this.element]);
|
||||
const rotate = this.element.rotate;
|
||||
const [leftTopX, leftTopY] = Vec.rotWith(
|
||||
@@ -441,7 +443,8 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
toRadian(rotate)
|
||||
);
|
||||
const [x, y] = this.gfx.viewport.toViewCoord(leftTopX, leftTopY);
|
||||
const x = ((leftTopX - viewportX) * zoom) / viewScale;
|
||||
const y = ((leftTopY - viewportY) * zoom) / viewScale;
|
||||
const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
|
||||
const constrainedAutoWidth = autoWidth && !!this.element.maxWidth;
|
||||
const editorWidth = constrainedAutoWidth
|
||||
@@ -476,7 +479,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
fontWeight: this.element.fontWeight,
|
||||
lineHeight: 'normal',
|
||||
outline: 'none',
|
||||
transform: `scale(${zoom}, ${zoom}) rotate(${rotate}deg)`,
|
||||
transform: `scale(${zoom / viewScale}, ${zoom / viewScale}) rotate(${rotate}deg)`,
|
||||
transformOrigin: 'top left',
|
||||
color,
|
||||
padding: `${verticalPadding}px ${horiPadding}px`,
|
||||
|
||||
@@ -418,13 +418,14 @@ export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const rect = getSelectedRect([this.element]);
|
||||
|
||||
const { translateX, translateY, zoom } = this.gfx.viewport;
|
||||
const { translateX, translateY, zoom, viewScale } = this.gfx.viewport;
|
||||
const [visualX, visualY] = this.getVisualPosition(this.element);
|
||||
const containerOffset = this.getContainerOffset();
|
||||
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
|
||||
const transformOperation = [
|
||||
`translate(${translateX}px, ${translateY}px)`,
|
||||
`translate(${visualX * zoom}px, ${visualY * zoom}px)`,
|
||||
`scale(${zoom})`,
|
||||
`translate(${translateX / viewScale}px, ${translateY / viewScale}px)`,
|
||||
`translate(${(visualX * zoom) / viewScale}px, ${(visualY * zoom) / viewScale}px)`,
|
||||
`scale(${zoom / viewScale})`,
|
||||
`rotate(${rotate}deg)`,
|
||||
`translate(${containerOffset})`,
|
||||
];
|
||||
|
||||
@@ -2,14 +2,48 @@ import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
|
||||
import type { Command, TextSelection } from '@blocksuite/std';
|
||||
import type { InlineRange } from '@blocksuite/std/inline';
|
||||
|
||||
function openInlineLatexEditor(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
index: number
|
||||
) {
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(index);
|
||||
if (!textPoint) return;
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function getSingleBlockInlineRange(
|
||||
textSelection: TextSelection
|
||||
): InlineRange | null {
|
||||
if (textSelection.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: textSelection.from.index,
|
||||
length: textSelection.from.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const insertInlineLatex: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
}> = (ctx, next) => {
|
||||
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
|
||||
if (!textSelection || !textSelection.isCollapsed()) return;
|
||||
if (!textSelection) return;
|
||||
|
||||
const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId);
|
||||
if (!blockComponent) return;
|
||||
@@ -20,24 +54,19 @@ export const insertInlineLatex: Command<{
|
||||
const inlineEditor = richText.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: '',
|
||||
}
|
||||
);
|
||||
const inlineRange = getSingleBlockInlineRange(textSelection);
|
||||
if (!inlineRange) return;
|
||||
|
||||
const latex = textSelection.isCollapsed()
|
||||
? ''
|
||||
: inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
inlineEditor.insertText(inlineRange, ' ', { latex });
|
||||
inlineEditor.setInlineRange({
|
||||
index: textSelection.from.index,
|
||||
index: inlineRange.index,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
@@ -56,19 +85,9 @@ export const insertInlineLatex: Command<{
|
||||
control: 'create inline equation',
|
||||
});
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1);
|
||||
if (!textPoint) return;
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
if (textSelection.isCollapsed()) {
|
||||
openInlineLatexEditor(inlineEditor, inlineRange.index + 1);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import katex from 'katex';
|
||||
import { css, html, render } from 'lit';
|
||||
import { css, html, type PropertyValues, render } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class AffineLatexNode extends SignalWatcher(
|
||||
@@ -85,6 +85,8 @@ export class AffineLatexNode extends SignalWatcher(
|
||||
|
||||
private _editorAbortController: AbortController | null = null;
|
||||
|
||||
private _isEditorOpen = false;
|
||||
|
||||
readonly latex$ = signal('');
|
||||
|
||||
readonly latexEditorSignal = signal('');
|
||||
@@ -174,6 +176,22 @@ export class AffineLatexNode extends SignalWatcher(
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (!changedProperties.has('delta') || this._isEditorOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latex = this.deltaLatex;
|
||||
if (this.latex$.peek() !== latex) {
|
||||
this.latex$.value = latex;
|
||||
}
|
||||
if (this.latexEditorSignal.peek() !== latex) {
|
||||
this.latexEditorSignal.value = latex;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<span class="affine-latex" data-selected=${this.selected}
|
||||
><div class="latex-container"></div>
|
||||
@@ -212,9 +230,11 @@ export class AffineLatexNode extends SignalWatcher(
|
||||
},
|
||||
});
|
||||
|
||||
this._isEditorOpen = true;
|
||||
this._editorAbortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
this._isEditorOpen = false;
|
||||
portal.remove();
|
||||
const latex = this.latexEditorSignal.peek();
|
||||
this.latex$.value = latex;
|
||||
|
||||
@@ -320,9 +320,21 @@ export const htmlMarkElementToDeltaMatcher = HtmlASTToDeltaExtension({
|
||||
if (!isElement(ast)) {
|
||||
return [];
|
||||
}
|
||||
const dataColor =
|
||||
typeof ast.properties?.dataColor === 'string'
|
||||
? ast.properties.dataColor
|
||||
: '';
|
||||
const colorName =
|
||||
dataColor &&
|
||||
/^(red|orange|yellow|green|teal|blue|purple|grey)$/.test(dataColor)
|
||||
? dataColor
|
||||
: 'yellow';
|
||||
return ast.children.flatMap(child =>
|
||||
context.toDelta(child, { trim: false }).map(delta => {
|
||||
delta.attributes = { ...delta.attributes };
|
||||
delta.attributes = {
|
||||
...delta.attributes,
|
||||
background: `var(--affine-text-highlight-${colorName})`,
|
||||
};
|
||||
return delta;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ type CodeBlockProps = {
|
||||
caption: string;
|
||||
preview?: boolean;
|
||||
lineNumber?: boolean;
|
||||
collapsed?: boolean;
|
||||
comments?: Record<string, boolean>;
|
||||
} & BlockMeta;
|
||||
|
||||
@@ -27,6 +28,7 @@ export const CodeBlockSchema = defineBlockSchema({
|
||||
caption: '',
|
||||
preview: undefined,
|
||||
lineNumber: undefined,
|
||||
collapsed: undefined,
|
||||
comments: undefined,
|
||||
'meta:createdAt': undefined,
|
||||
'meta:createdBy': undefined,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { TeXIcon } from '@blocksuite/icons/lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
/**
|
||||
@@ -119,6 +120,15 @@ export const textConversionConfigs: TextConversionConfig[] = [
|
||||
hotkey: [`Mod-Alt-c`],
|
||||
icon: CodeBlockIcon,
|
||||
},
|
||||
{
|
||||
flavour: 'affine:latex',
|
||||
type: undefined,
|
||||
name: 'Equation',
|
||||
description: 'Formula block with LaTeX rendering.',
|
||||
hotkey: null,
|
||||
icon: TeXIcon(),
|
||||
searchAlias: ['mathBlock', 'equationBlock', 'latexBlock'],
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'quote',
|
||||
|
||||
@@ -66,6 +66,10 @@ export type EmbedIframeConfig = {
|
||||
* The function to build the oEmbed URL for fetching embed data
|
||||
*/
|
||||
buildOEmbedUrl: (url: string) => string | undefined;
|
||||
/**
|
||||
* Validate the final iframe src before rendering.
|
||||
*/
|
||||
validateIframeUrl?: (iframeUrl: string, originalUrl?: string) => boolean;
|
||||
/**
|
||||
* Use oEmbed URL directly as iframe src without fetching oEmbed data
|
||||
*/
|
||||
|
||||
@@ -264,17 +264,21 @@ export class EdgelessWatcher {
|
||||
|
||||
const { viewport } = this.gfx;
|
||||
const rect = getSelectedRect([edgelessElement]);
|
||||
let [left, top] = viewport.toViewCoord(rect.left, rect.top);
|
||||
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
|
||||
const { viewportX, viewportY, viewScale } = viewport;
|
||||
const scale = this.widget.scale.peek();
|
||||
const width = rect.width * scale;
|
||||
const height = rect.height * scale;
|
||||
let left = ((rect.left - viewportX) * scale) / viewScale;
|
||||
const top = ((rect.top - viewportY) * scale) / viewScale;
|
||||
const width = (rect.width * scale) / viewScale;
|
||||
const height = (rect.height * scale) / viewScale;
|
||||
|
||||
let [right, bottom] = [left + width, top + height];
|
||||
|
||||
const padding = HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale;
|
||||
const padding = (HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale) / viewScale;
|
||||
|
||||
const containerWidth = DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale;
|
||||
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL;
|
||||
const containerWidth =
|
||||
(DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale) / viewScale;
|
||||
const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL / viewScale;
|
||||
|
||||
left -= containerWidth + offsetLeft;
|
||||
right += padding;
|
||||
|
||||
@@ -473,12 +473,15 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
|
||||
const { zoom, selection, gfx } = this;
|
||||
|
||||
const elements = selection.selectedElements;
|
||||
// in surface
|
||||
const rect = getSelectedRect(elements);
|
||||
|
||||
// in viewport
|
||||
const [left, top] = gfx.viewport.toViewCoord(rect.left, rect.top);
|
||||
const [width, height] = [rect.width * zoom, rect.height * zoom];
|
||||
// Compensate for outer CSS scale (e.g. embed-edgeless-synced-doc),
|
||||
// matching GfxBlockComponent.getCSSTransform.
|
||||
const { viewportX, viewportY, viewScale } = gfx.viewport;
|
||||
const left = ((rect.left - viewportX) * zoom) / viewScale;
|
||||
const top = ((rect.top - viewportY) * zoom) / viewScale;
|
||||
const width = (rect.width * zoom) / viewScale;
|
||||
const height = (rect.height * zoom) / viewScale;
|
||||
|
||||
let rotate = 0;
|
||||
if (elements.length === 1 && elements[0].rotate) {
|
||||
@@ -714,15 +717,17 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
|
||||
element => element.id,
|
||||
element => {
|
||||
const [modelX, modelY, w, h] = deserializeXYWH(element.xywh);
|
||||
const [x, y] = gfx.viewport.toViewCoord(modelX, modelY);
|
||||
const { viewportX, viewportY, zoom, viewScale } = gfx.viewport;
|
||||
const x = ((modelX - viewportX) * zoom) / viewScale;
|
||||
const y = ((modelY - viewportY) * zoom) / viewScale;
|
||||
const { left, top, borderWidth } = this._selectedRect;
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
boxSizing: 'border-box',
|
||||
left: `${x - left - borderWidth}px`,
|
||||
top: `${y - top - borderWidth}px`,
|
||||
width: `${w * this.zoom}px`,
|
||||
height: `${h * this.zoom}px`,
|
||||
width: `${(w * zoom) / viewScale}px`,
|
||||
height: `${(h * zoom) / viewScale}px`,
|
||||
transform: `rotate(${element.rotate}deg)`,
|
||||
border: `1px solid var(--affine-primary-color)`,
|
||||
};
|
||||
|
||||
@@ -222,6 +222,17 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Equation',
|
||||
showWhen: ({ std }) =>
|
||||
std.store.schema.flavourSchemaMap.has('affine:latex'),
|
||||
icon: TeXIcon(),
|
||||
action: ({ std }) => {
|
||||
std.command.exec(updateBlockType, {
|
||||
flavour: 'affine:latex',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Quote',
|
||||
showWhen: ({ std }) =>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"mammoth": "^1.11.0",
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import {
|
||||
defaultImageProxyMiddleware,
|
||||
docLinkBaseURLMiddleware,
|
||||
fileNameMiddleware,
|
||||
filePathMiddleware,
|
||||
MarkdownAdapter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
|
||||
import { extMimeMap, Transformer } from '@blocksuite/store';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import { createCollectionDocCRUD } from './markdown.js';
|
||||
|
||||
/** Recursive tree node representing a tag-based folder hierarchy. */
|
||||
type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
type BearImportOptions = {
|
||||
collection: Workspace;
|
||||
schema: Schema;
|
||||
imported: Blob;
|
||||
extensions: ExtensionType[];
|
||||
};
|
||||
|
||||
type BearImportResult = {
|
||||
docIds: string[];
|
||||
tags: Map<string, string[]>;
|
||||
folderHierarchy: FolderHierarchy;
|
||||
};
|
||||
|
||||
type BundleEntry = {
|
||||
bundlePath: string;
|
||||
markdownPath: string | null;
|
||||
infoJsonPath: string | null;
|
||||
assetPaths: string[];
|
||||
};
|
||||
|
||||
/** Create a DI provider from the given extensions. */
|
||||
function getProvider(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
extensions.forEach(ext => {
|
||||
ext.setup(container);
|
||||
});
|
||||
return container.provider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bear tags from the trailing footer of a markdown document.
|
||||
* Bear places tags (e.g. `#tag`, `#multi word tag#`, `#nested/tag`) at the end
|
||||
* of notes. This scans from the bottom up, collecting tag-only lines (up to 5)
|
||||
* and returns the deduplicated tags plus the content with those lines removed.
|
||||
*/
|
||||
function parseBearTags(markdown: string): {
|
||||
tags: string[];
|
||||
content: string;
|
||||
} {
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
const codeFenceState: boolean[] = [];
|
||||
let inCodeBlock = false;
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
}
|
||||
codeFenceState.push(inCodeBlock);
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
const tagLineIndices = new Set<number>();
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (codeFenceState[i]) break;
|
||||
|
||||
const lineTags = extractTagsFromLine(line);
|
||||
if (lineTags.length > 0) {
|
||||
for (const tag of lineTags) {
|
||||
tags.push(tag);
|
||||
}
|
||||
tagLineIndices.add(i);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (tagLineIndices.size >= 5) break;
|
||||
}
|
||||
|
||||
const filteredLines = lines.filter((_, i) => !tagLineIndices.has(i));
|
||||
while (
|
||||
filteredLines.length > 0 &&
|
||||
filteredLines[filteredLines.length - 1].trim() === ''
|
||||
) {
|
||||
filteredLines.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
tags: deduplicateTags(tags),
|
||||
content: filteredLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Bear tags from a single line. Supports open tags (`#tag`),
|
||||
* closed tags (`#multi word tag#`), and nested tags (`#parent/child`).
|
||||
* Returns an empty array if the line contains non-tag content.
|
||||
*/
|
||||
function extractTagsFromLine(line: string): string[] {
|
||||
const tags: string[] = [];
|
||||
let remaining = line;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
remaining = remaining.trimStart();
|
||||
if (!remaining) break;
|
||||
|
||||
if (remaining.startsWith('[')) return [];
|
||||
|
||||
if (remaining.startsWith('#')) {
|
||||
if (remaining.length > 1 && remaining[1] === ' ') return [];
|
||||
if (remaining.length > 2 && remaining[1] === '#') return [];
|
||||
|
||||
const closedMatch = remaining.match(/^#([^#\n]+)#/);
|
||||
if (closedMatch) {
|
||||
const tagValue = closedMatch[1].trim();
|
||||
if (tagValue) {
|
||||
tags.push(tagValue);
|
||||
remaining = remaining.slice(closedMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const openMatch = remaining.match(
|
||||
/^#([\p{L}\p{N}_][\p{L}\p{N}_/-]*)(.*)$/u
|
||||
);
|
||||
if (openMatch) {
|
||||
const tagValue = openMatch[1];
|
||||
const after = openMatch[2].trim();
|
||||
if (tagValue) {
|
||||
tags.push(tagValue);
|
||||
remaining = after;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate tags case-insensitively while preserving the original
|
||||
* capitalization of the first occurrence of each tag.
|
||||
*/
|
||||
function deduplicateTags(tags: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const tag of tags) {
|
||||
const normalized = tag.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
result.push(tag);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested folder hierarchy from Bear tags.
|
||||
* Tags like `parent/child` create nested folders. Documents are attached
|
||||
* as leaf nodes under their tag's folder using `__doc__` prefixed keys.
|
||||
*/
|
||||
function buildFolderHierarchyFromTags(
|
||||
tagDocMap: Map<string, string[]>
|
||||
): FolderHierarchy {
|
||||
const root: FolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
for (const [tag, docIds] of tagDocMap) {
|
||||
const parts = tag.split('/');
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
for (const part of parts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
for (const docId of docIds) {
|
||||
const docNodeKey = `__doc__${docId}`;
|
||||
if (!current.children.has(docNodeKey)) {
|
||||
current.children.set(docNodeKey, {
|
||||
name: docNodeKey,
|
||||
path: `${current.path}/${docNodeKey}`,
|
||||
parentPath: current.path,
|
||||
children: new Map(),
|
||||
pageId: docId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
const GFM_CALLOUT_MAP: Record<string, string> = {
|
||||
IMPORTANT: '\u26A0',
|
||||
NOTE: '\uD83D\uDCDD',
|
||||
WARNING: '\u26A0',
|
||||
TIP: '\uD83D\uDCA1',
|
||||
CAUTION: '\uD83D\uDD34',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert GFM-style callouts (`> [!NOTE]`, `> [!WARNING]`, etc.) to
|
||||
* emoji-based callouts that AFFiNE's remark-callout plugin understands.
|
||||
* Skips content inside fenced code blocks.
|
||||
*/
|
||||
function convertGfmCallouts(markdown: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
let inCodeBlock = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (!inCodeBlock) {
|
||||
lines[i] = lines[i].replace(
|
||||
/^(>\s*)\[!(\w+)\]/,
|
||||
(_match, prefix: string, type: string) => {
|
||||
const emoji = GFM_CALLOUT_MAP[type.toUpperCase()];
|
||||
return emoji ? `${prefix}[!${emoji}]` : _match;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function stripBearMetadataComments(markdown: string): string {
|
||||
let current = markdown;
|
||||
while (true) {
|
||||
const next = current.replace(/<!--\s*\{[^}]*\}\s*-->/g, '');
|
||||
if (next === current) {
|
||||
return current;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
|
||||
'\uD83D\uDFE2': 'green',
|
||||
'\uD83D\uDD35': 'blue',
|
||||
'\uD83D\uDFE3': 'purple',
|
||||
'\uD83D\uDD34': 'red',
|
||||
'\uD83D\uDFE1': 'yellow',
|
||||
'\uD83D\uDFE0': 'orange',
|
||||
};
|
||||
|
||||
/** Escape HTML special characters to prevent markup injection. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Bear `==highlight==` syntax to `<mark>` HTML elements.
|
||||
* Supports colored highlights via leading color emoji (e.g. `==🟢green text==`).
|
||||
* Skips content inside fenced code blocks.
|
||||
*/
|
||||
function convertHighlights(markdown: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
let inCodeBlock = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (!inCodeBlock) {
|
||||
lines[i] = lines[i].replace(
|
||||
/==(\S(?:[^=]|=[^=])*?)==/g,
|
||||
(_match, content: string) => {
|
||||
const firstChar = String.fromCodePoint(content.codePointAt(0)!);
|
||||
const color = HIGHLIGHT_COLOR_MAP[firstChar];
|
||||
if (color) {
|
||||
const text = content.slice(firstChar.length);
|
||||
return `<mark data-color="${color}">${escapeHtml(text)}</mark>`;
|
||||
}
|
||||
return `<mark>${escapeHtml(content)}</mark>`;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Extract the document title from the first `# heading` or fall back to the bundle name. */
|
||||
function extractTitle(markdown: string, bundleName: string): string {
|
||||
const lines = markdown.split('\n');
|
||||
let inCodeBlock = false;
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
const match = line.match(/^#\s+(.+)/);
|
||||
if (match) {
|
||||
const title = match[1].trim();
|
||||
if (title) return title;
|
||||
}
|
||||
}
|
||||
return bundleName.replace(/\.textbundle$/i, '') || 'Untitled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a Bear .bear2bk backup file.
|
||||
* Uses JSZip for lazy/streaming decompression to handle large backups.
|
||||
*/
|
||||
async function importBearBackup({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: BearImportOptions): Promise<BearImportResult> {
|
||||
const provider = getProvider(extensions);
|
||||
|
||||
// JSZip reads the zip directory without decompressing all entries
|
||||
const zip = await JSZip.loadAsync(imported);
|
||||
|
||||
// Scan entries and group by textbundle
|
||||
const bundleMap = new Map<string, BundleEntry>();
|
||||
|
||||
zip.forEach((path, _entry) => {
|
||||
if (path.includes('__MACOSX') || path.includes('.DS_Store')) return;
|
||||
|
||||
const tbMatch = path.match(/^(.+?\.textbundle)\/(.*)/i);
|
||||
if (!tbMatch) return;
|
||||
|
||||
const bundlePath = tbMatch[1];
|
||||
const innerPath = tbMatch[2];
|
||||
|
||||
if (!bundleMap.has(bundlePath)) {
|
||||
bundleMap.set(bundlePath, {
|
||||
bundlePath,
|
||||
markdownPath: null,
|
||||
infoJsonPath: null,
|
||||
assetPaths: [],
|
||||
});
|
||||
}
|
||||
const bundle = bundleMap.get(bundlePath)!;
|
||||
|
||||
if (innerPath === 'text.md' || innerPath === 'text.txt') {
|
||||
bundle.markdownPath = path;
|
||||
} else if (innerPath === 'info.json') {
|
||||
bundle.infoJsonPath = path;
|
||||
} else if (innerPath.startsWith('assets/') && innerPath !== 'assets/') {
|
||||
bundle.assetPaths.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Read info.json for all bundles to filter out trashed notes
|
||||
// (info.json is tiny, safe to read all at once)
|
||||
const validBundles: Array<{
|
||||
entry: BundleEntry;
|
||||
bearMeta: Record<string, unknown> | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const entry of bundleMap.values()) {
|
||||
if (!entry.markdownPath) continue;
|
||||
|
||||
let info: Record<string, unknown> = {};
|
||||
if (entry.infoJsonPath) {
|
||||
try {
|
||||
const text = await zip.file(entry.infoJsonPath)!.async('string');
|
||||
info = JSON.parse(text);
|
||||
} catch {
|
||||
// Invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const bearMeta = info['net.shinyfrog.bear'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (bearMeta?.trashed === 1) continue;
|
||||
|
||||
validBundles.push({ entry, bearMeta });
|
||||
}
|
||||
|
||||
if (validBundles.length === 0) {
|
||||
throw new Error(
|
||||
'No valid Bear textbundles found in the archive. Please select a .bear2bk backup file.'
|
||||
);
|
||||
}
|
||||
|
||||
const docIds: string[] = [];
|
||||
const tagDocMap = new Map<string, string[]>();
|
||||
|
||||
// Process bundles sequentially to limit memory.
|
||||
// Each bundle is wrapped in try/catch so one bad note does not abort the
|
||||
// entire import after earlier notes have already been written.
|
||||
for (const { entry, bearMeta } of validBundles) {
|
||||
try {
|
||||
// Read markdown (decompress on demand)
|
||||
const rawMarkdown = await zip.file(entry.markdownPath!)!.async('string');
|
||||
if (!rawMarkdown.trim()) continue;
|
||||
|
||||
const { tags, content: cleanedMarkdown } = parseBearTags(rawMarkdown);
|
||||
const bundleDirName =
|
||||
entry.bundlePath.split('/').findLast(Boolean) ?? 'Untitled';
|
||||
const title = extractTitle(cleanedMarkdown, bundleDirName);
|
||||
const markdown = convertHighlights(
|
||||
convertGfmCallouts(stripBearMetadataComments(cleanedMarkdown))
|
||||
);
|
||||
|
||||
// Read assets on demand (decompress only this bundle's assets)
|
||||
const pendingAssets = new Map<string, File>();
|
||||
const pendingPathBlobIdMap = new Map<string, string>();
|
||||
|
||||
for (const assetFullPath of entry.assetPaths) {
|
||||
try {
|
||||
const data = await zip.file(assetFullPath)!.async('arraybuffer');
|
||||
const tbMatch = assetFullPath.match(/^.+?\.textbundle\/(.*)/i);
|
||||
const assetRelPath = tbMatch ? tbMatch[1] : assetFullPath;
|
||||
const ext = assetRelPath.split('.').at(-1) ?? '';
|
||||
const mime = extMimeMap.get(ext.toLowerCase()) ?? '';
|
||||
const key = await sha(data);
|
||||
// Map both the full zip path and the relative path (assets/...)
|
||||
pendingPathBlobIdMap.set(assetFullPath, key);
|
||||
pendingPathBlobIdMap.set(assetRelPath, key);
|
||||
try {
|
||||
const decodedRel = decodeURIComponent(assetRelPath);
|
||||
if (decodedRel !== assetRelPath) {
|
||||
pendingPathBlobIdMap.set(decodedRel, key);
|
||||
}
|
||||
const decodedFull = decodeURIComponent(assetFullPath);
|
||||
if (decodedFull !== assetFullPath) {
|
||||
pendingPathBlobIdMap.set(decodedFull, key);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URI encoding
|
||||
}
|
||||
const fileName = assetRelPath.split('/').pop() ?? '';
|
||||
pendingAssets.set(key, new File([data], fileName, { type: mime }));
|
||||
} catch {
|
||||
// Failed to read asset, skip
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = `${entry.bundlePath}/text.md`;
|
||||
const job = new Transformer({
|
||||
schema,
|
||||
blobCRUD: collection.blobSync,
|
||||
docCRUD: createCollectionDocCRUD(collection),
|
||||
middlewares: [
|
||||
defaultImageProxyMiddleware,
|
||||
fileNameMiddleware(title),
|
||||
filePathMiddleware(fullPath),
|
||||
docLinkBaseURLMiddleware(collection.id),
|
||||
],
|
||||
});
|
||||
|
||||
const assets = job.assets;
|
||||
const pathBlobIdMap = job.assetsManager.getPathBlobIdMap();
|
||||
for (const [p, key] of pendingPathBlobIdMap.entries()) {
|
||||
pathBlobIdMap.set(p, key);
|
||||
}
|
||||
for (const [key, file] of pendingAssets.entries()) {
|
||||
assets.set(key, file);
|
||||
}
|
||||
|
||||
const mdAdapter = new MarkdownAdapter(job, provider);
|
||||
const doc = await mdAdapter.toDoc({
|
||||
file: markdown,
|
||||
assets: job.assetsManager,
|
||||
});
|
||||
|
||||
if (doc) {
|
||||
docIds.push(doc.id);
|
||||
|
||||
const metaPatch: Record<string, unknown> = {};
|
||||
if (bearMeta?.creationDate) {
|
||||
const ts = Date.parse(String(bearMeta.creationDate));
|
||||
if (!isNaN(ts)) metaPatch.createDate = ts;
|
||||
}
|
||||
if (bearMeta?.modificationDate) {
|
||||
const ts = Date.parse(String(bearMeta.modificationDate));
|
||||
if (!isNaN(ts)) metaPatch.updatedDate = ts;
|
||||
}
|
||||
if (Object.keys(metaPatch).length) {
|
||||
collection.meta.setDocMeta(doc.id, metaPatch);
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!tagDocMap.has(tag)) {
|
||||
tagDocMap.set(tag, []);
|
||||
}
|
||||
tagDocMap.get(tag)!.push(doc.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to import bundle: ${entry.bundlePath}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const folderHierarchy = buildFolderHierarchyFromTags(tagDocMap);
|
||||
return { docIds, tags: tagDocMap, folderHierarchy };
|
||||
}
|
||||
|
||||
/** Public API for importing Bear .bear2bk backup archives. */
|
||||
export const BearTransformer = {
|
||||
importBearBackup,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { BearTransformer } from './bear.js';
|
||||
export { DocxTransformer } from './docx.js';
|
||||
export { HtmlTransformer } from './html.js';
|
||||
export { MarkdownTransformer } from './markdown.js';
|
||||
|
||||
@@ -462,12 +462,23 @@ async function importMarkdownToDoc({
|
||||
* @param options.imported The zip file as a Blob
|
||||
* @returns A Promise that resolves to an array of IDs of the newly created docs
|
||||
*/
|
||||
type FolderHierarchy = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: Map<string, FolderHierarchy>;
|
||||
pageId?: string;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
async function importMarkdownZip({
|
||||
collection,
|
||||
schema,
|
||||
imported,
|
||||
extensions,
|
||||
}: ImportMarkdownZipOptions) {
|
||||
}: ImportMarkdownZipOptions): Promise<{
|
||||
docIds: string[];
|
||||
folderHierarchy?: FolderHierarchy;
|
||||
}> {
|
||||
const provider = getProvider(extensions);
|
||||
const unzip = new Unzip();
|
||||
await unzip.load(imported);
|
||||
@@ -476,6 +487,7 @@ async function importMarkdownZip({
|
||||
const pendingAssets: AssetMap = new Map();
|
||||
const pendingPathBlobIdMap: PathBlobIdMap = new Map();
|
||||
const markdownBlobs: ImportedFileEntry[] = [];
|
||||
const docPathMap: Array<{ fullPath: string; docId: string }> = [];
|
||||
|
||||
// Iterate over all files in the zip
|
||||
for (const { path, content: blob } of unzip) {
|
||||
@@ -527,10 +539,94 @@ async function importMarkdownZip({
|
||||
if (doc) {
|
||||
applyMetaPatch(collection, doc.id, meta);
|
||||
docIds.push(doc.id);
|
||||
docPathMap.push({ fullPath, docId: doc.id });
|
||||
}
|
||||
})
|
||||
);
|
||||
return docIds;
|
||||
|
||||
// Build folder hierarchy from zip paths
|
||||
const folderHierarchy = buildMarkdownZipFolderHierarchy(docPathMap);
|
||||
|
||||
return { docIds, folderHierarchy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree of {@link FolderHierarchy} nodes from the zip paths of
|
||||
* imported markdown files. Returns `undefined` when every entry sits at
|
||||
* the same level (no real subfolder structure). A common root directory
|
||||
* shared by all entries is stripped automatically so that the resulting
|
||||
* hierarchy starts one level deeper.
|
||||
*/
|
||||
function buildMarkdownZipFolderHierarchy(
|
||||
entries: Array<{ fullPath: string; docId: string }>
|
||||
): FolderHierarchy | undefined {
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
// Check if any entries have folder structure
|
||||
const hasSubfolders = entries.some(e => {
|
||||
const parts = e.fullPath.split('/').filter(Boolean);
|
||||
// More than just "root/file.md" -- need at least one real subfolder
|
||||
return parts.length > 2;
|
||||
});
|
||||
if (!hasSubfolders) {
|
||||
// All files are at the same level, no folder hierarchy needed
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const root: FolderHierarchy = {
|
||||
name: '',
|
||||
path: '',
|
||||
children: new Map(),
|
||||
};
|
||||
|
||||
// Check once whether all entries share a common root directory
|
||||
const candidateRoot = entries[0]?.fullPath.split('/').find(Boolean);
|
||||
const skipRoot =
|
||||
!!candidateRoot &&
|
||||
entries.every(e => e.fullPath.startsWith(candidateRoot + '/'));
|
||||
|
||||
for (const { fullPath, docId } of entries) {
|
||||
const parts = fullPath.split('/').filter(Boolean);
|
||||
const fileName = parts.pop(); // Remove filename
|
||||
if (!fileName) continue;
|
||||
|
||||
let folderParts = skipRoot ? parts.slice(1) : parts;
|
||||
|
||||
if (folderParts.length === 0) {
|
||||
// Root-level file, no folder needed
|
||||
continue;
|
||||
}
|
||||
|
||||
let current = root;
|
||||
let currentPath = '';
|
||||
|
||||
for (const folderName of folderParts) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
|
||||
if (!current.children.has(folderName)) {
|
||||
current.children.set(folderName, {
|
||||
name: folderName,
|
||||
path: currentPath,
|
||||
parentPath: parentPath || undefined,
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
current = current.children.get(folderName)!;
|
||||
}
|
||||
|
||||
// Add the doc as a leaf
|
||||
const docNodeKey = `__doc__${docId}`;
|
||||
current.children.set(docNodeKey, {
|
||||
name: docNodeKey,
|
||||
path: `${current.path}/${docNodeKey}`,
|
||||
parentPath: current.path,
|
||||
children: new Map(),
|
||||
pageId: docId,
|
||||
});
|
||||
}
|
||||
|
||||
return root.children.size > 0 ? root : undefined;
|
||||
}
|
||||
|
||||
export const MarkdownTransformer = {
|
||||
|
||||
@@ -148,13 +148,14 @@ export class EdgelessRemoteSelectionWidget extends WidgetComponent<RootBlockMode
|
||||
};
|
||||
|
||||
private readonly _updateTransform = requestThrottledConnectedFrame(() => {
|
||||
const { translateX, translateY, zoom } = this.gfx.viewport;
|
||||
const { translateX, translateY, zoom, viewScale } = this.gfx.viewport;
|
||||
|
||||
this.style.setProperty('--v-zoom', `${zoom}`);
|
||||
// Compensate for outer CSS scale, matching GfxBlockComponent.getCSSTransform.
|
||||
this.style.setProperty('--v-zoom', `${zoom / viewScale}`);
|
||||
|
||||
this.style.setProperty(
|
||||
'transform',
|
||||
`translate(${translateX}px, ${translateY}px) scale(var(--v-zoom))`
|
||||
`translate(${translateX / viewScale}px, ${translateY / viewScale}px) scale(var(--v-zoom))`
|
||||
);
|
||||
}, this);
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.vitepress/cache
|
||||
.vitepress/dist
|
||||
.vitepress/.temp
|
||||
api/
|
||||
@@ -0,0 +1,246 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import container from 'markdown-it-container';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import { defineConfig } from 'vitepress';
|
||||
import { renderSandbox } from 'vitepress-plugin-sandpack';
|
||||
|
||||
import { components, guide, reference } from './sidebar';
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
// FIXME: remove dead links
|
||||
ignoreDeadLinks: true,
|
||||
|
||||
title: 'BlockSuite',
|
||||
description: 'Content Editing Tech Stack for the Web',
|
||||
vite: {
|
||||
build: {
|
||||
target: 'ES2022',
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
{
|
||||
name: 'redirect-plugin',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === '/blocksuite-overview.html') {
|
||||
res.writeHead(301, { Location: '/guide/overview.html' });
|
||||
res.end();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
lang: 'en-US',
|
||||
head: [
|
||||
[
|
||||
'link',
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
sizes: '32x32',
|
||||
href: 'https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg',
|
||||
},
|
||||
],
|
||||
['meta', { property: 'twitter:card', content: 'summary_large_image' }],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'twitter:image',
|
||||
content:
|
||||
'https://raw.githubusercontent.com/toeverything/blocksuite/master/packages/docs/images/blocksuite-cover.jpg',
|
||||
},
|
||||
],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'og:image',
|
||||
content:
|
||||
'https://raw.githubusercontent.com/toeverything/blocksuite/master/packages/docs/images/blocksuite-cover.jpg',
|
||||
},
|
||||
],
|
||||
],
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
outline: [2, 3],
|
||||
|
||||
nav: [
|
||||
{
|
||||
text: 'Components',
|
||||
link: '/components/overview',
|
||||
activeMatch: '/components/*',
|
||||
},
|
||||
{
|
||||
text: 'Framework',
|
||||
link: '/guide/overview',
|
||||
activeMatch: '/guide/*',
|
||||
},
|
||||
{
|
||||
text: 'Playground',
|
||||
link: 'https://try-blocksuite.vercel.app/?init',
|
||||
},
|
||||
{
|
||||
text: 'More',
|
||||
items: [
|
||||
{ text: 'Blog', link: '/blog/', activeMatch: '/blog/*' },
|
||||
{
|
||||
text: 'API',
|
||||
link: '/api/',
|
||||
activeMatch: '/api/*',
|
||||
},
|
||||
{
|
||||
text: 'Releases',
|
||||
link: 'https://github.com/toeverything/blocksuite/releases',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
'/guide/': { base: '/', items: guide },
|
||||
'/api/': { base: '/', items: reference },
|
||||
'/components/': { base: '/', items: components },
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/toeverything/blocksuite' },
|
||||
{
|
||||
icon: {
|
||||
svg: '<svg role="img" xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path fill="#777777" d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z"/></svg>',
|
||||
},
|
||||
link: 'https://twitter.com/AffineDev',
|
||||
},
|
||||
],
|
||||
|
||||
footer: {
|
||||
copyright: 'Copyright © 2022-present Toeverything',
|
||||
},
|
||||
|
||||
search: {
|
||||
provider: 'local',
|
||||
options: {
|
||||
_render(src, env, md) {
|
||||
if (env.relativePath.startsWith('api/')) {
|
||||
return '';
|
||||
}
|
||||
return md.render(src, env);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
config(md) {
|
||||
md.use(container, 'code-sandbox', {
|
||||
render(tokens, idx) {
|
||||
return renderSandbox(tokens, idx, 'code-sandbox');
|
||||
},
|
||||
});
|
||||
rewriteApiMemberLinks(md);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const apiMemberLinkPattern =
|
||||
/^\/api\/@blocksuite\/(.+)\/(?:classes|enumerations|functions|interfaces|type-aliases|variables)\/([^/?#]+)(?:\.html)?((?:\?[^#]*)?(?:#.*)?)?$/;
|
||||
|
||||
function rewriteApiMemberLinks(md: MarkdownIt) {
|
||||
const apiMemberTargets = getApiMemberTargets();
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ??
|
||||
((tokens, idx, options, _env, self) =>
|
||||
self.renderToken(tokens, idx, options));
|
||||
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx];
|
||||
const hrefIndex = token.attrIndex('href');
|
||||
|
||||
if (hrefIndex >= 0 && token.attrs) {
|
||||
token.attrs[hrefIndex][1] = rewriteApiMemberLink(
|
||||
token.attrs[hrefIndex][1],
|
||||
apiMemberTargets
|
||||
);
|
||||
}
|
||||
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteApiMemberLink(
|
||||
href: string,
|
||||
apiMemberTargets: Map<string, string>
|
||||
) {
|
||||
const match = href.match(apiMemberLinkPattern);
|
||||
|
||||
if (!match) {
|
||||
return href;
|
||||
}
|
||||
|
||||
const [, packagePath, memberFileName, suffix = ''] = match;
|
||||
const target = apiMemberTargets.get(decodeURIComponent(memberFileName));
|
||||
|
||||
if (target) {
|
||||
return `${target}${suffix}`;
|
||||
}
|
||||
|
||||
return `/api/@blocksuite/${packagePath}.html${suffix}`;
|
||||
}
|
||||
|
||||
function getApiMemberTargets() {
|
||||
const apiDir = join(process.cwd(), 'api');
|
||||
const targets = new Map<string, string>();
|
||||
|
||||
if (!existsSync(apiDir)) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
for (const file of findMarkdownFiles(apiDir)) {
|
||||
const route = `/api/${relative(apiDir, file)
|
||||
.replace(/\.md$/, '.html')
|
||||
.split(sep)
|
||||
.join('/')}`;
|
||||
|
||||
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
||||
const member = line.match(/^### (.+)$/);
|
||||
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = getApiMemberName(member[1]);
|
||||
|
||||
if (name && !targets.has(name)) {
|
||||
targets.set(name, route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function findMarkdownFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const path = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return findMarkdownFiles(path);
|
||||
}
|
||||
|
||||
return entry.isFile() && entry.name.endsWith('.md') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function getApiMemberName(heading: string) {
|
||||
return heading
|
||||
.replaceAll('`', '')
|
||||
.replace(/\\([<>])/g, '$1')
|
||||
.replace(/^(abstract|readonly)\s+/, '')
|
||||
.replace(/\(\)$/, '')
|
||||
.replace(/<.*>$/, '')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join, parse } from 'node:path';
|
||||
|
||||
import type { DefaultTheme } from 'vitepress';
|
||||
|
||||
export const guide: DefaultTheme.NavItem[] = [
|
||||
{
|
||||
text: 'Introduction',
|
||||
items: [
|
||||
{ text: 'Overview', link: 'guide/overview' },
|
||||
{ text: 'Quick Start', link: 'guide/quick-start' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Framework Guide',
|
||||
items: [
|
||||
{ text: 'Component Types', link: 'guide/component-types' },
|
||||
{
|
||||
text: 'Working with Block Tree',
|
||||
// @ts-expect-error nested items are supported by VitePress at runtime
|
||||
link: 'guide/working-with-block-tree',
|
||||
items: [
|
||||
{
|
||||
text: 'Block Tree Basics',
|
||||
link: 'guide/working-with-block-tree#block-tree-basics',
|
||||
},
|
||||
{
|
||||
text: 'Block Tree in Editor',
|
||||
link: 'guide/working-with-block-tree#block-tree-in-editor',
|
||||
},
|
||||
{
|
||||
text: 'Selecting Blocks',
|
||||
link: 'guide/working-with-block-tree#selecting-blocks',
|
||||
},
|
||||
{
|
||||
text: 'Service and Commands',
|
||||
link: 'guide/working-with-block-tree#service-and-commands',
|
||||
},
|
||||
{
|
||||
text: 'Defining New Blocks',
|
||||
link: 'guide/working-with-block-tree#defining-new-blocks',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: 'Data Synchronization', link: 'guide/data-synchronization' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Framework Handbook',
|
||||
items: [
|
||||
{
|
||||
text: '<code>block-std</code>',
|
||||
items: [
|
||||
{
|
||||
text: 'Block Spec',
|
||||
link: 'guide/block-spec',
|
||||
// @ts-expect-error nested items are supported by VitePress at runtime
|
||||
items: [
|
||||
{ text: 'Block Schema', link: 'guide/block-schema' },
|
||||
{ text: 'Block Service', link: 'guide/block-service' },
|
||||
{ text: 'Block View', link: 'guide/block-view' },
|
||||
{ text: 'Block Widgets', link: 'guide/block-widgets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Selection',
|
||||
link: 'guide/selection',
|
||||
},
|
||||
{ text: 'Event', link: 'guide/event' },
|
||||
{ text: 'Command', link: 'guide/command' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '<code>store</code>',
|
||||
items: [
|
||||
{ text: 'Doc', link: 'guide/store#doc' },
|
||||
{ text: 'DocCollection', link: 'guide/store#doccollection' },
|
||||
{ text: 'Slot', link: 'guide/slot' },
|
||||
{ text: 'Adapter', link: 'guide/adapter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '<code>inline</code>',
|
||||
link: 'guide/inline',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Developing BlockSuite',
|
||||
items: [
|
||||
{
|
||||
text: 'Building Packages',
|
||||
link: '//github.com/toeverything/blocksuite/blob/master/BUILDING.md',
|
||||
},
|
||||
{
|
||||
text: 'Running Tests',
|
||||
link: '//github.com/toeverything/blocksuite/blob/master/BUILDING.md#testing',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const reference: DefaultTheme.NavItem[] = [
|
||||
{
|
||||
text: 'API Reference',
|
||||
items: getApiReferenceItems(),
|
||||
},
|
||||
];
|
||||
|
||||
function getApiReferenceItems(): DefaultTheme.NavItem[] {
|
||||
const apiDir = join(process.cwd(), 'api', '@blocksuite');
|
||||
|
||||
if (!existsSync(apiDir)) {
|
||||
return [
|
||||
{ text: '@blocksuite/store', link: 'api/@blocksuite/store' },
|
||||
{ text: '@blocksuite/std', link: 'api/@blocksuite/std/index' },
|
||||
{ text: '@blocksuite/affine', link: 'api/@blocksuite/affine' },
|
||||
];
|
||||
}
|
||||
|
||||
return readdirSync(apiDir, { withFileTypes: true })
|
||||
.flatMap(entry => {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
const name = parse(entry.name).name;
|
||||
return [
|
||||
{ text: `@blocksuite/${name}`, link: `api/@blocksuite/${name}` },
|
||||
];
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const indexPath = join(apiDir, entry.name, 'index.md');
|
||||
|
||||
if (existsSync(indexPath)) {
|
||||
return [
|
||||
{
|
||||
text: `@blocksuite/${entry.name}`,
|
||||
link: `api/@blocksuite/${entry.name}/index`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
.sort((a, b) => a.text.localeCompare(b.text));
|
||||
}
|
||||
|
||||
export const components: DefaultTheme.NavItem[] = [
|
||||
{
|
||||
text: 'Introduction',
|
||||
items: [{ text: 'Overview', link: 'components/overview' }],
|
||||
},
|
||||
{
|
||||
text: 'Editors',
|
||||
items: [
|
||||
{ text: '📝 Page Editor', link: 'components/editors/page-editor' },
|
||||
{
|
||||
text: '🎨 Edgeless Editor',
|
||||
// @ts-expect-error nested items are supported by VitePress at runtime
|
||||
link: 'components/editors/edgeless-editor',
|
||||
items: [
|
||||
{
|
||||
text: 'Data Structure',
|
||||
link: 'components/editors/edgeless-data-structure',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Blocks',
|
||||
items: [
|
||||
{
|
||||
text: 'Regular Blocks',
|
||||
items: [
|
||||
{ text: 'Root Block', link: 'components/blocks/root-block' },
|
||||
{ text: 'Note Block', link: 'components/blocks/note-block' },
|
||||
{
|
||||
text: 'Paragraph Block',
|
||||
link: 'components/blocks/paragraph-block',
|
||||
},
|
||||
{ text: 'List Block', link: 'components/blocks/list-block' },
|
||||
{ text: 'Code Block', link: 'components/blocks/code-block' },
|
||||
{ text: 'Image Block', link: 'components/blocks/image-block' },
|
||||
{
|
||||
text: 'Attachment Block',
|
||||
link: 'components/blocks/attachment-block',
|
||||
},
|
||||
{ text: 'Divider Block', link: 'components/blocks/divider-block' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Advanced Blocks',
|
||||
items: [
|
||||
{ text: 'Surface Block', link: 'components/blocks/surface-block' },
|
||||
{
|
||||
text: 'Database Block',
|
||||
link: 'components/blocks/database-block',
|
||||
},
|
||||
{ text: 'Frame Block', link: 'components/blocks/frame-block' },
|
||||
{ text: 'Link Blocks', link: 'components/blocks/link-blocks' },
|
||||
{ text: 'Embed Blocks', link: 'components/blocks/embed-blocks' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Widgets 🚧',
|
||||
items: [
|
||||
{ text: 'Slash Menu', link: 'components/widgets/slash-menu' },
|
||||
{ text: 'Format Bar', link: 'components/widgets/format-bar' },
|
||||
{ text: 'Drag Handle', link: 'components/widgets/drag-handle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Fragments 🚧',
|
||||
items: [
|
||||
{ text: 'Doc Title', link: 'components/fragments/doc-title' },
|
||||
{ text: 'Outline Panel', link: 'components/fragments/outline-panel' },
|
||||
{ text: 'Frame Panel', link: 'components/fragments/frame-panel' },
|
||||
{ text: 'Copilot Panel', link: 'components/fragments/copilot-panel' },
|
||||
{
|
||||
text: 'Bi-Directional Link Panel',
|
||||
link: 'components/fragments/bi-directional-link-panel',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<h1>BlockSuite Blog</h1>
|
||||
<div class="blog-posts-container">
|
||||
<div class="blog-post" v-for="post in posts">
|
||||
<a :href="post.url">
|
||||
<h2 class="blog-post-title">{{ post.title }}</h2>
|
||||
</a>
|
||||
<div class="blog-post-excerpt">
|
||||
{{ post.excerpt }}
|
||||
<a class="blog-post-read-more" :href="post.url">Read more →</a>
|
||||
</div>
|
||||
<div class="blog-post-date">{{ post.date.formatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePosts } from '../composables/use-posts';
|
||||
|
||||
const { posts } = usePosts();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
margin: auto;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 50px;
|
||||
font-size: 50px;
|
||||
font-weight: bolder;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.blog-posts-container {
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.blog-post-date {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.blog-post-excerpt {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dark .blog-post {
|
||||
border-bottom: 1px solid #343a40;
|
||||
}
|
||||
|
||||
.blog-post-read-more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="blog-post-meta">
|
||||
<span class="post-date">{{ post.date.formatted }}</span> by
|
||||
<span v-html="formattedAuthors"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { usePosts } from '../composables/use-posts';
|
||||
|
||||
const { post } = usePosts();
|
||||
|
||||
const formattedAuthors = computed(() => {
|
||||
return post.value.authors
|
||||
.map(
|
||||
author =>
|
||||
`<a class="author" href="${author.link}" target="_blank">${author.name}</a>`
|
||||
)
|
||||
.join(', ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.blog-post-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.author {
|
||||
color: var(--vp-c-text-1) !important;
|
||||
}
|
||||
.post-date {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<!-- 'code-options' is a build-in prop, do not edit it -->
|
||||
<Sandbox
|
||||
:rtl="rtl"
|
||||
:template="'vanilla-ts'"
|
||||
:light-theme="lightTheme"
|
||||
:dark-theme="darkTheme"
|
||||
:options="{
|
||||
...props, // do not forget it
|
||||
coderHeight: Number(props.coderHeight),
|
||||
previewHeight: Number(props.previewHeight),
|
||||
showLineNumbers: true,
|
||||
}"
|
||||
:custom-setup="{
|
||||
...props, // do not forget it
|
||||
deps: {
|
||||
yjs: 'latest',
|
||||
'@toeverything/theme': 'latest',
|
||||
'@blocksuite/presets': 'canary',
|
||||
},
|
||||
}"
|
||||
:code-options="codeOptions"
|
||||
>
|
||||
<slot />
|
||||
</Sandbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Sandbox, sandboxProps } from 'vitepress-plugin-sandpack';
|
||||
|
||||
const props = defineProps({
|
||||
...sandboxProps,
|
||||
coderHeight: String,
|
||||
previewHeight: String,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<img
|
||||
style="width: 70%; height: 100%; margin: auto; opacity: 0.8"
|
||||
src="https://raw.githubusercontent.com/toeverything/blocksuite/master/assets/logo.svg"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
icon?: string;
|
||||
}>();
|
||||
|
||||
const src = computed(() => {
|
||||
if (props.icon) return props.icon;
|
||||
return `https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/${props.name.toLowerCase()}.svg`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="src" :alt="`${name} Logo`" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
display: inline;
|
||||
transform: translateY(5px);
|
||||
margin-right: 8px;
|
||||
width: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { format, formatDistance } from 'date-fns';
|
||||
import { createContentLoader } from 'vitepress';
|
||||
|
||||
interface Post {
|
||||
title: string;
|
||||
authors: { name: string; link: string }[];
|
||||
url: string;
|
||||
date: {
|
||||
raw: string;
|
||||
time: number;
|
||||
formatted: string;
|
||||
since: string;
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(raw: string) {
|
||||
const date = new Date(raw);
|
||||
date.setUTCHours(8);
|
||||
return {
|
||||
raw: date.toISOString().split('T')[0],
|
||||
time: +date,
|
||||
formatted: format(date, 'yyyy/MM/dd'),
|
||||
since: formatDistance(date, new Date(), { addSuffix: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const data = [] as Post[];
|
||||
export { data };
|
||||
|
||||
export default createContentLoader('blog/*.md', {
|
||||
includeSrc: true,
|
||||
transform(raw) {
|
||||
return raw
|
||||
.filter(item => item.url !== '/blog/')
|
||||
.map(({ url, frontmatter }) => ({
|
||||
title: frontmatter.title,
|
||||
authors: frontmatter.authors ?? [],
|
||||
excerpt: frontmatter.excerpt ?? '',
|
||||
url,
|
||||
date: formatDate(frontmatter.date),
|
||||
}))
|
||||
.sort((a, b) => b.date.time - a.date.time);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useRoute } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { data as posts } from './posts.data';
|
||||
|
||||
export function usePosts() {
|
||||
const route = useRoute();
|
||||
const path = route.path;
|
||||
|
||||
function findCurrentIndex() {
|
||||
const result = posts.findIndex(p => p.url === route.path);
|
||||
if (result === -1) console.error(`blog post missing: ${route.path}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const post = computed(() => posts[findCurrentIndex()]);
|
||||
|
||||
return { posts, post, path };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// https://vitepress.dev/guide/custom-theme
|
||||
import 'vitepress-plugin-sandpack/dist/style.css';
|
||||
import './style.css';
|
||||
|
||||
import Theme from 'vitepress/theme';
|
||||
import { h } from 'vue';
|
||||
|
||||
import BlogListLayout from './components/blog-list-layout.vue';
|
||||
import BlogPostMeta from './components/blog-post-meta.vue';
|
||||
import CodeSandbox from './components/code-sandbox.vue';
|
||||
import HeroLogo from './components/hero-logo.vue';
|
||||
import Icon from './components/icon.vue';
|
||||
|
||||
export default {
|
||||
...Theme,
|
||||
Layout: () => {
|
||||
return h(Theme.Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
'home-hero-image': () => h(HeroLogo),
|
||||
// 'home-features-after': () => h(Playground),
|
||||
});
|
||||
},
|
||||
enhanceApp({ app }) {
|
||||
app.component('Icon', Icon);
|
||||
app.component('BlogListLayout', BlogListLayout);
|
||||
app.component('BlogPostMeta', BlogPostMeta);
|
||||
app.component('CodeSandbox', CodeSandbox);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Customize default theme styling by overriding CSS variables:
|
||||
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
|
||||
*/
|
||||
|
||||
/**
|
||||
* Colors
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-c-brand: #646cff;
|
||||
--vp-c-brand-light: #747bff;
|
||||
--vp-c-brand-lighter: #9499ff;
|
||||
--vp-c-brand-lightest: #bcc0ff;
|
||||
--vp-c-brand-dark: #535bf2;
|
||||
--vp-c-brand-darker: #454ce1;
|
||||
--vp-c-brand-dimm: rgba(100, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Button
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-button-brand-border: var(--vp-c-brand-light);
|
||||
--vp-button-brand-text: var(--vp-c-white);
|
||||
--vp-button-brand-bg: var(--vp-c-brand);
|
||||
--vp-button-brand-hover-border: var(--vp-c-brand-light);
|
||||
--vp-button-brand-hover-text: var(--vp-c-white);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-light);
|
||||
--vp-button-brand-active-border: var(--vp-c-brand-light);
|
||||
--vp-button-brand-active-text: var(--vp-c-white);
|
||||
--vp-button-brand-active-bg: var(--vp-button-brand-bg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Home
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(
|
||||
120deg,
|
||||
#bd34fe 30%,
|
||||
#41d1ff
|
||||
);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(
|
||||
-45deg,
|
||||
#bd34fe 50%,
|
||||
#47caff 50%
|
||||
);
|
||||
--vp-home-hero-image-filter: blur(40px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(72px);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Custom Block
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-custom-block-tip-border: var(--vp-c-brand);
|
||||
--vp-custom-block-tip-text: var(--vp-c-brand-darker);
|
||||
--vp-custom-block-tip-bg: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-custom-block-tip-border: var(--vp-c-brand);
|
||||
--vp-custom-block-tip-text: var(--vp-c-brand-lightest);
|
||||
--vp-custom-block-tip-bg: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Algolia
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.DocSearch {
|
||||
--docsearch-primary-color: var(--vp-c-brand) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* VitePress: Custom fix
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
Use lighter colors for links in dark mode for a11y.
|
||||
Also specify some classes twice to have higher specificity
|
||||
over scoped class data attribute.
|
||||
*/
|
||||
.dark .vp-doc a,
|
||||
.dark .vp-doc a > code,
|
||||
.dark .VPNavBarMenuLink.VPNavBarMenuLink:hover,
|
||||
.dark .VPNavBarMenuLink.VPNavBarMenuLink.active,
|
||||
.dark .link.link:hover,
|
||||
.dark .link.link.active,
|
||||
.dark .edit-link-button.edit-link-button,
|
||||
.dark .pager-link .title {
|
||||
color: var(--vp-c-brand-lighter);
|
||||
}
|
||||
|
||||
.dark .vp-doc a:hover,
|
||||
.dark .vp-doc a > code:hover {
|
||||
color: var(--vp-c-brand-lightest);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Transition by color instead of opacity */
|
||||
.dark .vp-doc .custom-block a {
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
html[class='dark'] {
|
||||
--affine-theme-mode: dark;
|
||||
|
||||
--affine-popover-shadow:
|
||||
0px 1px 10px -6px rgba(24, 39, 75, 0.08),
|
||||
0px 3px 16px -6px rgba(24, 39, 75, 0.04);
|
||||
--affine-font-h-1: 28px;
|
||||
--affine-font-h-2: 26px;
|
||||
--affine-font-h-3: 24px;
|
||||
--affine-font-h-4: 22px;
|
||||
--affine-font-h-5: 20px;
|
||||
--affine-font-h-6: 18px;
|
||||
--affine-font-base: 16px;
|
||||
--affine-font-sm: 14px;
|
||||
--affine-font-xs: 12px;
|
||||
--affine-line-height: calc(1em + 8px);
|
||||
--affine-z-index-modal: 1000;
|
||||
--affine-z-index-popover: 1000;
|
||||
--affine-font-family:
|
||||
Avenir Next, Poppins, apple-system, BlinkMacSystemFont, Helvetica Neue,
|
||||
Tahoma, PingFang SC, Microsoft Yahei, Arial, Hiragino Sans GB, sans-serif,
|
||||
Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
--affine-font-number-family:
|
||||
Roboto Mono, apple-system, BlinkMacSystemFont, Helvetica Neue, Tahoma,
|
||||
PingFang SC, Microsoft Yahei, Arial, Hiragino Sans GB, sans-serif,
|
||||
Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
--affine-font-code-family:
|
||||
Space Mono, Consolas, Menlo, Monaco, Courier, monospace, apple-system,
|
||||
BlinkMacSystemFont, Helvetica Neue, Tahoma, PingFang SC, Microsoft Yahei,
|
||||
Arial, Hiragino Sans GB, sans-serif, Apple Color Emoji, Segoe UI Emoji,
|
||||
Segoe UI Symbol, Noto Color Emoji;
|
||||
--affine-paragraph-space: 8px;
|
||||
--affine-popover-radius: 10px;
|
||||
--affine-zoom: 1;
|
||||
--affine-scale: calc(1 / var(--affine-zoom));
|
||||
|
||||
--affine-brand-color: rgb(84, 56, 255);
|
||||
--affine-primary-color: rgb(118, 95, 254);
|
||||
--affine-secondary-color: rgb(144, 150, 245);
|
||||
--affine-tertiary-color: rgb(30, 30, 30);
|
||||
--affine-hover-color: rgba(255, 255, 255, 0.1);
|
||||
--affine-icon-color: rgb(168, 168, 160);
|
||||
--affine-border-color: rgb(57, 57, 57);
|
||||
--affine-divider-color: rgb(114, 114, 114);
|
||||
--affine-placeholder-color: rgb(62, 62, 63);
|
||||
--affine-quote-color: rgb(100, 95, 130);
|
||||
--affine-link-color: rgb(185, 191, 227);
|
||||
--affine-edgeless-grid-color: rgb(49, 49, 49);
|
||||
--affine-success-color: rgb(77, 213, 181);
|
||||
--affine-warning-color: rgb(255, 123, 77);
|
||||
--affine-error-color: rgb(212, 140, 130);
|
||||
--affine-processing-color: rgb(195, 215, 255);
|
||||
--affine-text-emphasis-color: rgb(208, 205, 220);
|
||||
--affine-text-primary-color: rgb(234, 234, 234);
|
||||
--affine-text-secondary-color: rgb(156, 156, 160);
|
||||
--affine-text-disable-color: rgb(119, 117, 125);
|
||||
--affine-black-10: rgba(255, 255, 255, 0.1);
|
||||
--affine-black-30: rgba(255, 255, 255, 0.3);
|
||||
--affine-black-50: rgba(255, 255, 255, 0.5);
|
||||
--affine-black-60: rgba(255, 255, 255, 0.6);
|
||||
--affine-black-80: rgba(255, 255, 255, 0.8);
|
||||
--affine-black-90: rgba(255, 255, 255, 0.9);
|
||||
--affine-black: rgb(255, 255, 255);
|
||||
--affine-white-10: rgba(0, 0, 0, 0.1);
|
||||
--affine-white-30: rgba(0, 0, 0, 0.3);
|
||||
--affine-white-50: rgba(0, 0, 0, 0.5);
|
||||
--affine-white-60: rgba(0, 0, 0, 0.6);
|
||||
--affine-white-80: rgba(0, 0, 0, 0.8);
|
||||
--affine-white-90: rgba(0, 0, 0, 0.9);
|
||||
--affine-white: rgb(0, 0, 0);
|
||||
--affine-background-code-block: rgb(41, 44, 51);
|
||||
--affine-background-tertiary-color: rgb(30, 30, 30);
|
||||
--affine-background-processing-color: rgb(255, 255, 255);
|
||||
--affine-background-error-color: rgb(255, 255, 255);
|
||||
--affine-background-warning-color: rgb(255, 255, 255);
|
||||
--affine-background-success-color: rgb(255, 255, 255);
|
||||
--affine-background-primary-color: rgb(20, 20, 20);
|
||||
--affine-background-hover-color: rgb(47, 47, 47);
|
||||
--affine-background-secondary-color: rgb(32, 32, 32);
|
||||
--affine-background-modal-color: rgba(0, 0, 0, 0.8);
|
||||
--affine-background-overlay-panel-color: rgb(30, 30, 30);
|
||||
--affine-tag-blue: rgb(10, 84, 170);
|
||||
--affine-tag-green: rgb(55, 135, 79);
|
||||
--affine-tag-teal: rgb(33, 145, 138);
|
||||
--affine-tag-white: rgb(84, 84, 84);
|
||||
--affine-tag-purple: rgb(59, 38, 141);
|
||||
--affine-tag-red: rgb(139, 63, 63);
|
||||
--affine-tag-pink: rgb(194, 132, 132);
|
||||
--affine-tag-yellow: rgb(187, 165, 61);
|
||||
--affine-tag-orange: rgb(231, 161, 58);
|
||||
--affine-tag-gray: rgb(41, 41, 41);
|
||||
--affine-palette-yellow: rgb(255, 232, 56);
|
||||
--affine-palette-orange: rgb(255, 175, 56);
|
||||
--affine-palette-tangerine: rgb(255, 99, 31);
|
||||
--affine-palette-red: rgb(252, 63, 85);
|
||||
--affine-palette-magenta: rgb(255, 56, 179);
|
||||
--affine-palette-purple: rgb(182, 56, 255);
|
||||
--affine-palette-navy: rgb(59, 37, 204);
|
||||
--affine-palette-blue: rgb(79, 144, 255);
|
||||
--affine-palette-green: rgb(16, 203, 134);
|
||||
--affine-palette-grey: rgb(153, 153, 153);
|
||||
--affine-palette-white: rgb(255, 255, 255);
|
||||
--affine-palette-black: rgb(0, 0, 0);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: CRDT-Native Data Flow in BlockSuite
|
||||
date: 2023-04-15
|
||||
authors:
|
||||
- name: Yifeng Wang
|
||||
link: 'https://twitter.com/ewind1994'
|
||||
- name: Saul-Mirone
|
||||
link: 'https://github.com/Saul-Mirone'
|
||||
excerpt: To make editors intuitive and collaboration-ready, BlockSuite ensure that regardless of whether you are collaborating with others or not, the application code should be unaware of it. This article introduce how this is designed.
|
||||
---
|
||||
|
||||
# CRDT-Native Data Flow in BlockSuite
|
||||
|
||||
<BlogPostMeta />
|
||||
|
||||
To make editors intuitive and collaboration-ready, BlockSuite ensure that regardless of whether you are collaborating with others or not, the application code should be unaware of it. This article introduce how this is designed.
|
||||
|
||||
## CRDT as Single Source of Truth
|
||||
|
||||
Traditionally, CRDTs have often been seen as a technology specialized in conflict resolution. Many editors initially designed to support single users have implemented support for real-time collaboration by integrating CRDT libraries. To this end, the data models in these editors will be synchronized to the CRDTs. This usually involves two opposite data flows:
|
||||
|
||||
- When the local model is updated, the state of the native model is synchronized to the CRDT model.
|
||||
- When a remote peer is updated, the data resolved from the CRDT model is synchronized back to the native model.
|
||||
|
||||

|
||||
|
||||
Although this is an intuitive and common practice, it requires synchronization between two heterogeneous models, resulting in a bidirectional data flow. The main issues here are:
|
||||
|
||||
- This bidirectional binding is not that easy to implement reliably and requires non-trivial modifications.
|
||||
- Application-layer code often needs to distinguish whether an update comes from a remote source, which increases complexity.
|
||||
|
||||
As an alternative, BlockSuite chooses to directly use the CRDT model as the single source of truth (since BlockSuite uses [Yjs](https://github.com/yjs/yjs), we also call it _YModel_ here). This means that regardless of whether the update comes from local or remote sources, the same process will be performed:
|
||||
|
||||
1. Firstly modify YModel, triggering the corresponding [`Y.Event`](https://docs.yjs.dev/api/y.event) that contains all incremental state changes in this update.
|
||||
2. Update the model nodes in the block tree based on the `Y.Event`.
|
||||
3. Send corresponding slot events after updating the block model, so as to update UI components accordingly.
|
||||
|
||||
This design can be represented by the following diagram:
|
||||
|
||||

|
||||
|
||||
The advantage of this approach is that the application-layer code can **completely ignore whether updates to the block model come from local editing, history stack, or collaboration with other users**. Just subscribing to model update events is adequate.
|
||||
|
||||
## Case Study
|
||||
|
||||
As an example, suppose the current block tree structure is as follows:
|
||||
|
||||
```
|
||||
RootBlock
|
||||
NoteBlock
|
||||
ParagraphBlock 0
|
||||
ParagraphBlock 1
|
||||
ParagraphBlock 2
|
||||
```
|
||||
|
||||
Now user A selects `ParagraphBlock 2` and presses the delete key to delete it. At this point, `doc.deleteBlock` should be called to delete this block model instance:
|
||||
|
||||
```ts
|
||||
const blockModel = doc.root.children[0].children[2];
|
||||
doc.deleteBlock(blockModel);
|
||||
```
|
||||
|
||||
At this point, BlockSuite does not directly modify the block tree under `doc.root`, but will instead firstly modify the underlying YBlock. After the CRDT state is changed, Yjs will generate the corresponding [Y.Event](https://docs.yjs.dev/api/y.event) data structure, which contains all the incremental state changes in this update (similar to incremental patches in git and virtual DOM). BlockSuite will always use this as the basis to synchronize the block models, then trigger the corresponding slot events for UI updates.
|
||||
|
||||
In this example, as the parent of `ParagraphBlock 2`, the `model.childrenUpdated` slot event of `NoteBlock` will be triggered. This will enable the corresponding component in the UI framework component tree to refresh itself. Since each child block has an ID, this is very conducive to combining the common list key optimizations in UI frameworks, achieving on-demand block component updates.
|
||||
|
||||
But the real power lies in the fact that if this block tree is being concurrently edited by multiple people, when user B performs a similar operation, the corresponding update will be encoded by Yjs and distributed by the provider. **When User A receives and applies the update from User B, the same state update pipeline as local editing will be triggered**. This makes it unnecessary for the application to make any additional modifications or adaptations for collaboration scenarios, inherently gaining real-time collaboration capabilities.
|
||||
|
||||
## Unidirectional Update Flow
|
||||
|
||||
Besides the block tree that uses CRDT as its single source of truth, BlockSuite also manages shared states that do not require a history of changes, such as the awareness state of each user's cursor position. Additionally, some user metadata may not be shared among all users.
|
||||
|
||||
In BlockSuite, the management of these state types follows a consistent, unidirectional pattern, enabling an intuitive one-way update flow that efficiently translates state changes into visual updates.
|
||||
|
||||
The complete state update process in BlockSuite involves several distinct steps, particularly when handling editor-related UI interactions:
|
||||
|
||||
1. **UI Event Handling**: View components generate UI events like clicks and drags, initiating corresponding callbacks. In BlockSuite, it is recommended to model and reuse these interactions using commands.
|
||||
2. **State Manipulation via Commands**: Commands can manipulate the editor state to accomplish UI updates.
|
||||
3. **State-Driven View Updates**: Upon state changes, slot events are used to notify and update view components accordingly.
|
||||
|
||||

|
||||
|
||||
This update mechanism is depicted in the diagram above. Concepts such as [command](../guide/command), [view](../guide/block-view) and [event](../guide/event) are further elaborated in other documentation sections for detailed understanding.
|
||||
|
||||
## Summary
|
||||
|
||||
In summary, by utilizing the CRDT model as the single source of truth, the application layer code can remain agnostic to whether updates originate from local or remote sources. This simplifies synchronization and reduces complexity. This approach enables applications to acquire real-time collaboration capabilities without necessitating intrusive modifications or adaptations, which is a key reason why the BlockSuite editor has been inherently _collaborative_ from day one.
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: Building Document-Centric, CRDT-Native Editors
|
||||
date: 2024-01-10
|
||||
authors:
|
||||
- name: Yifeng Wang
|
||||
link: 'https://twitter.com/ewind1994'
|
||||
excerpt: 'This article presents the document-centric way for building editors, and why CRDT is required to make this happpen.'
|
||||
---
|
||||
|
||||
# Building Document-Centric, CRDT-Native Editors
|
||||
|
||||
<BlogPostMeta />
|
||||
|
||||
## Motivation
|
||||
|
||||
For years, web frameworks such as React and Vue have popularized the mental model of component based development. This approach allows us to break down complex front-end applications into components for better composition and maintenance.
|
||||
|
||||
Hence, when discussing front-end collaborative editing (or rich text editing), the first thought is often to define an `<Editor/>` component, then design the corresponding data flow and APIs around this editor. This method seems intuitive and has been adopted by many open-source editors in the front-end community. Everything sounds natural, but are there limitations or room for improvement?
|
||||
|
||||
In the past years, our team has been dedicated to building a notable open-source knowledge base product ([26k stars on GitHub](https://github.com/toeverything/AFFiNE)). To visualize and organize complex knowledge structures better, we wanted our the editor in our product to be powerful enough, so as to provide an immersive editing and collaboration experience - imagine nesting Google Docs or Notion in an infinite canvas like Figma, as shown below:
|
||||
|
||||

|
||||
|
||||
However, before finding the best practice, our journey in developing editors was full of challenges. At first glance, the front-end community offers many great rich text editors (like [Slate](https://github.com/ianstormtaylor/slate), [Tiptap](https://tiptap.dev/), [Lexical](https://lexical.dev/)) and whiteboard editors (like [tldraw](https://github.com/tldraw/tldraw)), which usually allows the _embedding_ of React components. Bundling various React-compatible editors together seemed convenient - but proved impractical. To some extent, this is like trying to cram several devices supporting the USB protocol into the same shell. Despite sharing the same interface, there's no guarantee the resulting product will work correctly.
|
||||
|
||||
The frustration encountered in directly integrating various open-source editors led us to question the current design philosophy of popular editing frameworks. As a result, we decided to rebuild all necessary infrastructure for our editors, based on recent breakthroughs in collaborative editing technology (specifically, [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type)). The outcome was a powerful design pattern that no longer revolves around the editor. We call this approach _**document-centric**_.
|
||||
|
||||
## The Document-Centric Approach
|
||||
|
||||
We believe that the current mainstream editing frameworks design their data flow around the `<Editor/>` component, with each editor managing its internal state cohesively. While this is a good design, some issues are hard to resolve:
|
||||
|
||||
- The data loading methods and internal state management mechanisms in different editors are not universal, making cross-editor state sharing difficult, often requiring redundant deep copies.
|
||||
- Different editor containers have distinct internal life cycles, complicating the establishment of a consistent component model.
|
||||
- The strong binding between document data and editor instances makes sharing a single document across multiple editor instances difficult, or managing multiple documents within a single editor instance.
|
||||
- Although editors generally support embedding external components, nesting editors can easily lead to conflicts in focus, selection, shortcuts, etc.
|
||||
|
||||
Consider a simple example where a text editor A and an image editor B are used together:
|
||||
|
||||

|
||||
|
||||
For a simple user operation sequence:
|
||||
|
||||
- Perform several image editing operations.
|
||||
- Delete the image, which will usually dispose its UI component.
|
||||
- Continue editing the text.
|
||||
|
||||

|
||||
|
||||
If A and B are independently implemented editors, how should the user operation history be managed? Allowing A and B to maintain their history states seems easier, but neither can hold a complete user operation history. When an editor instance is destroyed, the history stack recording user operations generally disappears. Therefore, this often requires bookkeeping outside these editor instances, which is only the beginning of a series of complexities.
|
||||
|
||||
Alternatively, in the document-centric model, **we believe that the _document_ - the data layer of the editor, should be maintained completely independent of the editor, allowing the document to persist throughout the application lifecycle**. Thus, no matter if a UI component is part of an editor or not, it should work by simply _**attaching**_ to this document, like this:
|
||||
|
||||

|
||||
|
||||
Once the document is separated from the editor, it becomes easy to overcome many difficulties under the editor-centric approach:
|
||||
|
||||
- The above example is no longer a problem. Since the history record is stored in this persistently existing document, there's no need for bookkeeping between editor instances.
|
||||
- Cross-editor state sharing can become zero-cost. Because the document (here we are referring to the editable content, not the global DOM variable) is also just a plain JavaScript object, which could be easily shared between different editor instances.
|
||||
- Since editor instances are no longer strictly bound to document instances, rendering multiple documents in a single editor or displaying a single document across multiple editors becomes intuitively feasible.
|
||||
|
||||
In other words, **the document-centric approach aims to establish a data layer that transcends editor boundaries, requiring various editors to drive their updates based on the (whole or partial) state of the external document, thus building a more flexible and diverse experience in a scalable way**.
|
||||
|
||||
But given the complexity of collaborative document editing, is such architecture technically feasible?
|
||||
|
||||
## Document-Centric and CRDT
|
||||
|
||||
Collaborative document editing is known for its complexity. Beyond handling user undo/redo history, traditional real-time collaboration requires complex algorithms like [Operational Transformation](https://en.wikipedia.org/wiki/Operational_transformation) to model editing actions into several restricted operations. Fortunately, CRDTs, which have made breakthrough progress in recent years, can encapsulate this complexity, making the document-centric model possible. **In other words, we believe document-centric needs to be built on the foundation of CRDT**.
|
||||
|
||||
Delving into the workings of CRDT is beyond the scope of this article. If you're unfamiliar with CRDT, all you need to know is that when used as a foundational library, CRDTs offer an experience and optimizations akin to standard JavaScript data types, much like the [ImmutableJS](https://immutable-js.com/).
|
||||
|
||||
Here's an example using ImmutableJS:
|
||||
|
||||
```ts
|
||||
import Immutable from 'immutable';
|
||||
|
||||
let immutableMap = Immutable.Map({ key1: 'value1' });
|
||||
immutableMap = immutableMap.set('key2', 'value2');
|
||||
|
||||
// { key1: 'value1', key2: 'value2' }
|
||||
console.log(immutableMap.toJSON());
|
||||
```
|
||||
|
||||
And here's an (intuitively symmetrical) example using [Yjs](https://github.com/yjs/yjs), a popular CRDT library:
|
||||
|
||||
```ts
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const yMap = new Y.Map();
|
||||
yMap.set('key1', 'value1');
|
||||
yMap.set('key2', 'value2');
|
||||
|
||||
// Supposed to be { key1: 'value1', key2: 'value2' }
|
||||
console.log(yMap.toJSON());
|
||||
```
|
||||
|
||||
But be aware, this example won't work as expected! Here `yMap.toJSON()` will return an empty object. Because in Yjs, **you actually need to create a `Y.Doc` first**, then can you use CRDT data types like `Y.Map` / `Y.Array` / `Y.Text`:
|
||||
|
||||
```ts
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const yDoc = new Y.Doc();
|
||||
// You need to `getMap` for top-level fields
|
||||
const yMap = yDoc.getMap('hello');
|
||||
yMap.set('key1', 'value1');
|
||||
yMap.set('key2', 'value2');
|
||||
// Only then can you attach nested data to doc nodes
|
||||
yMap.set('key3', new Y.Map());
|
||||
|
||||
// { key1: 'value1', key2: 'value2', key3: {} }
|
||||
console.log(yMap.toJSON());
|
||||
// { hello: { key1: 'value1', key2: 'value2', key3: {} } }
|
||||
console.log(yDoc.toJSON());
|
||||
```
|
||||
|
||||
To some extent, **this API design is precisely a representation of the document-centric approach**! Since all state changes are compulsively recorded on one persistently existing `Y.Doc`, it's highly apt for serving as the single source of truth for the state of UI components like editors. Documents based on Yjs have these capabilities:
|
||||
|
||||
- They can represent content structures equivalent to JSON, which includes maps, arrays, and various primitive data types in JavaScript.
|
||||
- Rich text nodes (using `Y.Text` instead of just `string`) can be optionally utilized within the document tree.
|
||||
- Highly granular event notifications are sent when document tree nodes are updated, potentially replacing the need for a virtual DOM!
|
||||
- Documents can be serialized into a binary structure akin to [protobuf](https://protobuf.dev/) or RSC payload (see [y-protocols](https://github.com/yjs/y-protocols)), and incremental encoding of partial updates to the document is also possible.
|
||||
- In collaborative scenarios, these updates can be broadcast directly. Clients don't need to take care about the order of update application to achieve a consistent merged result (as guaranteed by the CRDT algorithm), enabling reliable real-time collaboration among multiple users.
|
||||
|
||||
As shown in the following diagram, the entire `Y.Doc` can be encoded into binary updates like the ones depicted, and all subsequent updates such as `yMap.set()` can also be incrementally encoded into the same binary patch:
|
||||
|
||||

|
||||
|
||||
This mechanism is similar to git. Each `Y.Doc` works like a git repository, and every operation on the CRDT document, such as `yMap.set()`, is akin to performing a `git commit`. This is because, like git, CRDT records all historical operations but without merge conflicts. Naturally, this also makes history management based on CRDT (akin to `git revert`) possible. These capabilities are sufficient for implementing a complete data layer based on CRDT.
|
||||
|
||||
Therefore, we chose to implement a common document data layer based on Yjs. This results in the following application data flow:
|
||||
|
||||

|
||||
|
||||
The blue part owns the full capability to drive UI in complex collaborative applications, including the management of rich text, history, conflict resolution, model update events, etc. This part has a well-defined isolation boundary from UI components and can be used independently of editors. We believe this is the data layer needed for being document-centric.
|
||||
|
||||
## The BlockSuite Showcase
|
||||
|
||||
Embracing the document-centric philosophy, we created the [BlockSuite](https://github.com/toeverything/blocksuite) project.
|
||||
|
||||
In BlockSuite, documents are modeled as `doc` objects. Each doc holds a tree of blocks. Some editor presets can be used upon connecting to a doc as following:
|
||||
|
||||
```ts
|
||||
import { createEmptyDoc, PageEditor } from '@blocksuite/presets';
|
||||
|
||||
// Initialize a `doc` document
|
||||
const doc = createEmptyDoc().init();
|
||||
|
||||
// Create an editor, then attach it to the document
|
||||
const editor = new PageEditor();
|
||||
editor.doc = doc;
|
||||
|
||||
document.body.appendChild(editor);
|
||||
```
|
||||
|
||||
BlockSuite advocates for assembling the top-level `PageEditor` component from smaller editable components, as all editable components can connect to different nodes in the block tree document. For example, instead of using existing complex rich text editors, BlockSuite implemented a `@blocksuite/inline` rich text component that only supports rendering linear text sequences. Complex rich text content can be assembled from atomic inline editor components, as illustrated:
|
||||
|
||||

|
||||
|
||||
In the diagram, each inline editor instance connects to a `Y.Text` node in the document tree. It models the data format of rich text as a linear sequence, with expressive power equivalent to the [delta](https://quilljs.com/docs/delta/) format. Thus, all rich text content in the document tree can be split into separate inline editors for rendering, **eliminating the nesting between inline editors**. This significantly lowers the cost of implementing rich text features, as depicted:
|
||||
|
||||

|
||||
|
||||
Since various editors can be loaded and unloaded independently of the document, this allows BlockSuite to support switching between different editors using the same block tree document. Thus, when switching content between document editors and whiteboard editors (which we call `EdgelessEditor`), all operation history recorded on the doc can be preserved, rather than reset:
|
||||
|
||||

|
||||
|
||||
Moreover, the separation of document and editor also allows docs to be used independently of editors. This is why BlockSuite not only provides various editor UI components but also many peripheral UI components that rely on doc state yet are not part of the editor. We refer to these components as _fragments_. The lifecycle of a fragment can be completely independent of the editor, and it can be implemented with a different technology stack than that used for the editor. For example, the right sidebar in the following diagram belongs to `OutlineFragment`, which facilitates panel arrangement by the application layer (rather than an all-in-one editor):
|
||||
|
||||

|
||||
|
||||
Furthermore, by supporting a document data layer independent of the editor, we are also able to split traditionally editor-embedded components into independent fragments, thus providing a more unopinionated and reusable `PageEditor`. Areas like the title and doc info panel, intuitively part of the editor's internals, can also become examples of fragments:
|
||||
|
||||

|
||||
|
||||
Additionally, the document-centric approach aids in better separation between the data layer and rendering layer, enabling developers to break free from the typically DOM-based editors, to implement better performance optimization strategies. For example, the BlockSuite document supports a surface block specially designed for rendering graphic content, which could take the advantage of the HTML5 `<canvas>`. BlockSuite allows these graphic contents to interleave with other block tree contents rendered to the DOM, automatically merging graphic elements into as few canvases as possible to enhance rendering performance:
|
||||
|
||||

|
||||
|
||||
In contrast, when there are 2000 canvas shapes in the document, tldraw, the DOM-based open-source whiteboard, would reaches its limit. At this point, it exhibits noticeable frame drops during viewport panning and zooming, degrading the content to placeholders with React suspense. However, the canvas renderer in BlockSuite could still maintain a frame rate of over 100fps at this time - and don't forget, you can still use the complete DOM-based rich text editing capability!
|
||||
|
||||

|
||||
|
||||
A year after creating BlockSuite, we have not only implemented a collaborative editing framework under the document-centric approach but also delivered an editor product with powerful document editing and canvas whiteboard editing capabilities. Considering the time traditionally required to implement complex rich text editors from scratch, we believe this is a highly efficient pattern. Of course, as a young open-source project, BlockSuite still has many areas for continuous improvement, and we hope you could stay tuned!
|
||||
|
||||
## Summary
|
||||
|
||||
We explored the evolution of collaborative document editors, especially the transitioning from the traditional editor-centric approach to the document-centric approach. This transition implies several key points:
|
||||
|
||||
- **Separation of Data and Editor:** We emphasized the importance of separating the document data layer from the editor logic. Through this approach, document data becomes the core of the application, rather than being confined to a specific editor instance. This makes data sharing across editors and history management simple and efficient.
|
||||
- **Adoption of CRDT:** Withe the help of CRDT, we demonstrated how to efficiently handle complex issues in collaborative editing, such as real-time synchronization and conflict resolution. CRDT provides a scalable way to build powerful multi-user editing experiences while maintaining eventual consistency.
|
||||
- **Flexible UI Construction:** By separating the document data layer from the editor, we offered greater flexibility in building and optimizing user interfaces. Editors become pluggable components that can be flexibly assembled and configured according to specific application needs, creating richer and more dynamic user experiences.
|
||||
|
||||
We believe that the shift to document-centric not only solves some core issues faced by traditional editors but also opens up new possibilities for building future editing experiences. With this new design philosophy, developers can more flexibly build diverse collaborative tools while offering powerful, reliable, and seamless user experiences. As this pattern evolves, we look forward to seeing more innovative collaborative editing solutions emerge.
|
||||
|
||||
---
|
||||
|
||||
Support our project with a star 🌟 on GitHub: [**toeverything/blocksuite**](https://github.com/toeverything/blocksuite)
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
layout: BlogListLayout
|
||||
title: Blog
|
||||
---
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user