mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
98 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 | |||
| 0b4d25f332 | |||
| c6a99eb9cb | |||
| 77657a697b | |||
| eb953c0565 | |||
| 77c0b2ef47 | |||
| 7138fea9db | |||
| 156cfc7e76 | |||
| 2ca4973167 | |||
| a1ae7d11a3 | |||
| f41bc2d5c3 | |||
| e3391c0577 | |||
| 5806ad8a3a |
@@ -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" }
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
"version": "0.26.3",
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"msw": "^2.12.4",
|
||||
"msw": "^2.13.2",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +9,9 @@ import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { CodeBlockToolbarContext } from '../context.js';
|
||||
|
||||
@@ -82,18 +82,10 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
||||
|
||||
createLitPortal({
|
||||
template: html`
|
||||
<editor-menu-content
|
||||
data-show
|
||||
class="more-popup-menu"
|
||||
style=${styleMap({
|
||||
'--content-padding': '8px',
|
||||
'--packed-height': '4px',
|
||||
})}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${renderGroups(this.moreGroups, this.context)}
|
||||
</div>
|
||||
</editor-menu-content>
|
||||
<affine-code-more-menu
|
||||
.context=${this.context}
|
||||
.moreGroups=${this.moreGroups}
|
||||
></affine-code-more-menu>
|
||||
`,
|
||||
// should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host)
|
||||
portalStyles: {
|
||||
@@ -117,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>
|
||||
@@ -145,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { renderGroups } from '@blocksuite/affine-components/toolbar';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { CodeBlockToolbarContext } from '../context.js';
|
||||
|
||||
export class AffineCodeMoreMenu extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
override firstUpdated() {
|
||||
this.disposables.add(
|
||||
this.context.blockComponent.model.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'wrap' || key === 'lineNumber') {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-menu-content
|
||||
data-show
|
||||
class="more-popup-menu"
|
||||
style=${styleMap({
|
||||
'--content-padding': '8px',
|
||||
'--packed-height': '4px',
|
||||
})}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${renderGroups(this.moreGroups, this.context)}
|
||||
</div>
|
||||
</editor-menu-content>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: CodeBlockToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code-more-menu': AffineCodeMoreMenu;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
@@ -174,7 +208,8 @@ export const toggleGroup: MenuItemGroup<CodeBlockToolbarContext> = {
|
||||
return html`
|
||||
<editor-menu-action
|
||||
@click=${() => {
|
||||
blockComponent.setWrap(!wrapped);
|
||||
const currentWrap = blockComponent.model.props.wrap;
|
||||
blockComponent.setWrap(!currentWrap);
|
||||
}}
|
||||
aria-label=${label}
|
||||
>
|
||||
@@ -204,8 +239,10 @@ export const toggleGroup: MenuItemGroup<CodeBlockToolbarContext> = {
|
||||
return html`
|
||||
<editor-menu-action
|
||||
@click=${() => {
|
||||
const currentLineNumber =
|
||||
blockComponent.model.props.lineNumber ?? true;
|
||||
blockComponent.store.updateBlock(blockComponent.model, {
|
||||
lineNumber: !lineNumber,
|
||||
lineNumber: !currentLineNumber,
|
||||
});
|
||||
}}
|
||||
aria-label=${label}
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
} from './code-toolbar';
|
||||
import { AffineCodeToolbar } from './code-toolbar/components/code-toolbar';
|
||||
import { LanguageListButton } from './code-toolbar/components/lang-button';
|
||||
import { AffineCodeMoreMenu } from './code-toolbar/components/more-menu';
|
||||
import { PreviewButton } from './code-toolbar/components/preview-button';
|
||||
import { AffineCodeUnit } from './highlight/affine-code-unit';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('language-list-button', LanguageListButton);
|
||||
customElements.define('affine-code-toolbar', AffineCodeToolbar);
|
||||
customElements.define('affine-code-more-menu', AffineCodeMoreMenu);
|
||||
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
|
||||
customElements.define('affine-code-unit', AffineCodeUnit);
|
||||
customElements.define('affine-code', CodeBlockComponent);
|
||||
@@ -21,6 +23,7 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'language-list-button': LanguageListButton;
|
||||
'affine-code-toolbar': AffineCodeToolbar;
|
||||
'affine-code-more-menu': AffineCodeMoreMenu;
|
||||
'preview-button': PreviewButton;
|
||||
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -15,6 +15,8 @@ import { nanoid } from '@blocksuite/store';
|
||||
import type { Element } from 'hast';
|
||||
import type { Table as MarkdownTable } from 'mdast';
|
||||
|
||||
import { compareByOrder } from '../utils';
|
||||
|
||||
type RichTextType = DeltaInsert[];
|
||||
const createRichText = (text: RichTextType) => {
|
||||
return {
|
||||
@@ -70,12 +72,8 @@ export const processTable = (
|
||||
rows: Record<string, TableRow>,
|
||||
cells: Record<string, TableCellSerialized>
|
||||
): Table => {
|
||||
const sortedColumns = Object.values(columns).sort((a, b) =>
|
||||
a.order.localeCompare(b.order)
|
||||
);
|
||||
const sortedRows = Object.values(rows).sort((a, b) =>
|
||||
a.order.localeCompare(b.order)
|
||||
);
|
||||
const sortedColumns = Object.values(columns).sort(compareByOrder);
|
||||
const sortedRows = Object.values(rows).sort(compareByOrder);
|
||||
const table: Table = {
|
||||
rows: [],
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nanoid, Text } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
|
||||
import type { TableAreaSelection } from './selection-schema';
|
||||
import { compareByOrder } from './utils';
|
||||
|
||||
export class TableDataManager {
|
||||
constructor(private readonly model: TableBlockModel) {}
|
||||
@@ -28,15 +29,11 @@ export class TableDataManager {
|
||||
`${this.virtualRowCount$.value + this.rows$.value.length} x ${this.virtualColumnCount$.value + this.columns$.value.length}`
|
||||
);
|
||||
readonly rows$ = computed(() => {
|
||||
return Object.values(this.model.props.rows$.value).sort((a, b) =>
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
return Object.values(this.model.props.rows$.value).sort(compareByOrder);
|
||||
});
|
||||
|
||||
readonly columns$ = computed(() => {
|
||||
return Object.values(this.model.props.columns$.value).sort((a, b) =>
|
||||
a.order > b.order ? 1 : -1
|
||||
);
|
||||
return Object.values(this.model.props.columns$.value).sort(compareByOrder);
|
||||
});
|
||||
|
||||
readonly uiRows$ = computed(() => {
|
||||
|
||||
@@ -4,3 +4,8 @@ export const cleanSelection = () => {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
};
|
||||
|
||||
export const compareByOrder = <T extends { order: string }>(
|
||||
a: T,
|
||||
b: T
|
||||
): number => (a.order === b.order ? 0 : a.order > b.order ? 1 : -1);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -187,6 +187,7 @@ export class EditorMenuAction extends LitElement {
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 400;
|
||||
min-height: 30px; // 22 + 8
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:host(:hover),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,10 @@ import type { DataSource } from '../core/data-source/base.js';
|
||||
import { DetailSelection } from '../core/detail/selection.js';
|
||||
import type { FilterGroup } from '../core/filter/types.js';
|
||||
import { groupByMatchers } from '../core/group-by/define.js';
|
||||
import { GroupTrait, sortByManually } from '../core/group-by/trait.js';
|
||||
import { t } from '../core/logical/type-presets.js';
|
||||
import type { DataViewCellLifeCycle } from '../core/property/index.js';
|
||||
import type { Row } from '../core/view-manager/row.js';
|
||||
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
|
||||
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
|
||||
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
|
||||
@@ -214,7 +216,205 @@ const createDragController = () => {
|
||||
return new KanbanDragController({} as DragLogic);
|
||||
};
|
||||
|
||||
const createTestRow = (rowId: string): Row => ({
|
||||
rowId,
|
||||
cells$: signal([]) as Row['cells$'],
|
||||
index$: signal<Row['index$']['value']>(undefined),
|
||||
prev$: signal<Row | undefined>(undefined),
|
||||
next$: signal<Row | undefined>(undefined),
|
||||
delete: vi.fn(),
|
||||
move: vi.fn(),
|
||||
});
|
||||
|
||||
const createGroupTraitHarness = (options?: {
|
||||
groupProperties?: Array<{
|
||||
key: string;
|
||||
hide: boolean;
|
||||
manuallyCardSort: string[];
|
||||
}>;
|
||||
rowIds?: string[];
|
||||
values?: Record<string, boolean>;
|
||||
}) => {
|
||||
const dataSource = createMockDataSource([
|
||||
{
|
||||
id: 'checkbox',
|
||||
type: checkboxPropertyModelConfig.type,
|
||||
},
|
||||
]);
|
||||
|
||||
const property = {
|
||||
id: 'checkbox',
|
||||
dataType$: signal(t.boolean.instance()),
|
||||
meta$: signal({ config: {} }),
|
||||
};
|
||||
|
||||
const groupProperties = options?.groupProperties ?? [
|
||||
{
|
||||
key: 'true',
|
||||
hide: false,
|
||||
manuallyCardSort: [],
|
||||
},
|
||||
{
|
||||
key: 'false',
|
||||
hide: false,
|
||||
manuallyCardSort: [],
|
||||
},
|
||||
];
|
||||
const rows = options?.rowIds ?? [];
|
||||
const data$ = signal({
|
||||
groupProperties,
|
||||
});
|
||||
const cellValues = new Map(
|
||||
Object.entries(options?.values ?? {}).map(([rowId, value]) => [
|
||||
`${rowId}:checkbox`,
|
||||
value,
|
||||
])
|
||||
);
|
||||
const cells = new Map<
|
||||
string,
|
||||
{
|
||||
jsonValue$: ReturnType<typeof signal<boolean | undefined>>;
|
||||
jsonValueSet: ReturnType<typeof vi.fn<(value: unknown) => void>>;
|
||||
valueSet: ReturnType<typeof vi.fn<(value: unknown) => void>>;
|
||||
}
|
||||
>();
|
||||
|
||||
const cellGetOrCreate = (rowId: string, propertyId: string) => {
|
||||
const key = `${rowId}:${propertyId}`;
|
||||
const existing = cells.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const jsonValue$ = signal(cellValues.get(key));
|
||||
const update = (value: unknown) => {
|
||||
jsonValue$.value = value as boolean | undefined;
|
||||
cellValues.set(key, value as boolean);
|
||||
};
|
||||
const cell = {
|
||||
jsonValue$,
|
||||
jsonValueSet: vi.fn(update),
|
||||
valueSet: vi.fn(update),
|
||||
};
|
||||
cells.set(key, cell);
|
||||
return cell;
|
||||
};
|
||||
|
||||
const view = {
|
||||
data$,
|
||||
rows$: signal(rows.map(createTestRow)),
|
||||
isLocked$: signal(false),
|
||||
manager: {
|
||||
dataSource: asDataSource(dataSource),
|
||||
},
|
||||
propertyGetOrCreate: () => property,
|
||||
cellGetOrCreate,
|
||||
};
|
||||
|
||||
const groupBy$ = signal<GroupBy | undefined>({
|
||||
type: 'groupBy',
|
||||
columnId: 'checkbox',
|
||||
name: 'boolean',
|
||||
hideEmpty: false,
|
||||
sort: { desc: false },
|
||||
});
|
||||
|
||||
const ops = {
|
||||
groupBySet: vi.fn(),
|
||||
sortGroup: (keys: string[], asc?: boolean) => {
|
||||
const sorted = sortByManually(
|
||||
keys,
|
||||
value => value,
|
||||
data$.value.groupProperties.map(value => value.key)
|
||||
);
|
||||
return asc === false ? sorted.reverse() : sorted;
|
||||
},
|
||||
sortRow: (groupKey: string, groupedRows: Row[]) => {
|
||||
const group = data$.value.groupProperties.find(
|
||||
value => value.key === groupKey
|
||||
);
|
||||
return sortByManually(
|
||||
groupedRows,
|
||||
row => row.rowId,
|
||||
group?.manuallyCardSort ?? []
|
||||
);
|
||||
},
|
||||
changeGroupSort: vi.fn(),
|
||||
changeRowSort: vi.fn(),
|
||||
changeGroupHide: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
groupTrait: new GroupTrait(groupBy$, view as never, ops),
|
||||
ops,
|
||||
cells,
|
||||
};
|
||||
};
|
||||
|
||||
describe('kanban', () => {
|
||||
describe('group trait', () => {
|
||||
it('reapplies manual card order when building grouped rows', () => {
|
||||
const { groupTrait } = createGroupTraitHarness({
|
||||
groupProperties: [
|
||||
{
|
||||
key: 'true',
|
||||
hide: false,
|
||||
manuallyCardSort: ['row-2', 'row-1'],
|
||||
},
|
||||
{
|
||||
key: 'false',
|
||||
hide: false,
|
||||
manuallyCardSort: [],
|
||||
},
|
||||
],
|
||||
rowIds: ['row-1', 'row-2'],
|
||||
values: {
|
||||
'row-1': true,
|
||||
'row-2': true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
groupTrait.groupsDataList$.value
|
||||
?.find(group => group.key === 'true')
|
||||
?.rows.map(row => row.rowId)
|
||||
).toEqual(['row-2', 'row-1']);
|
||||
});
|
||||
|
||||
it('preserves manual group order when updating card sort', () => {
|
||||
const { groupTrait, ops, cells } = createGroupTraitHarness({
|
||||
groupProperties: [
|
||||
{
|
||||
key: 'false',
|
||||
hide: false,
|
||||
manuallyCardSort: ['row-1'],
|
||||
},
|
||||
{
|
||||
key: 'true',
|
||||
hide: false,
|
||||
manuallyCardSort: ['row-2'],
|
||||
},
|
||||
],
|
||||
rowIds: ['row-1', 'row-2'],
|
||||
values: {
|
||||
'row-1': false,
|
||||
'row-2': true,
|
||||
},
|
||||
});
|
||||
|
||||
groupTrait.moveCardTo('row-1', 'false', 'true', 'end');
|
||||
|
||||
expect(ops.changeRowSort).toHaveBeenCalledWith(
|
||||
['false', 'true'],
|
||||
'true',
|
||||
['row-2', 'row-1']
|
||||
);
|
||||
expect(cells.get('row-1:checkbox')?.jsonValueSet).toHaveBeenCalledWith(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group-by define', () => {
|
||||
it('boolean group should not include ungroup bucket', () => {
|
||||
const booleanGroup = groupByMatchers.find(
|
||||
|
||||
@@ -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';
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { SingleView } from '../view-manager/single-view.js';
|
||||
import { compareDateKeys } from './compare-date-keys.js';
|
||||
import { defaultGroupBy } from './default.js';
|
||||
import { findGroupByConfigByName, getGroupByService } from './matcher.js';
|
||||
// Test
|
||||
import type { GroupByConfig } from './types.js';
|
||||
|
||||
export type GroupInfo<
|
||||
@@ -88,6 +87,60 @@ function hasGroupProperties(
|
||||
return value === undefined || Array.isArray(value);
|
||||
}
|
||||
|
||||
const getOrderedGroupKeys = (
|
||||
keys: string[],
|
||||
groupInfo: GroupInfo | undefined,
|
||||
sortGroup: (keys: string[], asc?: boolean) => string[],
|
||||
sortAsc: boolean
|
||||
) => {
|
||||
if (groupInfo?.config.matchType.name === 'Date') {
|
||||
return [...keys].sort(compareDateKeys(groupInfo.config.name, sortAsc));
|
||||
}
|
||||
return sortGroup(keys, sortAsc);
|
||||
};
|
||||
|
||||
const applyGroupRowSort = <T extends { rows: Row[] }>(
|
||||
groups: Record<string, T>,
|
||||
orderedKeys: string[],
|
||||
sortRow: (groupKey: string, rows: Row[]) => Row[]
|
||||
) => {
|
||||
orderedKeys.forEach(key => {
|
||||
const group = groups[key];
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
group.rows = sortRow(key, group.rows);
|
||||
});
|
||||
};
|
||||
|
||||
const reorderGroupKeys = (
|
||||
keys: string[],
|
||||
groupKey: string,
|
||||
position: InsertToPosition
|
||||
) => {
|
||||
const currentIndex = keys.findIndex(key => key === groupKey);
|
||||
if (currentIndex < 0) {
|
||||
return keys;
|
||||
}
|
||||
if (typeof position === 'object') {
|
||||
if (position.id === groupKey) {
|
||||
return keys;
|
||||
}
|
||||
if (!keys.includes(position.id)) {
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
const reordered = [...keys];
|
||||
reordered.splice(currentIndex, 1);
|
||||
const index = insertPositionToIndex(position, reordered, key => key);
|
||||
if (index < 0) {
|
||||
return keys;
|
||||
}
|
||||
reordered.splice(index, 0, groupKey);
|
||||
return reordered;
|
||||
};
|
||||
|
||||
export class GroupTrait {
|
||||
hideEmpty$ = signal<boolean>(true);
|
||||
sortAsc$ = signal<boolean>(true);
|
||||
@@ -202,15 +255,13 @@ export class GroupTrait {
|
||||
if (!map) return;
|
||||
|
||||
const gi = this.groupInfo$.value;
|
||||
let ordered: string[];
|
||||
|
||||
if (gi?.config.matchType.name === 'Date') {
|
||||
ordered = Object.keys(map).sort(
|
||||
compareDateKeys(gi.config.name, this.sortAsc$.value)
|
||||
);
|
||||
} else {
|
||||
ordered = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value);
|
||||
}
|
||||
const ordered = getOrderedGroupKeys(
|
||||
Object.keys(map),
|
||||
gi,
|
||||
this.ops.sortGroup,
|
||||
this.sortAsc$.value
|
||||
);
|
||||
applyGroupRowSort(map, ordered, this.ops.sortRow);
|
||||
|
||||
return ordered
|
||||
.map(k => map[k])
|
||||
@@ -233,14 +284,13 @@ export class GroupTrait {
|
||||
const info = this.groupInfo$.value;
|
||||
if (!map || !info) return;
|
||||
|
||||
let orderedKeys: string[];
|
||||
if (info.config.matchType.name === 'Date') {
|
||||
orderedKeys = Object.keys(map).sort(
|
||||
compareDateKeys(info.config.name, this.sortAsc$.value)
|
||||
);
|
||||
} else {
|
||||
orderedKeys = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value);
|
||||
}
|
||||
const orderedKeys = getOrderedGroupKeys(
|
||||
Object.keys(map),
|
||||
info,
|
||||
this.ops.sortGroup,
|
||||
this.sortAsc$.value
|
||||
);
|
||||
applyGroupRowSort(map, orderedKeys, this.ops.sortRow);
|
||||
|
||||
const visible: Group[] = [];
|
||||
const hidden: Group[] = [];
|
||||
@@ -430,23 +480,29 @@ export class GroupTrait {
|
||||
.map(row => row.rowId) ?? [];
|
||||
const index = insertPositionToIndex(position, rows, row => row);
|
||||
rows.splice(index, 0, rowId);
|
||||
const groupKeys = Object.keys(groupMap);
|
||||
const groupKeys = getOrderedGroupKeys(
|
||||
Object.keys(groupMap),
|
||||
this.groupInfo$.value,
|
||||
this.ops.sortGroup,
|
||||
this.sortAsc$.value
|
||||
);
|
||||
this.ops.changeRowSort(groupKeys, toGroupKey, rows);
|
||||
}
|
||||
|
||||
moveGroupTo(groupKey: string, position: InsertToPosition) {
|
||||
const groups = this.groupsDataList$.value;
|
||||
const groups = this.groupsDataListAll$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
const keys = groups.map(v => v!.key);
|
||||
keys.splice(
|
||||
keys.findIndex(key => key === groupKey),
|
||||
1
|
||||
);
|
||||
const index = insertPositionToIndex(position, keys, key => key);
|
||||
keys.splice(index, 0, groupKey);
|
||||
this.changeGroupSort(keys);
|
||||
const currentKeys = groups.map(group => group.key);
|
||||
const reorderedKeys = reorderGroupKeys(currentKeys, groupKey, position);
|
||||
if (
|
||||
currentKeys.length === reorderedKeys.length &&
|
||||
currentKeys.every((key, index) => key === reorderedKeys[index])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.changeGroupSort(reorderedKeys);
|
||||
}
|
||||
|
||||
removeFromGroup(rowId: string, key: string) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,8 @@ import {
|
||||
EdgelessCRUDIdentifier,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
MindmapElementModel,
|
||||
ShapeElementModel,
|
||||
TextResizing,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { ShapeElementModel } from '@blocksuite/affine-model';
|
||||
import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
|
||||
@@ -21,15 +18,24 @@ import {
|
||||
stdContext,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { InlineEditor, RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline';
|
||||
import { consume } from '@lit/context';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
function isShapeElement(element: unknown): element is ShapeElementModel {
|
||||
return (
|
||||
!!element &&
|
||||
typeof element === 'object' &&
|
||||
'type' in element &&
|
||||
element.type === 'shape'
|
||||
);
|
||||
}
|
||||
|
||||
export function mountShapeTextEditor(
|
||||
shapeElement: ShapeElementModel,
|
||||
shapeElement: { id: string } | null | undefined,
|
||||
edgeless: BlockComponent
|
||||
) {
|
||||
const mountElm = edgeless.querySelector('.edgeless-mount-point');
|
||||
@@ -43,24 +49,27 @@ export function mountShapeTextEditor(
|
||||
const gfx = edgeless.std.get(GfxControllerIdentifier);
|
||||
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
if (!shapeElement?.id) {
|
||||
console.error('Cannot mount text editor on an invalid shape element');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedElement = crud.getElementById(shapeElement.id);
|
||||
|
||||
if (!(updatedElement instanceof ShapeElementModel)) {
|
||||
if (!isShapeElement(updatedElement)) {
|
||||
console.error('Cannot mount text editor on a non-shape element');
|
||||
return;
|
||||
}
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [shapeElement.id],
|
||||
elements: [updatedElement.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
if (!shapeElement.text) {
|
||||
if (!updatedElement.text) {
|
||||
const text = new Y.Text();
|
||||
edgeless.std
|
||||
.get(EdgelessCRUDIdentifier)
|
||||
.updateElement(shapeElement.id, { text });
|
||||
crud.updateElement(updatedElement.id, { text });
|
||||
}
|
||||
|
||||
const shapeEditor = new EdgelessShapeTextEditor();
|
||||
@@ -70,6 +79,8 @@ export function mountShapeTextEditor(
|
||||
}
|
||||
|
||||
export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
private _compositionUpdateRaf: number | null = null;
|
||||
|
||||
private _keeping = false;
|
||||
|
||||
private _lastXYWH = '';
|
||||
@@ -148,6 +159,11 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
if (this._compositionUpdateRaf !== null) {
|
||||
cancelAnimationFrame(this._compositionUpdateRaf);
|
||||
this._compositionUpdateRaf = null;
|
||||
}
|
||||
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = null;
|
||||
|
||||
@@ -171,10 +187,96 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _scheduleElementWHUpdate(flush = false) {
|
||||
if (flush) {
|
||||
if (this._compositionUpdateRaf !== null) {
|
||||
cancelAnimationFrame(this._compositionUpdateRaf);
|
||||
this._compositionUpdateRaf = null;
|
||||
}
|
||||
this._updateElementWH();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._compositionUpdateRaf !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._compositionUpdateRaf = requestAnimationFrame(() => {
|
||||
this._compositionUpdateRaf = null;
|
||||
this._updateElementWH();
|
||||
});
|
||||
}
|
||||
|
||||
private _getInlineEditorContentRect() {
|
||||
if (!this.inlineEditorContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textNodes = InlineEditor.getTextNodesFromElement(
|
||||
this.inlineEditorContainer
|
||||
);
|
||||
const firstText = textNodes[0];
|
||||
const lastText = textNodes.at(-1);
|
||||
|
||||
if (!firstText || !lastText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = this.ownerDocument.createRange();
|
||||
range.setStart(firstText, 0);
|
||||
range.setEnd(lastText, lastText.length);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
return rect.width > 0 || rect.height > 0 ? rect : null;
|
||||
}
|
||||
|
||||
private _updateElementWH() {
|
||||
const bcr = this.richText.getBoundingClientRect();
|
||||
const containerHeight = this.richText.offsetHeight;
|
||||
const containerWidth = this.richText.offsetWidth;
|
||||
const [verticalPadding, horizontalPadding] = this.element.padding;
|
||||
const contentRect = this._getInlineEditorContentRect();
|
||||
const autoWidth =
|
||||
this.element.textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
|
||||
const constrainedAutoWidth = autoWidth && !!this.element.maxWidth;
|
||||
const maxAutoWidth =
|
||||
constrainedAutoWidth && typeof this.element.maxWidth === 'number'
|
||||
? this.element.maxWidth
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const nativeRangeRect = this.inlineEditor
|
||||
?.getNativeRange()
|
||||
?.getBoundingClientRect();
|
||||
const nativeRangeHeight =
|
||||
nativeRangeRect != null
|
||||
? Math.max(0, nativeRangeRect.bottom - bcr.top + verticalPadding)
|
||||
: 0;
|
||||
const nativeRangeWidth =
|
||||
nativeRangeRect != null
|
||||
? Math.max(0, nativeRangeRect.right - bcr.left + horizontalPadding)
|
||||
: 0;
|
||||
const editorContentHeight =
|
||||
this.inlineEditorContainer?.scrollHeight != null
|
||||
? this.inlineEditorContainer.scrollHeight + verticalPadding * 2
|
||||
: 0;
|
||||
const editorContentWidth =
|
||||
this.inlineEditorContainer?.scrollWidth != null
|
||||
? this.inlineEditorContainer.scrollWidth + horizontalPadding * 2
|
||||
: 0;
|
||||
const contentRectHeight =
|
||||
contentRect != null ? contentRect.height + verticalPadding * 2 : 0;
|
||||
const contentRectWidth =
|
||||
contentRect != null ? contentRect.width + horizontalPadding * 2 : 0;
|
||||
const containerHeight = Math.max(
|
||||
this.richText.offsetHeight,
|
||||
contentRectHeight,
|
||||
constrainedAutoWidth ? nativeRangeHeight : 0,
|
||||
autoWidth ? 0 : editorContentHeight
|
||||
);
|
||||
const containerWidth = Math.max(
|
||||
Math.min(this.richText.offsetWidth, maxAutoWidth),
|
||||
Math.min(contentRectWidth, maxAutoWidth),
|
||||
Math.min(nativeRangeWidth, maxAutoWidth),
|
||||
autoWidth ? 0 : editorContentWidth
|
||||
);
|
||||
|
||||
const textResizing = this.element.textResizing;
|
||||
|
||||
if (
|
||||
@@ -213,7 +315,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
if (this.isMindMapNode) {
|
||||
const mindmap = this.element.group as MindmapElementModel;
|
||||
|
||||
mindmap.layout();
|
||||
mindmap.layout(mindmap.tree, { applyStyle: false });
|
||||
}
|
||||
|
||||
this.richText.style.minHeight = `${containerHeight}px`;
|
||||
@@ -259,6 +361,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor) return;
|
||||
|
||||
if (this.element.group instanceof MindmapElementModel) {
|
||||
this.inlineEditor.selectAll();
|
||||
} else {
|
||||
@@ -280,6 +383,21 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
this._unmount();
|
||||
}
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionupdate',
|
||||
() => {
|
||||
this._scheduleElementWHUpdate();
|
||||
}
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this.inlineEditorContainer,
|
||||
'compositionend',
|
||||
() => {
|
||||
this._scheduleElementWHUpdate(true);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
@@ -316,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(
|
||||
@@ -323,8 +443,15 @@ 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
|
||||
? 'max-content'
|
||||
: textResizing === TextResizing.AUTO_HEIGHT
|
||||
? rect.width + 'px'
|
||||
: 'fit-content';
|
||||
const color = this.std
|
||||
.get(ThemeProvider)
|
||||
.generateColorProperty(this.element.color, '#000000');
|
||||
@@ -333,10 +460,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
position: 'absolute',
|
||||
left: x + 'px',
|
||||
top: y + 'px',
|
||||
width:
|
||||
textResizing === TextResizing.AUTO_HEIGHT
|
||||
? rect.width + 'px'
|
||||
: 'fit-content',
|
||||
width: editorWidth,
|
||||
// override rich-text style (height: 100%)
|
||||
height: 'initial',
|
||||
minHeight:
|
||||
@@ -355,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`,
|
||||
@@ -377,9 +501,21 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
return html` <style>
|
||||
edgeless-shape-text-editor v-text [data-v-text] {
|
||||
overflow-wrap: ${autoWidth ? 'normal' : 'anywhere'};
|
||||
word-break: ${autoWidth ? 'normal' : 'break-word'} !important;
|
||||
white-space: ${autoWidth ? 'pre' : 'pre-wrap'} !important;
|
||||
overflow-wrap: ${constrainedAutoWidth
|
||||
? 'anywhere'
|
||||
: autoWidth
|
||||
? 'normal'
|
||||
: 'anywhere'};
|
||||
word-break: ${constrainedAutoWidth
|
||||
? 'break-word'
|
||||
: autoWidth
|
||||
? 'normal'
|
||||
: 'break-word'} !important;
|
||||
white-space: ${constrainedAutoWidth
|
||||
? 'pre-wrap'
|
||||
: autoWidth
|
||||
? 'pre'
|
||||
: 'pre-wrap'} !important;
|
||||
}
|
||||
|
||||
edgeless-shape-text-editor .inline-editor {
|
||||
@@ -389,7 +525,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
<rich-text
|
||||
.yText=${this.element.text}
|
||||
.enableFormat=${false}
|
||||
.enableAutoScrollHorizontally=${false}
|
||||
.enableAutoScrollHorizontally=${autoWidth && !this.isMindMapNode}
|
||||
style=${inlineEditorStyle}
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -311,7 +311,6 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
|
||||
id = this.surface.addElement({
|
||||
type,
|
||||
xywh: '[0,0,100,30]',
|
||||
maxWidth: false,
|
||||
...props,
|
||||
...style.node,
|
||||
});
|
||||
@@ -345,7 +344,6 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
|
||||
id = this.surface.addElement({
|
||||
type,
|
||||
xywh: '[0,0,113,41]',
|
||||
maxWidth: false,
|
||||
...props,
|
||||
...rootStyle,
|
||||
});
|
||||
@@ -834,7 +832,12 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
|
||||
}
|
||||
|
||||
const stashed = new Set<GfxPrimitiveElementModel>();
|
||||
const stashedNodeIds = new Set<string>();
|
||||
const traverse = (node: MindmapNode) => {
|
||||
if (this._stashedNode.has(node.id)) return;
|
||||
|
||||
this._stashedNode.add(node.id);
|
||||
stashedNodeIds.add(node.id);
|
||||
node.element.stash('xywh');
|
||||
stashed.add(node.element);
|
||||
|
||||
@@ -846,7 +849,9 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
|
||||
traverse(mindNode);
|
||||
|
||||
return () => {
|
||||
this._stashedNode.delete(mindNode.id);
|
||||
stashedNodeIds.forEach(id => {
|
||||
this._stashedNode.delete(id);
|
||||
});
|
||||
stashed.forEach(el => {
|
||||
el.pop('xywh');
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export type NodeStyle = {
|
||||
strokeColor: Color;
|
||||
|
||||
textResizing: TextResizing;
|
||||
maxWidth: false | number;
|
||||
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
@@ -62,6 +63,8 @@ export type ConnectorStyle = {
|
||||
mode: ConnectorMode;
|
||||
};
|
||||
|
||||
export const MINDMAP_NODE_MAX_WIDTH = 512;
|
||||
|
||||
export abstract class MindmapStyleGetter {
|
||||
abstract readonly root: NodeStyle;
|
||||
|
||||
@@ -90,6 +93,7 @@ export class StyleOne extends MindmapStyleGetter {
|
||||
radius: 8,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 4,
|
||||
strokeColor: '#53b2ef',
|
||||
@@ -161,6 +165,7 @@ export class StyleOne extends MindmapStyleGetter {
|
||||
radius: 8,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: color,
|
||||
@@ -198,6 +203,7 @@ export class StyleTwo extends MindmapStyleGetter {
|
||||
radius: 3,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: DefaultTheme.black,
|
||||
@@ -271,6 +277,7 @@ export class StyleTwo extends MindmapStyleGetter {
|
||||
radius: 3,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: DefaultTheme.black,
|
||||
@@ -308,6 +315,7 @@ export class StyleThree extends MindmapStyleGetter {
|
||||
radius: 10,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
@@ -343,6 +351,7 @@ export class StyleThree extends MindmapStyleGetter {
|
||||
radius: 10,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 2,
|
||||
strokeColor,
|
||||
@@ -420,6 +429,7 @@ export class StyleFour extends MindmapStyleGetter {
|
||||
radius: 0,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
maxWidth: MINDMAP_NODE_MAX_WIDTH,
|
||||
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user