Compare commits

...

15 Commits

Author SHA1 Message Date
DarkSky
ad52c46f0a feat: improve mobile oom 2026-02-19 22:51:57 +08:00
DarkSky
d8cc0acdd0 chore: update flags 2026-02-19 10:18:43 +08:00
Neo
35e1411407 fix: docTitle unexpectedly translated (#14467)
fix #14465

In Chinese mode, the document with the specified name may not be
displayed correctly in the sidebar, and it may be mistaken for the
translation of the content that needs to be translated.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed document title display in navigation panels on desktop and
mobile to properly render without additional processing steps.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-17 13:45:31 +00:00
DarkSky
8f833388eb feat: improve admin panel design (#14464) 2026-02-17 17:40:29 +08:00
DarkSky
850e646ab9 fix: electon rendering on windows (#14456)
fix #14450
fix #14401
fix #13983
fix #12766
fix #14404
fix #12019

#### PR Dependency Tree


* **PR #14456** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added new tab navigation functions: `switchTab`, `switchToNextTab`,
and `switchToPreviousTab`.

* **Bug Fixes**
  * Improved bounds validation for tab view resizing.
  * Enhanced tab lifecycle management during navigation events.
  * Refined background throttling behavior for active tabs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 14:08:26 +08:00
DarkSky
728e02cab7 feat: bump eslint & oxlint (#14452)
#### PR Dependency Tree


* **PR #14452** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved null-safety, dependency tracking, upload validation, and
error logging for more reliable uploads, clipboard, calendar linking,
telemetry, PDF/theme printing, and preview/zoom behavior.
* Tightened handling of all-day calendar events (missing date now
reported).

* **Deprecations**
  * Removed deprecated RadioButton and RadioButtonGroup; use RadioGroup.

* **Chores**
* Unified and upgraded linting/config, reorganized imports, and
standardized binary handling for more consistent builds and tooling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 13:52:08 +08:00
DarkSky
792164edd1 fix: sign 2026-02-16 12:23:26 +08:00
DarkSky
e3177e6837 feat: normalize search text (#14449)
#### PR Dependency Tree


* **PR #14449** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Improvements**
* Search text normalization now applied consistently across doc titles,
search results, and highlights for uniform display formatting.

* **Tests**
* Added comprehensive test coverage for search text normalization
utility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 08:07:04 +08:00
DarkSky
42f2d2b337 feat: support markdown preview (#14447) 2026-02-15 21:05:52 +08:00
DarkSky
9d7f4acaf1 fix: s3 upload compatibility (#14445)
fix #14432 

#### PR Dependency Tree


* **PR #14445** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Improved file upload handling to ensure consistent support for
different data formats during object and multipart uploads.
* Enhanced type safety throughout storage and workflow components by
removing unnecessary type assertions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-15 19:16:36 +08:00
DarkSky
9a1f600fc9 chore: update i18n status 2026-02-15 14:59:52 +08:00
steffenrapp
0f906ad623 feat(i18n): update German translation (#14444)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Chat panel: session management, history loading, embedding progress,
and deletion flow
* Document analytics: views, unique visitors, guest metrics, charts,
viewers and paywall messaging
* Calendar integration: expanded account/provider states, errors and
flow copy; DOCX import tooltip
  * Appearance: image antialiasing option and window-behavior toggles
  * Workspace sharing: visibility controls and related tooltips

* **Improvements**
* Expanded error and empty-state wording, subscription/payment
description, and experimental feature labels
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-15 14:57:47 +08:00
DarkSky
09aa65c52a feat: improve ci 2026-02-15 14:53:35 +08:00
DarkSky
25227a09f7 feat: improve grouping perf in edgeless (#14442)
fix #14433 

#### PR Dependency Tree


* **PR #14442** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Level-of-detail thumbnails for large images.
  * Adaptive pacing for snapping, distribution and other alignment work.
  * RAF coalescer utility to batch high-frequency updates.
  * Operation timing utility to measure synchronous work.

* **Improvements**
* Batch group/ungroup reparenting that preserves element order and
selection.
  * Coalesced panning and drag updates to reduce jitter.
* Connector/group indexing for more reliable updates, deletions and
sync.
  * Throttled viewport refresh behavior.

* **Documentation**
  * Docs added for RAF coalescer and measureOperation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-15 03:17:22 +08:00
DarkSky
c0694c589b fix: editor style (#14440)
#### PR Dependency Tree


* **PR #14440** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Refined CSS styling rules in workspace detail pages for improved
layout rendering consistency.
* Enhanced editor container display handling during loading states to
ensure proper layout adjustments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 19:12:24 +08:00
297 changed files with 9106 additions and 5029 deletions

View File

@@ -222,7 +222,7 @@
},
"SMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"default": "AFFiNE Self Hosted <noreply@example.com>"
},
"SMTP.ignoreTLS": {
@@ -262,7 +262,7 @@
},
"fallbackSMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"\"",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"\"",
"default": ""
},
"fallbackSMTP.ignoreTLS": {

View File

@@ -201,13 +201,44 @@ jobs:
nmHoistingLimits: workspaces
env:
npm_config_arch: ${{ matrix.spec.arch }}
- name: Download and overwrite packaged artifacts
- name: Download packaged artifacts
uses: actions/download-artifact@v4
with:
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: packaged-unsigned
- name: unzip packaged artifacts
run: Expand-Archive -Path packaged-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out
- name: Download signed packaged file diff
uses: actions/download-artifact@v4
with:
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out
path: signed-packaged-diff
- name: Apply signed packaged file diff
shell: pwsh
run: |
$DiffRoot = 'signed-packaged-diff/files'
$TargetRoot = 'packages/frontend/apps/electron/out'
if (!(Test-Path -LiteralPath $DiffRoot)) {
throw "Signed diff directory not found: $DiffRoot"
}
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
$ManifestPath = 'signed-packaged-diff/manifest.json'
if (Test-Path -LiteralPath $ManifestPath) {
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
foreach ($Entry in $ManifestEntries) {
$TargetPath = Join-Path $TargetRoot $Entry.path
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
throw "Applied signed file not found: $($Entry.path)"
}
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
if ($TargetHash -ne $Entry.sha256) {
throw "Signed file hash mismatch: $($Entry.path)"
}
}
}
- name: Make squirrel.windows installer
run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
@@ -267,13 +298,44 @@ jobs:
arch: arm64
runs-on: ${{ matrix.spec.runner }}
steps:
- name: Download and overwrite installer artifacts
- name: Download installer artifacts
uses: actions/download-artifact@v4
with:
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: installer-unsigned
- name: unzip installer artifacts
run: Expand-Archive -Path installer-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
- name: Download signed installer file diff
uses: actions/download-artifact@v4
with:
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
path: signed-installer-diff
- name: Apply signed installer file diff
shell: pwsh
run: |
$DiffRoot = 'signed-installer-diff/files'
$TargetRoot = 'packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make'
if (!(Test-Path -LiteralPath $DiffRoot)) {
throw "Signed diff directory not found: $DiffRoot"
}
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
$ManifestPath = 'signed-installer-diff/manifest.json'
if (Test-Path -LiteralPath $ManifestPath) {
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
foreach ($Entry in $ManifestEntries) {
$TargetPath = Join-Path $TargetRoot $Entry.path
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
throw "Applied signed file not found: $($Entry.path)"
}
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
if ($TargetHash -ne $Entry.sha256) {
throw "Signed file hash mismatch: $($Entry.path)"
}
}
}
- name: Save artifacts
run: |

View File

@@ -148,7 +148,7 @@ jobs:
name: Wait for approval
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: darkskygit,pengx17,L-Sun,EYHN
approvers: darkskygit
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image

View File

@@ -30,13 +30,43 @@ jobs:
run: |
cd ${{ env.ARCHIVE_DIR }}/out
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
- name: zip file
shell: cmd
- name: collect signed file diff
shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0}
run: |
cd ${{ env.ARCHIVE_DIR }}
7za a signed.zip .\out\*
$OutDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'out'
$DiffDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'signed-diff'
$FilesDir = Join-Path $DiffDir 'files'
New-Item -ItemType Directory -Path $FilesDir -Force | Out-Null
$SignedFiles = [regex]::Matches('${{ inputs.files }}', '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
if ($SignedFiles.Count -eq 0) {
throw 'No files to sign were provided.'
}
$Manifest = @()
foreach ($RelativePath in $SignedFiles) {
$SourcePath = Join-Path $OutDir $RelativePath
if (!(Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
throw "Signed file not found: $RelativePath"
}
$TargetPath = Join-Path $FilesDir $RelativePath
$TargetDir = Split-Path -Parent $TargetPath
if ($TargetDir) {
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
}
Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force
$Manifest += [PSCustomObject]@{
path = $RelativePath
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
}
}
$Manifest | ConvertTo-Json -Depth 4 | Out-File -FilePath (Join-Path $DiffDir 'manifest.json') -Encoding utf8
Write-Host "Collected $($SignedFiles.Count) signed files."
- name: upload
uses: actions/upload-artifact@v4
with:
name: signed-${{ inputs.artifact-name }}
path: ${{ env.ARCHIVE_DIR }}/signed.zip
path: ${{ env.ARCHIVE_DIR }}/signed-diff

View File

@@ -5,6 +5,10 @@
"correctness": "error",
"perf": "error"
},
"env": {
"builtin": true,
"es2026": true
},
"ignorePatterns": [
"**/node_modules",
".yarn",
@@ -44,6 +48,34 @@
"**/test-blocks.json"
],
"rules": {
"no-empty-static-block": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-unused-private-class-members": "error",
"no-useless-backreference": "error",
"react/display-name": "error",
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "warn",
"@typescript-eslint/prefer-for-of": "error",
"@typescript-eslint/no-unsafe-function-type": "error",
"@typescript-eslint/no-wrapper-object-types": "error",
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["**/dist"],
"message": "Don't import from dist",
"allowTypeImports": false
},
{
"group": ["**/src"],
"message": "Don't import from src",
"allowTypeImports": false
}
]
}
],
"no-await-in-loop": "allow",
"no-redeclare": "allow",
"promise/no-callback-in-promise": "allow",
@@ -70,6 +102,14 @@
"no-func-assign": "error",
"no-global-assign": "error",
"no-unused-vars": "error",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true,
"allowTaggedTemplates": true
}
],
"no-ex-assign": "error",
"no-loss-of-precision": "error",
"no-fallthrough": "error",
@@ -126,6 +166,7 @@
"react/no-render-return-value": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-comment-textnodes": "error",
"react/no-array-index-key": "off",
"typescript/consistent-type-imports": "error",
"typescript/no-non-null-assertion": "error",
"typescript/triple-slash-reference": "error",
@@ -241,6 +282,42 @@
"typescript/consistent-type-imports": "off",
"import/no-cycle": "off"
}
},
{
"files": [
"packages/**/*.{ts,tsx}",
"tools/**/*.{ts,tsx}",
"blocksuite/**/*.{ts,tsx}"
],
"rules": {
"react/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)"
}
]
}
},
{
"files": [
"**/__tests__/**/*",
"**/*.stories.tsx",
"**/*.spec.ts",
"**/tests/**/*",
"scripts/**/*",
"**/benchmark/**/*",
"**/__debug__/**/*",
"**/e2e/**/*"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["**/*.{ts,js,mjs}"],
"rules": {
"react/rules-of-hooks": "off"
}
}
]
}

View File

@@ -17,7 +17,7 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, oxlint.json, nyc.config.*",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, .oxlintrc.json, oxlint.json, nyc.config.*",
"Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml",
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"

10
Cargo.lock generated
View File

@@ -111,6 +111,7 @@ dependencies = [
"base64-simd",
"chrono",
"homedir",
"lru",
"objc2",
"objc2-foundation",
"sqlx",
@@ -2572,6 +2573,15 @@ dependencies = [
"weezl",
]
[[package]]
name = "lru"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "mac"
version = "0.1.1"

View File

@@ -46,6 +46,7 @@ resolver = "3"
libc = "0.2"
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
memory-indexer = "0.3.0"
mimalloc = "0.1"
mp4parse = "0.17"

View File

@@ -26,6 +26,11 @@ import {
@Peekable()
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024;
private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080;
private static readonly LOD_MAX_ZOOM = 0.4;
private static readonly LOD_THUMBNAIL_MAX_EDGE = 256;
static override styles = css`
affine-edgeless-image {
position: relative;
@@ -63,6 +68,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
width: 100%;
height: 100%;
}
affine-edgeless-image .resizable-img {
position: relative;
overflow: hidden;
}
`;
resourceController = new ResourceController(
@@ -70,6 +80,12 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
'Image'
);
private _lodThumbnailUrl: string | null = null;
private _lodSourceUrl: string | null = null;
private _lodGeneratingSourceUrl: string | null = null;
private _lodGenerationToken = 0;
private _lastShouldUseLod = false;
get blobUrl() {
return this.resourceController.blobUrl$.value;
}
@@ -96,6 +112,134 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
});
}
private _isLargeImage() {
const { width = 0, height = 0, size = 0 } = this.model.props;
const pixels = width * height;
return (
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
);
}
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
return (
Boolean(blobUrl) &&
this._isLargeImage() &&
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
);
}
private _revokeLodThumbnail() {
if (!this._lodThumbnailUrl) {
return;
}
URL.revokeObjectURL(this._lodThumbnailUrl);
this._lodThumbnailUrl = null;
}
private _resetLodSource(blobUrl: string | null) {
if (this._lodSourceUrl === blobUrl) {
return;
}
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = blobUrl;
this._revokeLodThumbnail();
}
private _createImageElement(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load image'));
image.src = src;
});
}
private _createThumbnailBlob(image: HTMLImageElement) {
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve<Blob | null>(null);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'low';
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
return new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve);
});
}
private _ensureLodThumbnail(blobUrl: string) {
if (
this._lodThumbnailUrl ||
this._lodGeneratingSourceUrl === blobUrl ||
!this._shouldUseLod(blobUrl)
) {
return;
}
const token = ++this._lodGenerationToken;
this._lodGeneratingSourceUrl = blobUrl;
void this._createImageElement(blobUrl)
.then(image => this._createThumbnailBlob(image))
.then(blob => {
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
return;
}
const thumbnailUrl = URL.createObjectURL(blob);
if (token !== this._lodGenerationToken || !this.isConnected) {
URL.revokeObjectURL(thumbnailUrl);
return;
}
this._revokeLodThumbnail();
this._lodThumbnailUrl = thumbnailUrl;
if (this._shouldUseLod(this.blobUrl)) {
this.requestUpdate();
}
})
.catch(err => {
if (token !== this._lodGenerationToken || !this.isConnected) {
return;
}
console.error(err);
})
.finally(() => {
if (token === this._lodGenerationToken) {
this._lodGeneratingSourceUrl = null;
}
});
}
private _updateLodFromViewport(zoom: number) {
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
if (shouldUseLod === this._lastShouldUseLod) {
return;
}
this._lastShouldUseLod = shouldUseLod;
if (shouldUseLod && this.blobUrl) {
this._ensureLodThumbnail(this.blobUrl);
}
this.requestUpdate();
}
override connectedCallback() {
super.connectedCallback();
@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
this.disposables.add(
this.model.props.sourceId$.subscribe(() => {
this._resetLodSource(null);
this.refreshData();
})
);
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._updateLodFromViewport(zoom);
})
);
this._lastShouldUseLod = this._shouldUseLod(this.blobUrl);
}
override disconnectedCallback() {
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = null;
this._revokeLodThumbnail();
super.disconnectedCallback();
}
override renderGfxBlock() {
const blobUrl = this.blobUrl;
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
this._resetLodSource(blobUrl);
const containerStyleMap = styleMap({
display: 'flex',
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
});
const { loading, icon, description, error, needUpload } = resovledState;
const shouldUseLod = this._shouldUseLod(blobUrl);
if (shouldUseLod && blobUrl) {
this._ensureLodThumbnail(blobUrl);
}
this._lastShouldUseLod = shouldUseLod;
const imageUrl =
shouldUseLod && this._lodThumbnailUrl ? this._lodThumbnailUrl : blobUrl;
return html`
<div class="affine-image-container" style=${containerStyleMap}>
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
class="drag-target"
draggable="false"
loading="lazy"
src=${blobUrl}
src=${imageUrl ?? ''}
alt=${caption}
@error=${this._handleError}
/>

View File

@@ -33,7 +33,11 @@ import {
ReleaseFromGroupIcon,
UnlockIcon,
} from '@blocksuite/icons/lit';
import type { GfxModel } from '@blocksuite/std/gfx';
import {
batchAddChildren,
batchRemoveChildren,
type GfxModel,
} from '@blocksuite/std/gfx';
import { html } from 'lit';
import { renderAlignmentMenu } from './alignment';
@@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = {
const group = firstModel.group;
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(firstModel);
batchRemoveChildren(group, [firstModel]);
firstModel.index = ctx.gfx.layer.generateIndex();
const parent = group.group;
if (parent && parent instanceof GroupElementModel) {
parent.addChild(firstModel);
batchAddChildren(parent, [firstModel]);
}
},
},
@@ -255,9 +258,12 @@ export const builtinMiscToolbarConfig = {
// release other elements from their groups and group with top element
otherElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
element.group?.removeChild(element);
topElement.group?.addChild(element);
if (element.group) {
batchRemoveChildren(element.group, [element]);
}
if (topElement.group) {
batchAddChildren(topElement.group, [element]);
}
});
if (otherElements.length === 0) {

View File

@@ -40,10 +40,146 @@ export const SurfaceBlockSchemaExtension =
export class SurfaceBlockModel extends BaseSurfaceModel {
private readonly _disposables: DisposableGroup = new DisposableGroup();
private readonly _connectorIdsByEndpoint = new Map<string, Set<string>>();
private readonly _connectorIndexDisposables = new DisposableGroup();
private readonly _connectorEndpoints = new Map<
string,
{ sourceId: string | null; targetId: string | null }
>();
private _addConnectorEndpoint(endpointId: string, connectorId: string) {
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
if (connectorIds) {
connectorIds.add(connectorId);
return;
}
this._connectorIdsByEndpoint.set(endpointId, new Set([connectorId]));
}
private _isConnectorModel(model: unknown): model is ConnectorElementModel {
return (
!!model &&
typeof model === 'object' &&
'type' in model &&
(model as { type?: string }).type === 'connector'
);
}
private _removeConnectorEndpoint(endpointId: string, connectorId: string) {
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
if (!connectorIds) {
return;
}
connectorIds.delete(connectorId);
if (connectorIds.size === 0) {
this._connectorIdsByEndpoint.delete(endpointId);
}
}
private _removeConnectorFromIndex(connectorId: string) {
const endpoints = this._connectorEndpoints.get(connectorId);
if (!endpoints) {
return;
}
if (endpoints.sourceId) {
this._removeConnectorEndpoint(endpoints.sourceId, connectorId);
}
if (endpoints.targetId) {
this._removeConnectorEndpoint(endpoints.targetId, connectorId);
}
this._connectorEndpoints.delete(connectorId);
}
private _rebuildConnectorIndex() {
this._connectorIdsByEndpoint.clear();
this._connectorEndpoints.clear();
this.getElementsByType('connector').forEach(connector => {
this._setConnectorEndpoints(connector as ConnectorElementModel);
});
}
private _setConnectorEndpoints(connector: ConnectorElementModel) {
const sourceId = connector.source?.id ?? null;
const targetId = connector.target?.id ?? null;
const previousEndpoints = this._connectorEndpoints.get(connector.id);
if (
previousEndpoints?.sourceId === sourceId &&
previousEndpoints?.targetId === targetId
) {
return;
}
if (previousEndpoints?.sourceId) {
this._removeConnectorEndpoint(previousEndpoints.sourceId, connector.id);
}
if (previousEndpoints?.targetId) {
this._removeConnectorEndpoint(previousEndpoints.targetId, connector.id);
}
if (sourceId) {
this._addConnectorEndpoint(sourceId, connector.id);
}
if (targetId) {
this._addConnectorEndpoint(targetId, connector.id);
}
this._connectorEndpoints.set(connector.id, {
sourceId,
targetId,
});
}
override _init() {
this._extendElement(elementsCtorMap);
super._init();
this._rebuildConnectorIndex();
this._connectorIndexDisposables.add(
this.elementAdded.subscribe(({ id }) => {
const model = this.getElementById(id);
if (this._isConnectorModel(model)) {
this._setConnectorEndpoints(model);
}
})
);
this._connectorIndexDisposables.add(
this.elementUpdated.subscribe(({ id, props }) => {
if (!props['source'] && !props['target']) {
return;
}
const model = this.getElementById(id);
if (this._isConnectorModel(model)) {
this._setConnectorEndpoints(model);
}
})
);
this._connectorIndexDisposables.add(
this.elementRemoved.subscribe(({ id, type }) => {
if (type === 'connector') {
this._removeConnectorFromIndex(id);
}
})
);
this.deleted.subscribe(() => {
this._connectorIndexDisposables.dispose();
this._connectorIdsByEndpoint.clear();
this._connectorEndpoints.clear();
});
this.store.provider
.getAll(surfaceMiddlewareIdentifier)
.forEach(({ middleware }) => {
@@ -52,13 +188,31 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
}
getConnectors(id: string) {
const connectors = this.getElementsByType(
'connector'
) as unknown[] as ConnectorElementModel[];
const connectorIds = this._connectorIdsByEndpoint.get(id);
return connectors.filter(
connector => connector.source?.id === id || connector.target?.id === id
);
if (!connectorIds?.size) {
return [];
}
const staleConnectorIds: string[] = [];
const connectors: ConnectorElementModel[] = [];
connectorIds.forEach(connectorId => {
const model = this.getElementById(connectorId);
if (!this._isConnectorModel(model)) {
staleConnectorIds.push(connectorId);
return;
}
connectors.push(model);
});
staleConnectorIds.forEach(connectorId => {
this._removeConnectorFromIndex(connectorId);
});
return connectors;
}
override getElementsByType<K extends keyof SurfaceElementModelMap>(

View File

@@ -67,7 +67,7 @@ export const autoScrollOnBoundary = (
};
const cancelBoxListen = effect(() => {
box.value;
void box.value;
startUpdate();
});

View File

@@ -24,12 +24,12 @@ import {
DataViewUIBase,
DataViewUILogicBase,
} from '../../../core/view/data-view-base.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import {
type TableSingleView,
TableViewRowSelection,
type TableViewSelectionWithType,
} from '../../index.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
} from '../selection.js';
import type { TableSingleView } from '../table-view-manager.js';
import { TableClipboardController } from './controller/clipboard.js';
import { TableDragController } from './controller/drag.js';
import { TableHotkeysController } from './controller/hotkeys.js';

View File

@@ -60,10 +60,9 @@ export class BaseExtensionProvider<
* @param context - The context object containing scope and registration function
* @param option - Optional configuration options for the provider
*/
setup(context: Context<Scope>, option?: Options) {
setup(_context: Context<Scope>, option?: Options) {
if (option) {
this.schema.parse(option);
}
context;
}
}

View File

@@ -884,7 +884,7 @@ export class ConnectionOverlay extends Overlay {
private _setupThemeListener(): void {
const themeService = this.gfx.std.get(ThemeProvider);
this._themeDisposer = effect(() => {
themeService.theme$;
void themeService.theme$.value;
this._emphasisColor = this._getEmphasisColor();
});
}

View File

@@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = (
);
return () => {
pendingFlag = false;
pendingList.clear();
disposables.forEach(d => d.unsubscribe());
};
};

View File

@@ -26,6 +26,7 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
@@ -33,6 +34,9 @@
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('fractional-indexing', () => ({
generateKeyBetween: vi.fn(),
generateNKeysBetween: vi.fn(),
}));
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
import { ungroupCommand } from '../command/group-api.js';
type TestElement = {
id: string;
index: string;
group: TestElement | null;
childElements: TestElement[];
removeChildren?: (elements: TestElement[]) => void;
addChildren?: (elements: TestElement[]) => void;
};
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
const createElement = (
id: string,
index: string,
group: TestElement | null
): TestElement => ({
id,
index,
group,
childElements: [],
});
const createUngroupFixture = () => {
const parent = createElement('parent', 'p0', null);
const left = createElement('left', 'a0', parent);
const right = createElement('right', 'a0', parent);
const group = createElement('group', 'm0', parent);
const childA = createElement('child-a', 'c0', group);
const childB = createElement('child-b', 'c1', group);
group.childElements = [childB, childA];
parent.childElements = [left, group, right];
parent.removeChildren = vi.fn();
parent.addChildren = vi.fn();
group.removeChildren = vi.fn();
const elementOrder = new Map<TestElement, number>([
[left, 0],
[group, 1],
[right, 2],
[childA, 3],
[childB, 4],
]);
const selectionSet = vi.fn();
const gfx = {
layer: {
compare: (a: TestElement, b: TestElement) =>
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
},
selection: {
set: selectionSet,
},
};
const std = {
get: vi.fn(() => gfx),
store: {
transact: (callback: () => void) => callback(),
},
};
return {
childA,
childB,
group,
parent,
selectionSet,
std,
};
};
describe('ungroupCommand', () => {
beforeEach(() => {
mockedGenerateNKeysBetween.mockReset();
mockedGenerateKeyBetween.mockReset();
});
test('falls back to open-ended key generation when sibling interval is invalid', () => {
const fixture = createUngroupFixture();
mockedGenerateNKeysBetween
.mockImplementationOnce(() => {
throw new Error('interval reversed');
})
.mockReturnValueOnce(['n0', 'n1']);
const next = vi.fn();
ungroupCommand(
{
std: fixture.std,
group: fixture.group as any,
} as any,
next
);
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
1,
'a0',
'a0',
2
);
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
2,
'a0',
null,
2
);
expect(fixture.childA.index).toBe('n0');
expect(fixture.childB.index).toBe('n1');
expect(fixture.selectionSet).toHaveBeenCalledWith({
editing: false,
elements: ['child-a', 'child-b'],
});
expect(next).toHaveBeenCalledTimes(1);
});
test('falls back to key-by-key generation when all batched strategies fail', () => {
const fixture = createUngroupFixture();
mockedGenerateNKeysBetween.mockImplementation(() => {
throw new Error('invalid range');
});
let seq = 0;
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
ungroupCommand(
{
std: fixture.std,
group: fixture.group as any,
} as any,
vi.fn()
);
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
expect(fixture.childA.index).toBe('k0');
expect(fixture.childB.index).toBe('k1');
});
});

View File

@@ -4,7 +4,80 @@ import {
MindmapElementModel,
} from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/std';
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
import {
batchAddChildren,
batchRemoveChildren,
type GfxController,
GfxControllerIdentifier,
type GfxModel,
measureOperation,
} from '@blocksuite/std/gfx';
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
const getTopLevelOrderedElements = (gfx: GfxController) => {
const topLevelElements = gfx.layer.layers.reduce<GfxModel[]>(
(elements, layer) => {
layer.elements.forEach(element => {
if (element.group === null) {
elements.push(element as GfxModel);
}
});
return elements;
},
[]
);
topLevelElements.sort((a, b) => gfx.layer.compare(a, b));
return topLevelElements;
};
const buildUngroupIndexes = (
orderedElements: GfxModel[],
afterIndex: string | null,
beforeIndex: string | null,
fallbackAnchorIndex: string
) => {
if (orderedElements.length === 0) {
return [];
}
const count = orderedElements.length;
const tryGenerateN = (left: string | null, right: string | null) => {
try {
const generated = generateNKeysBetween(left, right, count);
return generated.length === count ? generated : null;
} catch {
return null;
}
};
const tryGenerateOneByOne = (left: string | null, right: string | null) => {
try {
let cursor = left;
return orderedElements.map(() => {
cursor = generateKeyBetween(cursor, right);
return cursor;
});
} catch {
return null;
}
};
// Preferred: keep ungrouped children in the original group slot.
return (
tryGenerateN(afterIndex, beforeIndex) ??
// Fallback: ignore the upper bound when legacy/broken data has reversed interval.
tryGenerateN(afterIndex, null) ??
// Fallback: use group index as anchor when sibling interval is unavailable.
tryGenerateN(fallbackAnchorIndex, null) ??
// Last resort: always valid.
tryGenerateN(null, null) ??
// Defensive fallback for unexpected library behavior.
tryGenerateOneByOne(null, null) ??
[]
);
};
export const createGroupCommand: Command<
{ elements: GfxModel[] | string[] },
@@ -39,96 +112,118 @@ export const createGroupFromSelectedCommand: Command<
{},
{ groupId: string }
> = (ctx, next) => {
const { std } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection, surface } = gfx;
measureOperation('edgeless:create-group-from-selected', () => {
const { std } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection, surface } = gfx;
if (!surface) {
return;
}
if (!surface) {
return;
}
if (
selection.selectedElements.length === 0 ||
!selection.selectedElements.every(
element =>
element.group === selection.firstElement.group &&
!(element.group instanceof MindmapElementModel)
)
) {
return;
}
if (
selection.selectedElements.length === 0 ||
!selection.selectedElements.every(
element =>
element.group === selection.firstElement.group &&
!(element.group instanceof MindmapElementModel)
)
) {
return;
}
const parent = selection.firstElement.group as GroupElementModel;
const parent = selection.firstElement.group;
let groupId: string | undefined;
std.store.transact(() => {
const [_, result] = std.command.exec(createGroupCommand, {
elements: selection.selectedElements,
});
if (parent !== null) {
selection.selectedElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(element);
if (!result.groupId) {
return;
}
groupId = result.groupId;
const group = surface.getElementById(groupId);
if (parent !== null && group) {
batchRemoveChildren(parent, selection.selectedElements);
batchAddChildren(parent, [group]);
}
});
}
const [_, result] = std.command.exec(createGroupCommand, {
elements: selection.selectedElements,
if (!groupId) {
return;
}
selection.set({
editing: false,
elements: [groupId],
});
next({ groupId });
});
if (!result.groupId) {
return;
}
const group = surface.getElementById(result.groupId);
if (parent !== null && group) {
parent.addChild(group);
}
selection.set({
editing: false,
elements: [result.groupId],
});
next({ groupId: result.groupId });
};
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
ctx,
next
) => {
const { std, group } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const parent = group.group as GroupElementModel;
const elements = group.childElements;
measureOperation('edgeless:ungroup', () => {
const { std, group } = ctx;
const gfx = std.get(GfxControllerIdentifier);
const { selection } = gfx;
const parent = group.group;
const elements = [...group.childElements];
if (group instanceof MindmapElementModel) {
return;
}
if (group instanceof MindmapElementModel) {
return;
}
if (parent !== null) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parent.removeChild(group);
}
const orderedElements = [...elements].sort((a, b) =>
gfx.layer.compare(a, b)
);
const siblings = parent
? [...parent.childElements].sort((a, b) => gfx.layer.compare(a, b))
: getTopLevelOrderedElements(gfx);
const groupPosition = siblings.indexOf(group);
const beforeSiblingIndex =
groupPosition > 0 ? (siblings[groupPosition - 1]?.index ?? null) : null;
const afterSiblingIndex =
groupPosition === -1
? null
: (siblings[groupPosition + 1]?.index ?? null);
const nextIndexes = buildUngroupIndexes(
orderedElements,
beforeSiblingIndex,
afterSiblingIndex,
group.index
);
elements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(element);
});
std.store.transact(() => {
if (parent !== null) {
batchRemoveChildren(parent, [group]);
}
// keep relative index order of group children after ungroup
elements
.sort((a, b) => gfx.layer.compare(a, b))
.forEach(element => {
std.store.transact(() => {
element.index = gfx.layer.generateIndex();
batchRemoveChildren(group, elements);
// keep relative index order of group children after ungroup
orderedElements.forEach((element, idx) => {
const index = nextIndexes[idx];
if (element.index !== index) {
element.index = index;
}
});
if (parent !== null) {
batchAddChildren(parent, orderedElements);
}
});
if (parent !== null) {
elements.forEach(element => {
parent.addChild(element);
selection.set({
editing: false,
elements: orderedElements.map(ele => ele.id),
});
}
selection.set({
editing: false,
elements: elements.map(ele => ele.id),
next();
});
next();
};

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/affine-gfx-group',
},
onConsoleLog(log, type) {
if (log.includes('lit.dev/msg/dev-mode')) {
return false;
}
console.warn(`Unexpected ${type} log`, log);
throw new Error(log);
},
environment: 'happy-dom',
},
});

View File

@@ -32,6 +32,9 @@
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts"

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from 'vitest';
import {
AdaptiveCooldownController,
AdaptiveStrideController,
} from '../snap/adaptive-load-controller.js';
describe('AdaptiveStrideController', () => {
test('increases stride under heavy cost and respects maxStride', () => {
const controller = new AdaptiveStrideController({
heavyCostMs: 6,
maxStride: 3,
recoveryCostMs: 2,
});
controller.reportCost(10);
controller.reportCost(12);
controller.reportCost(15);
// stride should be capped at 3, so only every 3rd tick runs.
expect(controller.shouldSkip()).toBe(false);
expect(controller.shouldSkip()).toBe(true);
expect(controller.shouldSkip()).toBe(true);
expect(controller.shouldSkip()).toBe(false);
});
test('decreases stride when cost recovers and reset clears state', () => {
const controller = new AdaptiveStrideController({
heavyCostMs: 8,
maxStride: 4,
recoveryCostMs: 3,
});
controller.reportCost(12);
controller.reportCost(12);
controller.reportCost(1);
// From stride 3 recovered to stride 2: run every other tick.
expect(controller.shouldSkip()).toBe(false);
expect(controller.shouldSkip()).toBe(true);
expect(controller.shouldSkip()).toBe(false);
controller.reset();
expect(controller.shouldSkip()).toBe(false);
expect(controller.shouldSkip()).toBe(false);
});
});
describe('AdaptiveCooldownController', () => {
test('enters cooldown when cost exceeds threshold', () => {
const controller = new AdaptiveCooldownController({
cooldownFrames: 2,
maxCostMs: 5,
});
controller.reportCost(9);
expect(controller.shouldRun()).toBe(false);
expect(controller.shouldRun()).toBe(false);
expect(controller.shouldRun()).toBe(true);
});
test('reset exits cooldown immediately', () => {
const controller = new AdaptiveCooldownController({
cooldownFrames: 3,
maxCostMs: 5,
});
controller.reportCost(6);
expect(controller.shouldRun()).toBe(false);
controller.reset();
expect(controller.shouldRun()).toBe(true);
});
});

View File

@@ -0,0 +1,177 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import { MouseButton } from '@blocksuite/std/gfx';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { PanTool } from '../tools/pan-tool.js';
type PointerDownHandler = (event: {
raw: {
button: number;
preventDefault: () => void;
};
}) => unknown;
const mockRaf = () => {
let callback: FrameRequestCallback | undefined;
const requestAnimationFrameMock = vi
.fn()
.mockImplementation((cb: FrameRequestCallback) => {
callback = cb;
return 1;
});
const cancelAnimationFrameMock = vi.fn();
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock);
return {
getCallback: () => callback,
requestAnimationFrameMock,
cancelAnimationFrameMock,
};
};
const createToolFixture = (options?: {
currentToolName?: string;
currentToolOptions?: Record<string, unknown>;
}) => {
const applyDeltaCenter = vi.fn();
const selectionSet = vi.fn();
const setTool = vi.fn();
const navigatorSettingUpdated = {
next: vi.fn(),
};
const currentToolName = options?.currentToolName;
const currentToolOption = {
toolType: currentToolName
? ({
toolName: currentToolName,
} as any)
: undefined,
options: options?.currentToolOptions,
};
const gfx = {
viewport: {
zoom: 2,
applyDeltaCenter,
},
selection: {
surfaceSelections: [{ elements: ['shape-1'] }],
set: selectionSet,
},
tool: {
currentTool$: {
peek: () => null,
},
currentToolOption$: {
peek: () => currentToolOption,
},
setTool,
},
std: {
get: (identifier: unknown) => {
if (identifier === EdgelessLegacySlotIdentifier) {
return { navigatorSettingUpdated };
}
return null;
},
},
doc: {},
};
const tool = new PanTool(gfx as any);
return {
applyDeltaCenter,
navigatorSettingUpdated,
selectionSet,
setTool,
tool,
};
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('PanTool', () => {
test('flushes accumulated delta on dragEnd', () => {
mockRaf();
const { tool, applyDeltaCenter } = createToolFixture();
tool.dragStart({ x: 100, y: 100 } as any);
tool.dragMove({ x: 80, y: 60 } as any);
tool.dragMove({ x: 70, y: 40 } as any);
expect(applyDeltaCenter).not.toHaveBeenCalled();
tool.dragEnd({} as any);
expect(applyDeltaCenter).toHaveBeenCalledTimes(1);
expect(applyDeltaCenter).toHaveBeenCalledWith(15, 30);
expect(tool.panning$.value).toBe(false);
});
test('cancel in unmounted drops pending deltas', () => {
mockRaf();
const { tool, applyDeltaCenter } = createToolFixture();
tool.dragStart({ x: 100, y: 100 } as any);
tool.dragMove({ x: 80, y: 60 } as any);
tool.unmounted();
tool.dragEnd({} as any);
expect(applyDeltaCenter).not.toHaveBeenCalled();
});
test('middle click temporary pan restores frameNavigator with restoredAfterPan', () => {
const { tool, navigatorSettingUpdated, selectionSet, setTool } =
createToolFixture({
currentToolName: 'frameNavigator',
currentToolOptions: { mode: 'fit' },
});
const hooks: Partial<Record<'pointerDown', PointerDownHandler>> = {};
(tool as any).eventTarget = {
addHook: (eventName: 'pointerDown', handler: PointerDownHandler) => {
hooks[eventName] = handler;
},
};
tool.mounted();
const preventDefault = vi.fn();
const pointerDown = hooks.pointerDown!;
const ret = pointerDown({
raw: {
button: MouseButton.MIDDLE,
preventDefault,
},
});
expect(ret).toBe(false);
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(navigatorSettingUpdated.next).toHaveBeenCalledWith({
blackBackground: false,
});
expect(setTool).toHaveBeenNthCalledWith(1, PanTool, {
panning: true,
});
document.dispatchEvent(
new PointerEvent('pointerup', { button: MouseButton.MIDDLE })
);
expect(selectionSet).toHaveBeenCalledWith([{ elements: ['shape-1'] }]);
expect(setTool).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
toolName: 'frameNavigator',
}),
{
mode: 'fit',
restoredAfterPan: true,
}
);
});
});

View File

@@ -0,0 +1,65 @@
export class AdaptiveStrideController {
private _stride = 1;
private _ticks = 0;
constructor(
private readonly _options: {
heavyCostMs: number;
maxStride: number;
recoveryCostMs: number;
}
) {}
reportCost(costMs: number) {
if (costMs > this._options.heavyCostMs) {
this._stride = Math.min(this._options.maxStride, this._stride + 1);
return;
}
if (costMs < this._options.recoveryCostMs && this._stride > 1) {
this._stride -= 1;
}
}
reset() {
this._stride = 1;
this._ticks = 0;
}
shouldSkip() {
const shouldSkip = this._stride > 1 && this._ticks % this._stride !== 0;
this._ticks += 1;
return shouldSkip;
}
}
export class AdaptiveCooldownController {
private _remainingFrames = 0;
constructor(
private readonly _options: {
cooldownFrames: number;
maxCostMs: number;
}
) {}
reportCost(costMs: number) {
if (costMs > this._options.maxCostMs) {
this._remainingFrames = this._options.cooldownFrames;
}
}
reset() {
this._remainingFrames = 0;
}
shouldRun() {
if (this._remainingFrames <= 0) {
return true;
}
this._remainingFrames -= 1;
return false;
}
}

View File

@@ -8,11 +8,18 @@ import {
InteractivityExtension,
} from '@blocksuite/std/gfx';
import { AdaptiveStrideController } from './adaptive-load-controller';
import type { SnapOverlay } from './snap-overlay';
export class SnapExtension extends InteractivityExtension {
static override key = 'snap-manager';
private static readonly MAX_ALIGN_SKIP_STRIDE = 3;
private static readonly ALIGN_HEAVY_COST_MS = 5;
private static readonly ALIGN_RECOVERY_COST_MS = 2;
get snapOverlay() {
return this.std.getOptional(
OverlayIdentifier('snap-manager')
@@ -29,6 +36,11 @@ export class SnapExtension extends InteractivityExtension {
}
let alignBound: Bound | null = null;
const alignStride = new AdaptiveStrideController({
heavyCostMs: SnapExtension.ALIGN_HEAVY_COST_MS,
maxStride: SnapExtension.MAX_ALIGN_SKIP_STRIDE,
recoveryCostMs: SnapExtension.ALIGN_RECOVERY_COST_MS,
});
return {
onDragStart() {
@@ -42,6 +54,7 @@ export class SnapExtension extends InteractivityExtension {
return pre;
}, [] as GfxModel[])
);
alignStride.reset();
},
onDragMove(context: ExtensionDragMoveContext) {
if (
@@ -53,14 +66,22 @@ export class SnapExtension extends InteractivityExtension {
return;
}
if (alignStride.shouldSkip()) {
return;
}
const currentBound = alignBound.moveDelta(context.dx, context.dy);
const alignStart = performance.now();
const alignRst = snapOverlay.align(currentBound);
const alignCost = performance.now() - alignStart;
alignStride.reportCost(alignCost);
context.dx = alignRst.dx + context.dx;
context.dy = alignRst.dy + context.dy;
},
clear() {
alignBound = null;
alignStride.reset();
snapOverlay.clear();
},
};

View File

@@ -6,6 +6,8 @@ import {
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
import type { GfxModel } from '@blocksuite/std/gfx';
import { AdaptiveCooldownController } from './adaptive-load-controller';
interface Distance {
horiz?: {
/**
@@ -35,6 +37,9 @@ interface Distance {
const ALIGN_THRESHOLD = 8;
const DISTRIBUTION_LINE_OFFSET = 1;
const STROKE_WIDTH = 2;
const DISTRIBUTE_ALIGN_MAX_CANDIDATES = 160;
const DISTRIBUTE_ALIGN_MAX_COST_MS = 5;
const DISTRIBUTE_ALIGN_COOLDOWN_FRAMES = 2;
export class SnapOverlay extends Overlay {
static override overlayName: string = 'snap-manager';
@@ -75,6 +80,11 @@ export class SnapOverlay extends Overlay {
vertical: [],
};
private readonly _distributeCooldown = new AdaptiveCooldownController({
cooldownFrames: DISTRIBUTE_ALIGN_COOLDOWN_FRAMES,
maxCostMs: DISTRIBUTE_ALIGN_MAX_COST_MS,
});
override clear() {
this._referenceBounds = {
vertical: [],
@@ -87,6 +97,7 @@ export class SnapOverlay extends Overlay {
};
this._distributedAlignLines = [];
this._skippedElements.clear();
this._distributeCooldown.reset();
super.clear();
}
@@ -673,13 +684,24 @@ export class SnapOverlay extends Overlay {
}
}
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
}
const shouldTryDistribute =
this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
this._distributeCooldown.shouldRun();
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
if (shouldTryDistribute) {
const distributeStart = performance.now();
// point align priority is higher than distribute align
if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
}
if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport);
}
const distributeCost = performance.now() - distributeStart;
this._distributeCooldown.reportCost(distributeCost);
}
this._renderer?.refresh();
@@ -776,24 +798,26 @@ export class SnapOverlay extends Overlay {
});
const verticalBounds: Bound[] = [];
const horizBounds: Bound[] = [];
const allBounds: Bound[] = [];
const allCandidateElements = new Set<GfxModel>();
vertCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
verticalBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
const bound = candidate.elementBound;
verticalBounds.push(bound);
allCandidateElements.add(candidate);
});
horizCandidates.forEach(candidate => {
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
horizBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
const bound = candidate.elementBound;
horizBounds.push(bound);
allCandidateElements.add(candidate);
});
this._referenceBounds = {
horizontal: horizBounds,
vertical: verticalBounds,
all: allBounds,
all: [...allCandidateElements].map(element => element.elementBound),
};
}

View File

@@ -4,7 +4,12 @@ import {
} from '@blocksuite/affine-block-surface';
import { on } from '@blocksuite/affine-shared/utils';
import type { PointerEventState } from '@blocksuite/std';
import { BaseTool, MouseButton, type ToolOptions } from '@blocksuite/std/gfx';
import {
BaseTool,
createRafCoalescer,
MouseButton,
type ToolOptions,
} from '@blocksuite/std/gfx';
import { Signal } from '@preact/signals-core';
interface RestorablePresentToolOptions {
@@ -21,13 +26,30 @@ export class PanTool extends BaseTool<PanToolOption> {
private _lastPoint: [number, number] | null = null;
private _pendingDelta: [number, number] = [0, 0];
private readonly _deltaFlushCoalescer = createRafCoalescer<void>(() => {
this._flushPendingDelta();
});
readonly panning$ = new Signal<boolean>(false);
private _flushPendingDelta() {
if (this._pendingDelta[0] === 0 && this._pendingDelta[1] === 0) {
return;
}
const [deltaX, deltaY] = this._pendingDelta;
this._pendingDelta = [0, 0];
this.gfx.viewport.applyDeltaCenter(deltaX, deltaY);
}
override get allowDragWithRightButton(): boolean {
return true;
}
override dragEnd(_: PointerEventState): void {
this._deltaFlushCoalescer.flush();
this._lastPoint = null;
this.panning$.value = false;
}
@@ -43,12 +65,14 @@ export class PanTool extends BaseTool<PanToolOption> {
const deltaY = lastY - e.y;
this._lastPoint = [e.x, e.y];
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
this._pendingDelta[0] += deltaX / zoom;
this._pendingDelta[1] += deltaY / zoom;
this._deltaFlushCoalescer.schedule(undefined);
}
override dragStart(e: PointerEventState): void {
this._lastPoint = [e.x, e.y];
this._pendingDelta = [0, 0];
this.panning$.value = true;
}
@@ -120,4 +144,8 @@ export class PanTool extends BaseTool<PanToolOption> {
return false;
});
}
override unmounted(): void {
this._deltaFlushCoalescer.cancel();
}
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.js',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/affine-gfx-pointer',
},
onConsoleLog(log, type) {
if (log.includes('lit.dev/msg/dev-mode')) {
return false;
}
console.warn(`Unexpected ${type} log`, log);
throw new Error(log);
},
environment: 'happy-dom',
},
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/await-thenable */
import type {
Template,
TemplateCategory,

View File

@@ -155,9 +155,22 @@ export class FrameBlockModel
}
removeChild(element: GfxModel): void {
this.removeChildren([element]);
}
removeChildren(elements: GfxModel[]): void {
const childIds = [...new Set(elements.map(element => element.id))];
if (!this.props.childElementIds || childIds.length === 0) {
return;
}
this.store.transact(() => {
this.props.childElementIds &&
delete this.props.childElementIds[element.id];
const childElementIds = this.props.childElementIds;
if (!childElementIds) return;
childIds.forEach(childId => {
delete childElementIds[childId];
});
});
}
}

View File

@@ -54,12 +54,21 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
}
override addChild(element: GfxModel) {
if (!canSafeAddToContainer(this, element)) {
this.addChildren([element]);
}
addChildren(elements: GfxModel[]) {
elements = [...new Set(elements)].filter(element =>
canSafeAddToContainer(this, element)
);
if (elements.length === 0) {
return;
}
this.surface.store.transact(() => {
this.children.set(element.id, true);
elements.forEach(element => {
this.children.set(element.id, true);
});
});
}
@@ -76,11 +85,22 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
}
removeChild(element: GfxModel) {
this.removeChildren([element]);
}
removeChildren(elements: GfxModel[]) {
if (!this.children) {
return;
}
const childIds = [...new Set(elements.map(element => element.id))];
if (childIds.length === 0) {
return;
}
this.surface.store.transact(() => {
this.children.delete(element.id);
childIds.forEach(childId => {
this.children.delete(childId);
});
});
}

View File

@@ -9,7 +9,7 @@ import rehypeParse from 'rehype-parse';
import { unified } from 'unified';
import type { AffineTextAttributes } from '../../types/index.js';
import { HtmlDeltaConverter } from '../html/delta-converter.js';
import type { HtmlDeltaConverter } from '../html/delta-converter.js';
import {
rehypeInlineToBlock,
rehypeWrapInlineElements,

View File

@@ -873,7 +873,7 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
return {
table: {
headerRows: 0,
widths: Array(sortedColumns.length).fill('*'),
widths: Array.from({ length: sortedColumns.length }, () => '*'),
body: tableBody,
},
margin: [0, 5, 0, 5],

View File

@@ -115,12 +115,9 @@ export async function printToPdf(
) as HTMLDivElement;
// force light theme in print iframe
iframe.contentWindow.document.documentElement.setAttribute(
'data-theme',
'light'
);
iframe.contentWindow.document.body.setAttribute('data-theme', 'light');
importedRoot.setAttribute('data-theme', 'light');
iframe.contentWindow.document.documentElement.dataset.theme = 'light';
iframe.contentWindow.document.body.dataset.theme = 'light';
importedRoot.dataset.theme = 'light';
// draw saved canvas image to canvas
const allImportedCanvas = importedRoot.getElementsByTagName('canvas');

View File

@@ -126,7 +126,7 @@ export class EdgelessZoomToolbar extends WithDisposable(LitElement) {
this.disposables.add(
effect(() => {
this.gfx.tool.currentToolName$.value;
void this.gfx.tool.currentToolName$.value;
this.requestUpdate();
})
);

View File

@@ -289,7 +289,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
this.disposables.add(
effect(() => {
const std = this.rootComponent.std;
std.selection.value;
void std.selection.value;
// wait cursor updated
requestAnimationFrame(() => {
this._scrollCurrentBlockIntoView();

View File

@@ -1,5 +1,5 @@
import type { ExtensionType, Schema, Workspace } from '@blocksuite/store';
// @ts-ignore
// @ts-expect-error -- mammoth.browser has no compatible type declaration for this subpath.
import { convertToHtml } from 'mammoth/mammoth.browser';
import { HtmlTransformer } from './html';

View File

@@ -10,12 +10,12 @@ import { Container } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { sha } from '@blocksuite/global/utils';
import type {
DocMeta,
ExtensionType,
Schema,
Store,
Workspace,
} from '@blocksuite/store';
import type { DocMeta } from '@blocksuite/store';
import { extMimeMap, Transformer } from '@blocksuite/store';
import type { AssetMap, ImportedFileEntry, PathBlobIdMap } from './type.js';

View File

@@ -171,9 +171,11 @@ export class Unzip {
const fileExt =
fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1);
const mime = extMimeMap.get(fileExt ?? '');
const content = new File([this.unzipped![path]], fileName, {
type: mime ?? '',
}) as Blob;
const content = new File(
[new Uint8Array(this.unzipped![path]).buffer],
fileName,
mime ? { type: mime } : undefined
) as Blob;
const fixedPath = this.fixFileNameEncoding(path);

View File

@@ -27,10 +27,10 @@ async function exportDocs(
titleMiddleware(collection.meta.docMetas),
],
});
const snapshots = await Promise.all(docs.map(job.docToSnapshot));
await Promise.all(
snapshots
docs
.map(job.docToSnapshot)
.filter((snapshot): snapshot is DocSnapshot => !!snapshot)
.map(async snapshot => {
// Use the title and id as the snapshot file name

View File

@@ -34,6 +34,7 @@
- [canSafeAddToContainer](functions/canSafeAddToContainer.md)
- [compareLayer](functions/compareLayer.md)
- [convert](functions/convert.md)
- [createRafCoalescer](functions/createRafCoalescer.md)
- [derive](functions/derive.md)
- [generateKeyBetween](functions/generateKeyBetween.md)
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
@@ -42,5 +43,6 @@
- [GfxCompatible](functions/GfxCompatible.md)
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
- [local](functions/local.md)
- [measureOperation](functions/measureOperation.md)
- [observe](functions/observe.md)
- [watch](functions/watch.md)

View File

@@ -0,0 +1,27 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / createRafCoalescer
# Function: createRafCoalescer()
> **createRafCoalescer**\<`T`\>(`apply`): `RafCoalescer`\<`T`\>
Coalesce high-frequency updates and only process the latest payload in one frame.
## Type Parameters
### T
`T`
## Parameters
### apply
(`payload`) => `void`
## Returns
`RafCoalescer`\<`T`\>

View File

@@ -0,0 +1,34 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / measureOperation
# Function: measureOperation()
> **measureOperation**\<`T`\>(`name`, `fn`): `T`
Measure operation cost via Performance API when available.
Marks are always cleared, while measure entries are intentionally retained
so callers can inspect them from Performance tools.
## Type Parameters
### T
`T`
## Parameters
### name
`string`
### fn
() => `T`
## Returns
`T`

View File

@@ -356,3 +356,63 @@ describe('convert decorator', () => {
expect(elementModel.shapeType).toBe('rect');
});
});
describe('surface group index cache', () => {
test('syncGroupChildrenIndex should replace outdated parent mappings', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
model._syncGroupChildrenIndex('group-1', ['a', 'b'], []);
expect(model._parentGroupMap.get('a')).toBe('group-1');
expect(model._parentGroupMap.get('b')).toBe('group-1');
model._syncGroupChildrenIndex('group-1', ['b', 'c']);
expect(model._parentGroupMap.has('a')).toBe(false);
expect(model._parentGroupMap.get('b')).toBe('group-1');
expect(model._parentGroupMap.get('c')).toBe('group-1');
});
test('removeGroupFromChildrenIndex should clear both child snapshot and reverse lookup', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
model._syncGroupChildrenIndex('group-2', ['x', 'y'], []);
model._removeGroupFromChildrenIndex('group-2');
expect(model._groupChildIdsMap.has('group-2')).toBe(false);
expect(model._parentGroupMap.has('x')).toBe(false);
expect(model._parentGroupMap.has('y')).toBe(false);
});
test('getGroup should recover from stale cache and update reverse lookup', () => {
const { surfaceModel } = commonSetup();
const model = surfaceModel as any;
const shapeId = surfaceModel.addElement({
type: 'testShape',
});
const shape = surfaceModel.getElementById(shapeId)!;
const fakeGroup = {
id: 'group-fallback',
hasChild: (element: { id: string }) => element.id === shapeId,
};
model._groupLikeModels.set(fakeGroup.id, fakeGroup);
model._parentGroupMap.set(shapeId, 'stale-group-id');
expect(surfaceModel.getGroup(shapeId)).toBe(fakeGroup);
expect(model._parentGroupMap.get(shapeId)).toBe(fakeGroup.id);
expect(model._parentGroupMap.has('stale-group-id')).toBe(false);
const otherShapeId = surfaceModel.addElement({
type: 'testShape',
});
model._parentGroupMap.set(otherShapeId, 'another-missing-group');
expect(surfaceModel.getGroup(otherShapeId)).toBeNull();
expect(model._parentGroupMap.has(otherShapeId)).toBe(false);
// keep one explicit check on element-based lookup path
expect(surfaceModel.getGroup(shape as any)).toBe(fakeGroup);
});
});

View File

@@ -0,0 +1,165 @@
import { describe, expect, test, vi } from 'vitest';
import {
type GfxGroupCompatibleInterface,
gfxGroupCompatibleSymbol,
} from '../../gfx/model/base.js';
import type { GfxModel } from '../../gfx/model/model.js';
import {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
} from '../../utils/tree.js';
type TestElement = {
id: string;
group: TestGroup | null;
groups: TestGroup[];
};
type TestGroup = TestElement & {
[gfxGroupCompatibleSymbol]: true;
childIds: string[];
childElements: GfxModel[];
addChild: (element: GfxModel) => void;
removeChild: (element: GfxModel) => void;
hasChild: (element: GfxModel) => boolean;
hasDescendant: (element: GfxModel) => boolean;
};
const createElement = (id: string): TestElement => ({
id,
group: null,
groups: [],
});
const createGroup = (id: string): TestGroup => {
const group: TestGroup = {
id,
[gfxGroupCompatibleSymbol]: true,
group: null,
groups: [],
childIds: [],
childElements: [],
addChild(element: GfxModel) {
const child = element as unknown as TestElement;
if (this.childElements.includes(element)) {
return;
}
this.childElements.push(element);
this.childIds.push(child.id);
child.group = this;
child.groups = [...this.groups, this];
},
removeChild(element: GfxModel) {
const child = element as unknown as TestElement;
this.childElements = this.childElements.filter(item => item !== element);
this.childIds = this.childIds.filter(id => id !== child.id);
if (child.group === this) {
child.group = null;
child.groups = [];
}
},
hasChild(element: GfxModel) {
return this.childElements.includes(element);
},
hasDescendant(element: GfxModel) {
return descendantElementsImpl(
this as unknown as GfxGroupCompatibleInterface
).includes(element);
},
};
return group;
};
describe('tree utils', () => {
test('batchAddChildren prefers container.addChildren and deduplicates', () => {
const a = createElement('a') as unknown as GfxModel;
const b = createElement('b') as unknown as GfxModel;
const container = {
addChildren: vi.fn(),
addChild: vi.fn(),
};
batchAddChildren(container as any, [a, a, b]);
expect(container.addChildren).toHaveBeenCalledTimes(1);
expect(container.addChildren).toHaveBeenCalledWith([a, b]);
expect(container.addChild).not.toHaveBeenCalled();
});
test('batchRemoveChildren falls back to container.removeChild and deduplicates', () => {
const a = createElement('a') as unknown as GfxModel;
const b = createElement('b') as unknown as GfxModel;
const container = {
removeChild: vi.fn(),
};
batchRemoveChildren(container as any, [a, a, b]);
expect(container.removeChild).toHaveBeenCalledTimes(2);
expect(container.removeChild).toHaveBeenNthCalledWith(1, a);
expect(container.removeChild).toHaveBeenNthCalledWith(2, b);
});
test('getTopElements removes descendants when ancestors are selected', () => {
const root = createGroup('root');
const nested = createGroup('nested');
const leafA = createElement('leaf-a');
const leafB = createElement('leaf-b');
const leafC = createElement('leaf-c');
root.addChild(leafA as unknown as GfxModel);
root.addChild(nested as unknown as GfxModel);
nested.addChild(leafB as unknown as GfxModel);
const result = getTopElements([
root as unknown as GfxModel,
nested as unknown as GfxModel,
leafA as unknown as GfxModel,
leafB as unknown as GfxModel,
leafC as unknown as GfxModel,
]);
expect(result).toEqual([
root as unknown as GfxModel,
leafC as unknown as GfxModel,
]);
});
test('descendantElementsImpl stops on cyclic graph', () => {
const groupA = createGroup('group-a');
const groupB = createGroup('group-b');
groupA.addChild(groupB as unknown as GfxModel);
groupB.addChild(groupA as unknown as GfxModel);
const descendants = descendantElementsImpl(groupA as unknown as any);
expect(descendants).toHaveLength(2);
expect(new Set(descendants).size).toBe(2);
});
test('canSafeAddToContainer blocks self and circular descendants', () => {
const parent = createGroup('parent');
const child = createGroup('child');
const unrelated = createElement('plain');
parent.addChild(child as unknown as GfxModel);
expect(
canSafeAddToContainer(parent as unknown as any, parent as unknown as any)
).toBe(false);
expect(
canSafeAddToContainer(child as unknown as any, parent as unknown as any)
).toBe(false);
expect(
canSafeAddToContainer(
parent as unknown as any,
unrelated as unknown as any
)
).toBe(true);
});
});

View File

@@ -190,7 +190,7 @@ export class Clipboard extends LifeCycleWatcher {
);
}
return slice;
} catch (error) {
} catch {
const getDataByType = this._getDataByType(data);
const slice = await this._getSnapshotByPriority(
type => getDataByType(type),

View File

@@ -1,5 +1,5 @@
import { LifeCycleWatcher } from '../extension/index.js';
import { BlockServiceIdentifier } from '../identifier.js';
import { LifeCycleWatcher } from './lifecycle-watcher.js';
export class ServiceManager extends LifeCycleWatcher {
static override readonly key = 'serviceManager';

View File

@@ -5,6 +5,8 @@ export {
SortOrder,
} from '../utils/layer.js';
export {
batchAddChildren,
batchRemoveChildren,
canSafeAddToContainer,
descendantElementsImpl,
getTopElements,
@@ -94,6 +96,8 @@ export {
type SurfaceBlockProps,
type SurfaceMiddleware,
} from './model/surface/surface-model.js';
export { measureOperation } from './perf.js';
export { createRafCoalescer, type RafCoalescer } from './raf-coalescer.js';
export { GfxSelectionManager } from './selection.js';
export {
SurfaceMiddlewareBuilder,

View File

@@ -11,6 +11,7 @@ import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
import type { GfxModel } from '../model/model.js';
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import { createRafCoalescer } from '../raf-coalescer.js';
import type { GfxElementModelView } from '../view/view.js';
import { createInteractionContext, type SupportedEvents } from './event.js';
import {
@@ -55,6 +56,20 @@ export const InteractivityIdentifier = GfxExtensionIdentifier(
'interactivity-manager'
) as ServiceIdentifier<InteractivityManager>;
const DRAG_MOVE_RAF_THRESHOLD = 100;
const DRAG_MOVE_HEAVY_COST_MS = 4;
const shouldAllowDragMoveCoalescing = (
elements: { model: GfxModel }[]
): boolean => {
return elements.every(({ model }) => {
const isConnector = 'type' in model && model.type === 'connector';
const isContainer = 'childIds' in model;
return !isConnector && !isContainer;
});
};
export class InteractivityManager extends GfxExtension {
static override key = 'interactivity-manager';
@@ -381,11 +396,18 @@ export class InteractivityManager extends GfxExtension {
};
let dragLastPos = internal.dragStartPos;
let lastEvent = event;
let lastMoveDelta: [number, number] | null = null;
const canCoalesceDragMove = shouldAllowDragMoveCoalescing(
internal.elements
);
let shouldCoalesceDragMove =
canCoalesceDragMove &&
internal.elements.length >= DRAG_MOVE_RAF_THRESHOLD;
const applyDragMove = (event: PointerEvent) => {
const moveStart = performance.now();
lastEvent = event;
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
onDragMove(lastEvent as PointerEvent);
});
const onDragMove = (event: PointerEvent) => {
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
);
@@ -407,6 +429,16 @@ export class InteractivityManager extends GfxExtension {
moveContext[direction] = 0;
}
if (
lastMoveDelta &&
lastMoveDelta[0] === moveContext.dx &&
lastMoveDelta[1] === moveContext.dy
) {
return;
}
lastMoveDelta = [moveContext.dx, moveContext.dy];
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler?.onDragMove?.(moveContext)
@@ -423,13 +455,39 @@ export class InteractivityManager extends GfxExtension {
elements: internal.elements,
});
});
if (
canCoalesceDragMove &&
!shouldCoalesceDragMove &&
performance.now() - moveStart > DRAG_MOVE_HEAVY_COST_MS
) {
shouldCoalesceDragMove = true;
}
};
const dragMoveCoalescer = createRafCoalescer<PointerEvent>(applyDragMove);
const flushPendingDragMove = () => {
dragMoveCoalescer.flush();
};
const onDragMove = (event: PointerEvent) => {
if (!shouldCoalesceDragMove) {
applyDragMove(event);
return;
}
dragMoveCoalescer.schedule(event);
};
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
onDragMove(lastEvent as PointerEvent);
});
const onDragEnd = (event: PointerEvent) => {
this.activeInteraction$.value = null;
host.removeEventListener('pointermove', onDragMove, false);
host.removeEventListener('pointerup', onDragEnd, false);
viewportWatcher.unsubscribe();
flushPendingDragMove();
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])

View File

@@ -101,6 +101,8 @@ export class LayerManager extends GfxExtension {
layers: Layer[] = [];
private readonly _groupChildSnapshot = new Map<string, string[]>();
slots = {
layerUpdated: new Subject<{
type: 'delete' | 'add' | 'update';
@@ -148,6 +150,43 @@ export class LayerManager extends GfxExtension {
: 'block';
}
private _getModelById(id: string): GfxModel | null {
if (!this._surface) return null;
return (
this._surface.getElementById(id) ??
(this._doc.getModelById(id) as GfxModel | undefined) ??
null
);
}
private _getRelatedGroupElements(
group: GfxModel & GfxGroupCompatibleInterface,
oldChildIds?: string[]
) {
const elements = new Set<GfxModel>([group, ...group.descendantElements]);
oldChildIds?.forEach(id => {
const model = this._getModelById(id);
if (!model) return;
elements.add(model);
if (isGfxGroupCompatibleModel(model)) {
model.descendantElements.forEach(descendant => {
elements.add(descendant);
});
}
});
return [...elements];
}
private _syncGroupChildSnapshot(
group: GfxModel & GfxGroupCompatibleInterface
) {
this._groupChildSnapshot.set(group.id, [...group.childIds]);
}
private _initLayers() {
let blockIdx = 0;
let canvasIdx = 0;
@@ -487,6 +526,29 @@ export class LayerManager extends GfxExtension {
updateLayersZIndex(layers, index);
}
private _refreshElementsInLayer(elements: GfxModel[]) {
const uniqueElements = [...new Set(elements)];
uniqueElements.forEach(element => {
const modelType = this._getModelType(element);
if (modelType === 'canvas') {
removeFromOrderedArray(this.canvasElements, element);
insertToOrderedArray(this.canvasElements, element);
} else {
removeFromOrderedArray(this.blocks, element);
insertToOrderedArray(this.blocks, element);
}
});
uniqueElements.forEach(element => {
this._removeFromLayer(element, this._getModelType(element));
});
uniqueElements.sort(compare).forEach(element => {
this._insertIntoLayer(element, this._getModelType(element));
});
}
private _reset() {
const elements = (
this._doc
@@ -512,6 +574,17 @@ export class LayerManager extends GfxExtension {
this.canvasElements.sort(compare);
this.blocks.sort(compare);
this._groupChildSnapshot.clear();
this.canvasElements.forEach(element => {
if (isGfxGroupCompatibleModel(element)) {
this._syncGroupChildSnapshot(element);
}
});
this.blocks.forEach(element => {
if (isGfxGroupCompatibleModel(element)) {
this._syncGroupChildSnapshot(element);
}
});
this._initLayers();
this._buildCanvasLayers();
@@ -522,7 +595,8 @@ export class LayerManager extends GfxExtension {
*/
private _updateLayer(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
) {
const modelType = this._getModelType(element);
const isLocalElem = element instanceof GfxLocalElementModel;
@@ -539,7 +613,16 @@ export class LayerManager extends GfxExtension {
};
if (shouldUpdateGroupChildren) {
this._reset();
const group = element as GfxModel & GfxGroupCompatibleInterface;
const oldChildIds = childIdsChanged
? Array.isArray(oldValues?.['childIds'])
? (oldValues['childIds'] as string[])
: this._groupChildSnapshot.get(group.id)
: undefined;
const relatedElements = this._getRelatedGroupElements(group, oldChildIds);
this._refreshElementsInLayer(relatedElements);
this._syncGroupChildSnapshot(group);
return true;
}
@@ -581,6 +664,13 @@ export class LayerManager extends GfxExtension {
element
);
}
if (isContainer) {
this._syncGroupChildSnapshot(
element as GfxModel & GfxGroupCompatibleInterface
);
}
this._insertIntoLayer(element as GfxModel, modelType);
if (isContainer) {
@@ -648,7 +738,26 @@ export class LayerManager extends GfxExtension {
const isLocalElem = element instanceof GfxLocalElementModel;
if (isGroup) {
this._reset();
const groupElements = this._getRelatedGroupElements(
element as GfxModel & GfxGroupCompatibleInterface
);
const descendants = groupElements.filter(model => model !== element);
if (!isLocalElem) {
const groupType = this._getModelType(element);
if (groupType === 'canvas') {
removeFromOrderedArray(this.canvasElements, element);
} else {
removeFromOrderedArray(this.blocks, element);
}
this._removeFromLayer(element, groupType);
}
this._groupChildSnapshot.delete(element.id);
this._refreshElementsInLayer(descendants);
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'delete',
initiatingElement: element as GfxModel,
@@ -680,6 +789,7 @@ export class LayerManager extends GfxExtension {
override unmounted() {
this.slots.layerUpdated.complete();
this._groupChildSnapshot.clear();
this._disposable.dispose();
}
@@ -777,9 +887,10 @@ export class LayerManager extends GfxExtension {
update(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
) {
if (this._updateLayer(element, props)) {
if (this._updateLayer(element, props, oldValues)) {
this._buildCanvasLayers();
this.slots.layerUpdated.next({
type: 'update',
@@ -867,7 +978,11 @@ export class LayerManager extends GfxExtension {
this._disposable.add(
surface.elementUpdated.subscribe(payload => {
if (payload.props['index'] || payload.props['childIds']) {
this.update(surface.getElementById(payload.id)!, payload.props);
this.update(
surface.getElementById(payload.id)!,
payload.props,
payload.oldValues
);
}
})
);

View File

@@ -6,6 +6,7 @@ import { signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import { measureOperation } from '../../perf.js';
import {
type GfxGroupCompatibleInterface,
isGfxGroupCompatibleModel,
@@ -74,6 +75,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
protected _groupLikeModels = new Map<string, GfxGroupModel>();
protected _parentGroupMap = new Map<string, string>();
protected _groupChildIdsMap = new Map<string, string[]>();
protected _middlewares: SurfaceMiddleware[] = [];
protected _surfaceBlockModel = true;
@@ -133,6 +138,44 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
});
}
private _collectElementsToDelete(
id: string,
deleteElementIds: Set<string>,
orderedDeleteIds: string[],
deleteBlockIds: Set<string>
) {
if (deleteElementIds.has(id)) {
return;
}
const element = this.getElementById(id);
if (!element) {
return;
}
deleteElementIds.add(id);
if (element instanceof GfxGroupLikeElementModel) {
element.childIds.forEach(childId => {
if (this.hasElementById(childId)) {
this._collectElementsToDelete(
childId,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
return;
}
if (this.store.hasBlock(childId)) {
deleteBlockIds.add(childId);
}
});
}
orderedDeleteIds.push(id);
}
private _createElementFromProps(
props: Record<string, unknown>,
options: {
@@ -247,6 +290,26 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
};
}
private _emitElementUpdated(
model: GfxPrimitiveElementModel,
payload: ElementUpdatedData
) {
if (
isGfxGroupCompatibleModel(model) &&
('childIds' in payload.props || 'childIds' in payload.oldValues)
) {
const oldChildIds = Array.isArray(payload.oldValues['childIds'])
? (payload.oldValues['childIds'] as string[])
: undefined;
this._syncGroupChildrenIndex(model.id, model.childIds, oldChildIds);
}
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.propsUpdated.next({ key });
});
}
private _initElementModels() {
const elementsYMap = this.elements.getValue()!;
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
@@ -260,6 +323,7 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
if (isGfxGroupCompatibleModel(model)) {
this._groupLikeModels.set(model.id, model);
this._syncGroupChildrenIndex(model.id, model.childIds, []);
}
};
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
@@ -270,7 +334,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
sameTypeElements.splice(index, 1);
}
if (this._groupLikeModels.has(model.id)) {
this._parentGroupMap.delete(model.id);
if (isGfxGroupCompatibleModel(model)) {
this._removeGroupFromChildrenIndex(model.id);
this._groupLikeModels.delete(model.id);
}
};
@@ -304,9 +371,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
element,
{
onChange: payload => {
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
this._emitElementUpdated(model.model, {
...payload,
id,
});
},
skipFieldInit: true,
@@ -351,10 +418,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
val,
{
onChange: payload => {
(this.elementUpdated.next(payload),
Object.keys(payload.props).forEach(key => {
model.model.propsUpdated.next({ key });
}));
this._emitElementUpdated(model.model, {
...payload,
id: key,
});
},
skipFieldInit: true,
}
@@ -371,9 +438,12 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
Object.values(this.store.blocks.peek()).forEach(block => {
if (isGfxGroupCompatibleModel(block.model)) {
this._groupLikeModels.set(block.id, block.model);
this._syncGroupChildrenIndex(block.id, block.model.childIds, []);
}
});
this._rebuildGroupChildrenIndex();
elementsYMap.observe(onElementsMapChange);
const subscription = this.store.slots.blockUpdated.subscribe(payload => {
@@ -381,11 +451,17 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
case 'add':
if (isGfxGroupCompatibleModel(payload.model)) {
this._groupLikeModels.set(payload.id, payload.model);
this._syncGroupChildrenIndex(
payload.id,
payload.model.childIds,
[]
);
}
break;
case 'delete':
if (isGfxGroupCompatibleModel(payload.model)) {
this._removeGroupFromChildrenIndex(payload.id);
this._groupLikeModels.delete(payload.id);
}
{
@@ -395,6 +471,16 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
group.removeChild(payload.model as GfxModel);
}
}
this._parentGroupMap.delete(payload.id);
break;
case 'update':
if (payload.props.key === 'childElementIds') {
const group = this.store.getBlock(payload.id)?.model;
if (group && isGfxGroupCompatibleModel(group)) {
this._syncGroupChildrenIndex(group.id, group.childIds);
}
}
break;
}
@@ -403,6 +489,8 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
this.deleted.subscribe(() => {
elementsYMap.unobserve(onElementsMapChange);
subscription.unsubscribe();
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
});
}
@@ -500,6 +588,71 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
return this._elementCtorMap[type];
}
private _rebuildGroupChildrenIndex() {
this._groupChildIdsMap.clear();
this._parentGroupMap.clear();
this._groupLikeModels.forEach(group => {
this._syncGroupChildrenIndex(group.id, group.childIds, []);
});
}
private _removeFromParentGroupIfNeeded(
element: GfxModel,
deleteElementIds: Set<string>
) {
const parentGroupId = this._parentGroupMap.get(element.id);
if (parentGroupId && deleteElementIds.has(parentGroupId)) {
return;
}
let parentGroup: GfxGroupModel | null = null;
if (parentGroupId) {
parentGroup = this._groupLikeModels.get(parentGroupId) ?? null;
}
parentGroup = parentGroup ?? this.getGroup(element.id);
if (parentGroup && !deleteElementIds.has(parentGroup.id)) {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
parentGroup.removeChild(element);
}
}
private _removeGroupFromChildrenIndex(groupId: string) {
const previousChildIds = this._groupChildIdsMap.get(groupId) ?? [];
previousChildIds.forEach(childId => {
if (this._parentGroupMap.get(childId) === groupId) {
this._parentGroupMap.delete(childId);
}
});
this._groupChildIdsMap.delete(groupId);
}
private _syncGroupChildrenIndex(
groupId: string,
nextChildIds: string[],
previousChildIds?: string[]
) {
const prev = previousChildIds ?? this._groupChildIdsMap.get(groupId) ?? [];
prev.forEach(childId => {
if (this._parentGroupMap.get(childId) === groupId) {
this._parentGroupMap.delete(childId);
}
});
nextChildIds.forEach(childId => {
this._parentGroupMap.set(childId, groupId);
});
this._groupChildIdsMap.set(groupId, [...nextChildIds]);
}
addElement<T extends object = Record<string, unknown>>(
props: Partial<T> & { type: string }
) {
@@ -526,9 +679,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
const elementModel = this._createElementFromProps(props, {
onChange: payload => {
this.elementUpdated.next(payload);
Object.keys(payload.props).forEach(key => {
elementModel.model.propsUpdated.next({ key });
this._emitElementUpdated(elementModel.model, {
...payload,
id,
});
},
});
@@ -560,24 +713,48 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
return;
}
this.store.transact(() => {
const element = this.getElementById(id)!;
const group = this.getGroup(id);
measureOperation('edgeless:delete-element', () => {
const deleteElementIds = new Set<string>();
const orderedDeleteIds: string[] = [];
const deleteBlockIds = new Set<string>();
if (element instanceof GfxGroupLikeElementModel) {
element.childIds.forEach(childId => {
if (this.hasElementById(childId)) {
this.deleteElement(childId);
} else if (this.store.hasBlock(childId)) {
this.store.deleteBlock(this.store.getBlock(childId)!.model);
}
});
this._collectElementsToDelete(
id,
deleteElementIds,
orderedDeleteIds,
deleteBlockIds
);
if (orderedDeleteIds.length === 0) {
return;
}
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group?.removeChild(element as GfxModel);
this.store.transact(() => {
orderedDeleteIds.forEach(elementId => {
const element = this.getElementById(elementId);
this.elements.getValue()!.delete(id);
if (!element) {
return;
}
this._removeFromParentGroupIfNeeded(element, deleteElementIds);
this.elements.getValue()!.delete(elementId);
});
deleteBlockIds.forEach(blockId => {
const block = this.store.getBlock(blockId)?.model;
if (!block) {
return;
}
this._removeFromParentGroupIfNeeded(
block as GfxModel,
deleteElementIds
);
this.store.deleteBlock(block);
});
});
});
}
@@ -607,18 +784,31 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
}
getGroup(elem: string | GfxModel): GfxGroupModel | null {
elem =
const id = typeof elem === 'string' ? elem : elem.id;
const parentGroupId = this._parentGroupMap.get(id);
if (parentGroupId) {
const group = this._groupLikeModels.get(parentGroupId);
if (group) {
return group;
}
this._parentGroupMap.delete(id);
}
const model =
typeof elem === 'string'
? ((this.getElementById(elem) ??
this.store.getBlock(elem)?.model) as GfxModel)
: elem;
if (!elem) return null;
if (!model) return null;
assertType<GfxModel>(elem);
assertType<GfxModel>(model);
for (const group of this._groupLikeModels.values()) {
if (group.hasChild(elem)) {
if (group.hasChild(model)) {
this._parentGroupMap.set(id, group.id);
return group;
}
}

View File

@@ -0,0 +1,31 @@
let opMeasureSeq = 0;
/**
* Measure operation cost via Performance API when available.
*
* Marks are always cleared, while measure entries are intentionally retained
* so callers can inspect them from Performance tools.
*/
export const measureOperation = <T>(name: string, fn: () => T): T => {
if (
typeof performance === 'undefined' ||
typeof performance.mark !== 'function' ||
typeof performance.measure !== 'function'
) {
return fn();
}
const operationId = opMeasureSeq++;
const startMark = `${name}:${operationId}:start`;
const endMark = `${name}:${operationId}:end`;
performance.mark(startMark);
try {
return fn();
} finally {
performance.mark(endMark);
performance.measure(name, startMark, endMark);
performance.clearMarks(startMark);
performance.clearMarks(endMark);
}
};

View File

@@ -0,0 +1,76 @@
export interface RafCoalescer<T> {
cancel: () => void;
flush: () => void;
schedule: (payload: T) => void;
}
type FrameScheduler = (callback: FrameRequestCallback) => number;
type FrameCanceller = (id: number) => void;
const getFrameScheduler = (): FrameScheduler => {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame;
}
return callback => {
return globalThis.setTimeout(() => {
callback(
typeof performance !== 'undefined' ? performance.now() : Date.now()
);
}, 16) as unknown as number;
};
};
const getFrameCanceller = (): FrameCanceller => {
if (typeof cancelAnimationFrame === 'function') {
return cancelAnimationFrame;
}
return id => globalThis.clearTimeout(id);
};
/**
* Coalesce high-frequency updates and only process the latest payload in one frame.
*/
export const createRafCoalescer = <T>(
apply: (payload: T) => void
): RafCoalescer<T> => {
const scheduleFrame = getFrameScheduler();
const cancelFrame = getFrameCanceller();
let pendingPayload: T | undefined;
let hasPendingPayload = false;
let rafId: number | null = null;
const run = () => {
rafId = null;
if (!hasPendingPayload) return;
const payload = pendingPayload as T;
pendingPayload = undefined;
hasPendingPayload = false;
apply(payload);
};
return {
schedule(payload: T) {
pendingPayload = payload;
hasPendingPayload = true;
if (rafId !== null) return;
rafId = scheduleFrame(run);
},
flush() {
if (rafId !== null) cancelFrame(rafId);
run();
},
cancel() {
if (rafId !== null) {
cancelFrame(rafId);
rafId = null;
}
pendingPayload = undefined;
hasPendingPayload = false;
},
};
};

View File

@@ -41,6 +41,10 @@ export function requestThrottledConnectedFrame<
viewport: PropTypes.instanceOf(Viewport),
})
export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private static readonly VIEWPORT_REFRESH_PIXEL_THRESHOLD = 18;
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
static override styles = css`
gfx-viewport {
position: absolute;
@@ -104,6 +108,14 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private _lastVisibleModels?: Set<GfxBlockElementModel>;
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
private _lastViewportRefreshTime = 0;
private _pendingViewportRefreshTimer: ReturnType<
typeof globalThis.setTimeout
> | null = null;
private readonly _pendingChildrenUpdates: {
id: string;
resolve: () => void;
@@ -115,26 +127,90 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private _updatingChildrenFlag = false;
private _clearPendingViewportRefreshTimer() {
if (this._pendingViewportRefreshTimer !== null) {
clearTimeout(this._pendingViewportRefreshTimer);
this._pendingViewportRefreshTimer = null;
}
}
private _scheduleTrailingViewportRefresh() {
this._clearPendingViewportRefreshTimer();
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
this._pendingViewportRefreshTimer = null;
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
}
private _refreshViewportByViewportUpdate(update: {
zoom: number;
center: [number, number];
}) {
const now = performance.now();
const previous = this._lastViewportUpdate;
this._lastViewportUpdate = {
zoom: update.zoom,
center: [update.center[0], update.center[1]],
};
if (!previous) {
this._lastViewportRefreshTime = now;
this._refreshViewport();
return;
}
const zoomChanged = Math.abs(previous.zoom - update.zoom) > 0.0001;
const centerMovedInPixel = Math.hypot(
(update.center[0] - previous.center[0]) * update.zoom,
(update.center[1] - previous.center[1]) * update.zoom
);
const timeoutReached =
now - this._lastViewportRefreshTime >=
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
if (
zoomChanged ||
centerMovedInPixel >=
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
timeoutReached
) {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = now;
this._refreshViewport();
return;
}
this._scheduleTrailingViewportRefresh();
}
override connectedCallback(): void {
super.connectedCallback();
const viewportUpdateCallback = () => {
this._refreshViewport();
};
if (!this.enableChildrenSchedule) {
delete this.scheduleUpdateChildren;
}
this._hideOutsideAndNoSelectedBlock();
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
this.viewport.viewportUpdated.subscribe(update =>
this._refreshViewportByViewportUpdate(update)
)
);
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => viewportUpdateCallback())
this.viewport.sizeUpdated.subscribe(() => {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
})
);
}
override disconnectedCallback(): void {
this._clearPendingViewportRefreshTimer();
super.disconnectedCallback();
}
override render() {
return html``;
}

View File

@@ -7,6 +7,11 @@ import {
} from '../gfx/model/base.js';
import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
type BatchGroupContainer = GfxGroupCompatibleInterface & {
addChildren?: (elements: GfxModel[]) => void;
removeChildren?: (elements: GfxModel[]) => void;
};
/**
* Get the top elements from the list of elements, which are in some tree structures.
*
@@ -26,19 +31,65 @@ import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
* The result should be `[G1, G4, E6]`
*/
export function getTopElements(elements: GfxModel[]): GfxModel[] {
const results = new Set(elements);
const uniqueElements = [...new Set(elements)];
const selected = new Set(uniqueElements);
const topElements: GfxModel[] = [];
elements = [...new Set(elements)];
for (const element of uniqueElements) {
let ancestor = element.group;
let hasSelectedAncestor = false;
elements.forEach(e1 => {
elements.forEach(e2 => {
if (isGfxGroupCompatibleModel(e1) && e1.hasDescendant(e2)) {
results.delete(e2);
while (ancestor) {
if (selected.has(ancestor as GfxModel)) {
hasSelectedAncestor = true;
break;
}
});
});
ancestor = ancestor.group;
}
return [...results];
if (!hasSelectedAncestor) {
topElements.push(element);
}
}
return topElements;
}
export function batchAddChildren(
container: GfxGroupCompatibleInterface,
elements: GfxModel[]
) {
const uniqueElements = [...new Set(elements)];
if (uniqueElements.length === 0) return;
const batchContainer = container as BatchGroupContainer;
if (batchContainer.addChildren) {
batchContainer.addChildren(uniqueElements);
return;
}
uniqueElements.forEach(element => {
container.addChild(element);
});
}
export function batchRemoveChildren(
container: GfxGroupCompatibleInterface,
elements: GfxModel[]
) {
const uniqueElements = [...new Set(elements)];
if (uniqueElements.length === 0) return;
const batchContainer = container as BatchGroupContainer;
if (batchContainer.removeChildren) {
batchContainer.removeChildren(uniqueElements);
return;
}
uniqueElements.forEach(element => {
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
container.removeChild(element);
});
}
function traverse(
@@ -64,7 +115,9 @@ function traverse(
});
}
postCallBack && postCallBack(element);
if (postCallBack) {
postCallBack(element);
}
};
innerTraverse(element);

View File

@@ -170,10 +170,10 @@ export class EditorHost extends SignalWatcher(
...Object.values(widgetTags),
];
await Promise.all(
elementsTags.map(tag => {
elementsTags.map(async tag => {
const element = this.renderRoot.querySelector(tag._$litStatic$);
if (element instanceof LitElement) {
return element.updateComplete;
return await element.updateComplete;
}
return null;
})

View File

@@ -382,6 +382,7 @@ describe('addBlock', () => {
const doc0 = collection.createDoc('doc:home');
const doc1 = collection.createDoc('space:doc1');
// eslint-disable-next-line @typescript-eslint/await-thenable
await Promise.all([doc0.load(), doc1.load()]);
assert.equal(collection.docs.size, 2);
const store0 = doc0.getStore({

View File

@@ -1,7 +1,7 @@
import { minimatch } from 'minimatch';
import { SCHEMA_NOT_FOUND_MESSAGE } from '../consts.js';
import { BlockSchema, type BlockSchemaType } from '../model/index.js';
import { BlockSchema, type BlockSchemaType } from '../model/block/zod.js';
import { SchemaValidateError } from './error.js';
/**

View File

@@ -1,9 +1,6 @@
import {
BlockModel,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index';
import { BlockModel } from '../model/block/block-model.js';
import { type DraftModel, toDraftModel } from '../model/block/draft.js';
import type { Store } from '../model/store/store.js';
type SliceData = {
content: DraftModel[];

View File

@@ -3,14 +3,11 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick } from '@blocksuite/global/utils';
import { Subject } from 'rxjs';
import {
BlockModel,
type BlockSchemaType,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index.js';
import type { Schema } from '../schema/index.js';
import { BlockModel } from '../model/block/block-model.js';
import { type DraftModel, toDraftModel } from '../model/block/draft.js';
import type { BlockSchemaType } from '../model/block/zod.js';
import type { Store } from '../model/store/store.js';
import type { Schema } from '../schema/schema.js';
import { AssetsManager } from './assets.js';
import { BaseBlockTransformer } from './base.js';
import type {

View File

@@ -235,6 +235,69 @@ describe('connector', () => {
expect(model.getConnectors(id2)).toEqual([]);
});
test('should update endpoint index when connector retargets', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const id3 = model.addElement({
type: 'shape',
});
const connectorId = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
const connector = model.getElementById(connectorId)!;
expect(model.getConnectors(id).map(c => c.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
model.updateElement(connectorId, {
source: {
id: id3,
},
target: {
id: id2,
},
});
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id3).map(c => c.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
});
test('getConnectors should purge stale connector ids from endpoint cache', () => {
const shapeId = model.addElement({
type: 'shape',
});
const surfaceModel = model as any;
surfaceModel._connectorIdsByEndpoint.set(
shapeId,
new Set(['missing-connector-id'])
);
surfaceModel._connectorEndpoints.set('missing-connector-id', {
sourceId: shapeId,
targetId: null,
});
expect(model.getConnectors(shapeId)).toEqual([]);
expect(
surfaceModel._connectorIdsByEndpoint
.get(shapeId)
?.has('missing-connector-id') ?? false
).toBe(false);
expect(surfaceModel._connectorEndpoints.has('missing-connector-id')).toBe(
false
);
});
test('should return null if connector are deleted', async () => {
const id = model.addElement({
type: 'shape',

View File

@@ -5,6 +5,7 @@ import eslint from '@eslint/js';
import tsParser from '@typescript-eslint/parser';
import eslintConfigPrettier from 'eslint-config-prettier';
import importX from 'eslint-plugin-import-x';
import oxlint from 'eslint-plugin-oxlint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
@@ -16,7 +17,10 @@ const __require = createRequire(import.meta.url);
const rxjs = __require('@smarttools/eslint-plugin-rxjs');
const ignoreList = readFileSync('.prettierignore', 'utf-8')
const ignoreList = readFileSync(
new URL('.prettierignore', import.meta.url),
'utf-8'
)
.split('\n')
.filter(line => line.trim() && !line.startsWith('#'));
@@ -60,105 +64,51 @@ export default tseslint.config(
'simple-import-sort': simpleImportSort,
rxjs,
unicorn,
oxlint,
},
rules: {
...eslint.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
...oxlint.configs.recommended.rules,
// covered by TypeScript
'no-dupe-args': 'off',
// the following rules are disabled because they are covered by oxlint
'array-callback-return': 'off',
'constructor-super': 'off',
eqeqeq: 'off',
'getter-return': 'off',
'for-direction': 'off',
'require-yield': 'off',
'use-isnan': 'off',
'valid-typeof': 'off',
'no-self-compare': 'off',
'no-empty': 'off',
'no-constant-binary-expression': 'off',
'no-constructor-return': 'off',
'no-func-assign': 'off',
'no-global-assign': 'off',
'no-ex-assign': 'off',
'no-fallthrough': 'off',
'no-irregular-whitespace': 'off',
'no-control-regex': 'off',
'no-with': 'off',
'no-debugger': 'off',
'no-const-assign': 'off',
'no-import-assign': 'off',
'no-setter-return': 'off',
'no-obj-calls': 'off',
'no-unsafe-negation': 'off',
'no-dupe-class-members': 'off',
'no-dupe-keys': 'off',
'no-this-before-super': 'off',
'no-empty-character-class': 'off',
'no-useless-catch': 'off',
'no-async-promise-executor': 'off',
'no-unreachable': 'off',
'no-duplicate-case': 'off',
'no-empty-pattern': 'off',
'no-unused-labels': 'off',
'no-sparse-arrays': 'off',
'no-delete-var': 'off',
'no-compare-neg-zero': 'off',
'no-redeclare': 'off',
'no-case-declarations': 'off',
'no-class-assign': 'off',
'no-var': 'off',
'no-self-assign': 'off',
'no-inner-declarations': 'off',
'no-dupe-else-if': 'off',
'no-invalid-regexp': 'off',
'no-unsafe-finally': 'off',
'no-prototype-builtins': 'off',
'no-shadow-restricted-names': 'off',
'no-nonoctal-decimal-escape': 'off',
'no-constant-condition': 'off',
'no-useless-escape': 'off',
'no-unsafe-optional-chaining': 'off',
'no-extra-boolean-cast': 'off',
'no-regex-spaces': 'off',
'no-unused-vars': 'off',
'no-undef': 'off',
'no-cond-assign': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/no-unknown-property': 'off',
'react/no-string-refs': 'off',
'react/no-direct-mutation-state': 'off',
'react/require-render-return': 'off',
'react/jsx-no-undef': 'off',
'react/jsx-no-duplicate-props': 'off',
'react/jsx-key': 'off',
'react/no-danger-with-children': 'off',
'react/no-unescaped-entities': 'off',
'react/no-is-mounted': 'off',
'react/no-find-dom-node': 'off',
'react/no-children-prop': 'off',
'react/no-render-return-value': 'off',
'react/jsx-no-target-blank': 'off',
'react/jsx-no-comment-textnodes': 'off',
'react/prop-types': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/use-memo': 'off',
'sonarjs/no-useless-catch': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-loss-of-precision': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/triple-slash-reference': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-duplicate-enum-values': 'off',
'@typescript-eslint/no-extra-non-null-assertion': 'off',
'@typescript-eslint/no-misused-new': 'off',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/no-unsafe-declaration-merging': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
@@ -167,30 +117,13 @@ export default tseslint.config(
'@typescript-eslint/no-empty-function': 'off',
// rules that are not supported by oxlint
'no-unreachable-loop': 'error',
'@typescript-eslint/no-unsafe-function-type': 'error',
'@typescript-eslint/no-wrapper-object-types': 'error',
'@typescript-eslint/unified-signatures': 'error',
'@typescript-eslint/return-await': [
'error',
'error-handling-correctness-only',
],
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/dist'],
message: "Don't import from dist",
allowTypeImports: false,
},
{
group: ['**/src'],
message: "Don't import from src",
allowTypeImports: false,
},
],
},
],
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-empty-collection': 'error',
@@ -198,7 +131,6 @@ export default tseslint.config(
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-ignored-return': 'error',
'sonarjs/no-one-iteration-loop': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/non-existent-operator': 'error',
'sonarjs/no-collapsible-if': 'error',
@@ -234,13 +166,6 @@ export default tseslint.config(
'error',
{ includeInternal: true },
],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks:
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget|useRefEffect)',
},
],
'rxjs/finnish': [
'error',
{
@@ -304,7 +229,6 @@ export default tseslint.config(
{ ignoreVoid: true },
],
'@typescript-eslint/no-misused-promises': 0,
'@typescript-eslint/no-restricted-imports': 0,
},
},
{

View File

@@ -26,9 +26,10 @@
"lint:eslint:fix": "yarn lint:eslint --fix --fix-type problem,suggestion,layout",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint -c oxlint.json --deny-warnings",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"lint:ox": "oxlint --deny-warnings",
"lint:ox:fix": "yarn lint:ox --fix",
"lint": "yarn lint:ox && yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:ox:fix && yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
@@ -51,7 +52,7 @@
"devDependencies": {
"@affine-tools/cli": "workspace:*",
"@capacitor/cli": "^7.0.0",
"@eslint/js": "^9.16.0",
"@eslint/js": "^9.39.2",
"@faker-js/faker": "^10.1.0",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.6.1",
@@ -61,32 +62,33 @@
"@toeverything/infra": "workspace:*",
"@types/eslint": "^9.6.1",
"@types/node": "^22.0.0",
"@typescript-eslint/parser": "^8.18.0",
"@typescript-eslint/parser": "^8.55.0",
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-istanbul": "^3.2.4",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"electron": "^39.0.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import-x": "^4.5.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-oxlint": "^1.46.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^59.0.0",
"eslint-plugin-sonarjs": "^3.0.7",
"eslint-plugin-unicorn": "^63.0.0",
"happy-dom": "^20.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.0.0",
"msw": "^2.12.4",
"oxlint": "~1.18.0",
"oxlint": "^1.47.0",
"prettier": "^3.7.4",
"semver": "^7.7.3",
"serve": "^14.2.4",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.0",
"typescript-eslint": "^8.55.0",
"unplugin-swc": "^1.5.9",
"vite": "^7.2.7",
"vitest": "^3.2.4"

View File

@@ -31,7 +31,7 @@ assert.strictEqual(
bench
.add('tiktoken', () => {
const encoder = encoding_for_model('gpt-4o');
encoder.encode_ordinary(FIXTURE).length;
void encoder.encode_ordinary(FIXTURE).length;
})
.add('native', () => {
fromModelName('gpt-4o').count(FIXTURE);

View File

@@ -43,7 +43,6 @@ class MockR2Provider extends R2StorageProvider {
destroy() {}
// @ts-ignore expect override
override async proxyPutObject(
key: string,
body: any,

View File

@@ -1,6 +1,7 @@
import { LookupAddress } from 'node:dns';
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import { LookupAddress } from 'dns';
import Sinon from 'sinon';
import type { Response } from 'supertest';
@@ -14,7 +15,6 @@ import { createTestingApp, TestingApp } from './utils';
type TestContext = {
app: TestingApp;
};
const test = ava as TestFn<TestContext>;
const LookupAddressStub = (async (_hostname, options) => {

View File

@@ -51,10 +51,10 @@ function parseKey(privateKey: string) {
let priv: KeyObject;
try {
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'pkcs8' });
} catch (e1) {
} catch {
try {
priv = createPrivateKey({ key: keyBuf, format: 'pem', type: 'sec1' });
} catch (e2) {
} catch {
// As a last resort rely on auto-detection
priv = createPrivateKey(keyBuf);
}

View File

@@ -175,7 +175,7 @@ export class R2StorageProvider extends S3StorageProvider {
body: Readable | Buffer | Uint8Array | string,
options: { contentType?: string; contentLength?: number } = {}
) {
return this.client.putObject(key, body as any, {
return this.client.putObject(key, this.normalizeBody(body), {
contentType: options.contentType,
contentLength: options.contentLength,
});
@@ -192,13 +192,24 @@ export class R2StorageProvider extends S3StorageProvider {
key,
uploadId,
partNumber,
body as any,
this.normalizeBody(body),
{ contentLength: options.contentLength }
);
return result.etag;
}
private normalizeBody(body: Readable | Buffer | Uint8Array | string) {
// s3mini does not accept Node.js Readable directly.
// Convert it to Web ReadableStream for compatibility.
if (body instanceof Readable) {
return Readable.toWeb(body);
} else if (typeof body === 'string') {
return this.encoder.encode(body);
}
return body;
}
override async get(
key: string,
signedUrl?: boolean

View File

@@ -281,7 +281,7 @@ export class S3StorageProvider implements StorageProvider {
this.logger.verbose(`Read object \`${key}\``);
return {
body: Readable.fromWeb(obj.body as any),
body: Readable.fromWeb(obj.body),
metadata: {
contentType: contentType ?? 'application/octet-stream',
contentLength: contentLength ?? 0,

View File

@@ -22,12 +22,14 @@ function firstNonEmpty(...values: Array<string | undefined>) {
}
export function getRequestClientIp(req: Request) {
return firstNonEmpty(
req.get('CF-Connecting-IP'),
firstForwardedForIp(req.get('X-Forwarded-For')),
req.get('X-Real-IP'),
req.ip
)!;
return (
firstNonEmpty(
req.get('CF-Connecting-IP'),
firstForwardedForIp(req.get('X-Forwarded-For')),
req.get('X-Real-IP'),
req.ip
) ?? ''
);
}
export function getRequestTrackerId(req: Request) {
@@ -39,6 +41,7 @@ export function getRequestTrackerId(req: Request) {
req.get('X-Real-IP'),
req.get('CF-Ray'),
req.ip
)!
) ??
''
);
}

View File

@@ -180,7 +180,7 @@ export async function assertSsrFSafeUrl(
let addresses: string[];
try {
addresses = await resolveHostAddresses(hostname);
} catch (error) {
} catch {
throw createSsrfBlockedError('unresolvable_hostname', {
url: url.toString(),
hostname,

View File

@@ -109,3 +109,45 @@ test('should record page view when rendering shared page', async t => {
docContent.restore();
record.restore();
});
test('should return markdown content and skip page view when accept is text/markdown', async t => {
const docId = randomUUID();
const { app, adapter, models, docReader } = t.context;
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'markdown');
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
await models.doc.publish(workspace.id, docId);
const markdown = Sinon.stub(docReader, 'getDocMarkdown').resolves({
title: 'markdown-doc',
markdown: '# markdown-doc',
});
const docContent = Sinon.stub(docReader, 'getDocContent');
const record = Sinon.stub(
models.workspaceAnalytics,
'recordDocView'
).resolves();
const res = await app
.GET(`/workspace/${workspace.id}/${docId}`)
.set('accept', 'text/markdown')
.expect(200);
t.true(markdown.calledOnceWithExactly(workspace.id, docId, false));
t.is(res.text, '# markdown-doc');
t.true((res.headers['content-type'] as string).startsWith('text/markdown'));
t.true(docContent.notCalled);
t.true(record.notCalled);
markdown.restore();
docContent.restore();
record.restore();
});

View File

@@ -44,6 +44,12 @@ const staticPaths = new Set([
'trash',
]);
const markdownType = new Set([
'text/markdown',
'application/markdown',
'text/x-markdown',
]);
@Controller('/workspace')
export class DocRendererController {
private readonly logger = new Logger(DocRendererController.name);
@@ -68,6 +74,21 @@ export class DocRendererController {
.digest('hex');
}
private async allowDocPreview(workspaceId: string, docId: string) {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) return false;
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
if (!allowUrlPreview) {
// if page is private, but workspace url preview is on
allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
}
return allowUrlPreview;
}
@Public()
@Get('/*path')
async render(@Req() req: Request, @Res() res: Response) {
@@ -81,28 +102,55 @@ export class DocRendererController {
let opts: RenderOptions | null = null;
// /workspace/:workspaceId/{:docId | staticPaths}
const [, , workspaceId, subPath, ...restPaths] = req.path.split('/');
const [, , workspaceId, sub, ...rest] = req.path.split('/');
const isWorkspace =
workspaceId && sub && !staticPaths.has(sub) && rest.length === 0;
const isDocPath = isWorkspace && workspaceId !== sub;
if (
isDocPath &&
req.accepts().some(t => markdownType.has(t.toLowerCase()))
) {
try {
const allowPreview = await this.allowDocPreview(workspaceId, sub);
if (!allowPreview) {
res.status(404).end();
return;
}
const markdown = await this.doc.getDocMarkdown(workspaceId, sub, false);
if (markdown) {
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.send(markdown.markdown);
return;
}
} catch (e) {
this.logger.error('failed to render markdown page', e);
}
res.status(404).end();
return;
}
// /:workspaceId/:docId
if (workspaceId && !staticPaths.has(subPath) && restPaths.length === 0) {
if (isWorkspace) {
try {
opts =
workspaceId === subPath
? await this.getWorkspaceContent(workspaceId)
: await this.getPageContent(workspaceId, subPath);
opts = isDocPath
? await this.getPageContent(workspaceId, sub)
: await this.getWorkspaceContent(workspaceId);
metrics.doc.counter('render').add(1);
if (opts && workspaceId !== subPath) {
if (opts && isDocPath) {
void this.models.workspaceAnalytics
.recordDocView({
workspaceId,
docId: subPath,
visitorId: this.buildVisitorId(req, workspaceId, subPath),
docId: sub,
visitorId: this.buildVisitorId(req, workspaceId, sub),
isGuest: true,
})
.catch(error => {
this.logger.warn(
`Failed to record shared page view: ${workspaceId}/${subPath}`,
`Failed to record shared page view: ${workspaceId}/${sub}`,
error as Error
);
});
@@ -124,20 +172,7 @@ export class DocRendererController {
workspaceId: string,
docId: string
): Promise<RenderOptions | null> {
const allowSharing = await this.models.workspace.allowSharing(workspaceId);
if (!allowSharing) {
return null;
}
let allowUrlPreview = await this.models.doc.isPublic(workspaceId, docId);
if (!allowUrlPreview) {
// if page is private, but workspace url preview is on
allowUrlPreview =
await this.models.workspace.allowUrlPreview(workspaceId);
}
if (allowUrlPreview) {
if (await this.allowDocPreview(workspaceId, docId)) {
return this.doc.getDocContent(workspaceId, docId);
}

View File

@@ -56,7 +56,7 @@ defineModuleConfig('mailer', {
env: 'MAILER_PASSWORD',
},
'SMTP.sender': {
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted \<noreply@example.com\>")',
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted &lt;noreply@example.com&gt;")',
default: 'AFFiNE Self Hosted <noreply@example.com>',
env: 'MAILER_SENDER',
},
@@ -92,7 +92,7 @@ defineModuleConfig('mailer', {
default: '',
},
'fallbackSMTP.sender': {
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted \<noreply@example.com\>")',
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted &lt;noreply@example.com&gt;")',
default: '',
},
'fallbackSMTP.ignoreTLS': {

View File

@@ -2,9 +2,11 @@ import { Body, Controller, Options, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { BadRequest, Throttle, UseNamedGuard } from '../../base';
import type { CurrentUser as CurrentUserType } from '../auth';
import { Public } from '../auth';
import { CurrentUser } from '../auth';
import {
CurrentUser,
type CurrentUser as CurrentUserType,
Public,
} from '../auth';
import { TelemetryService } from './service';
import { TelemetryAck, type TelemetryBatch } from './types';

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
Info,
InputType,
Int,
Mutation,
@@ -14,6 +15,12 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
type FragmentDefinitionNode,
type GraphQLResolveInfo,
Kind,
type SelectionNode,
} from 'graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { PaginationInput, URLHelper } from '../../../base';
@@ -53,6 +60,44 @@ registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder',
});
function hasSelectedField(
selections: readonly SelectionNode[],
fieldName: string,
fragments: Record<string, FragmentDefinitionNode>
): boolean {
for (const selection of selections) {
if (selection.kind === Kind.FIELD) {
if (selection.name.value === fieldName) {
return true;
}
continue;
}
if (selection.kind === Kind.INLINE_FRAGMENT) {
if (
hasSelectedField(
selection.selectionSet.selections,
fieldName,
fragments
)
) {
return true;
}
continue;
}
const fragment = fragments[selection.name.value];
if (
fragment &&
hasSelectedField(fragment.selectionSet.selections, fieldName, fragments)
) {
return true;
}
}
return false;
}
@InputType()
class ListWorkspaceInput {
@Field(() => Int, { defaultValue: 20 })
@@ -471,22 +516,40 @@ export class AdminWorkspaceResolver {
})
async adminDashboard(
@Args('input', { nullable: true, type: () => AdminDashboardInput })
input?: AdminDashboardInput
input?: AdminDashboardInput,
@Info() info?: GraphQLResolveInfo
) {
this.assertCloudOnly();
const includeTopSharedLinks = Boolean(
info?.fieldNodes.some(
node =>
node.selectionSet &&
hasSelectedField(
node.selectionSet.selections,
'topSharedLinks',
info.fragments
)
)
);
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
timezone: input?.timezone,
storageHistoryDays: input?.storageHistoryDays,
syncHistoryHours: input?.syncHistoryHours,
sharedLinkWindowDays: input?.sharedLinkWindowDays,
includeTopSharedLinks,
});
return {
...dashboard,
topSharedLinks: dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
})),
topSharedLinks: includeTopSharedLinks
? dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(
`/workspace/${link.workspaceId}/${link.docId}`
),
}))
: [],
};
}

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import pkg from '../package.json' with { type: 'json' };
declare global {
// oxlint-disable-next-line no-shadow-restricted-names
namespace globalThis {
// oxlint-disable-next-line no-var
var env: Readonly<Env>;

View File

@@ -110,10 +110,10 @@ export class CalendarAccountModel extends BaseModel {
refreshIntervalMinutes: data.refreshIntervalMinutes,
};
if (!!accessToken) {
if (accessToken) {
updateData.accessToken = accessToken;
}
if (!!refreshToken) {
if (refreshToken) {
updateData.refreshToken = refreshToken;
}

View File

@@ -117,7 +117,7 @@ export class CopilotSessionModel extends BaseModel {
if (typeof value !== 'string') {
return value;
}
return value.replace(/\u0000/g, '') as T;
return value.replaceAll('\0', '') as T;
}
private sanitizeJsonValue<T>(value: T): T {

View File

@@ -51,6 +51,7 @@ export type AdminDashboardOptions = {
storageHistoryDays?: number;
syncHistoryHours?: number;
sharedLinkWindowDays?: number;
includeTopSharedLinks?: boolean;
};
export type AdminAllSharedLinksOptions = {
@@ -262,6 +263,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
90,
DEFAULT_SHARED_LINK_WINDOW_DAYS
);
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
const now = new Date();
@@ -274,6 +276,66 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1));
const topSharedLinksPromise = includeTopSharedLinks
? this.db.$queryRaw<
{
workspaceId: string;
docId: string;
title: string | null;
publishedAt: Date | null;
docUpdatedAt: Date | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
views: bigint | number;
uniqueViews: bigint | number;
guestViews: bigint | number;
lastAccessedAt: Date | null;
}[]
>`
WITH view_agg AS (
SELECT
workspace_id,
doc_id,
COALESCE(SUM(total_views), 0) AS views,
COALESCE(SUM(unique_views), 0) AS unique_views,
COALESCE(SUM(guest_views), 0) AS guest_views,
MAX(last_accessed_at) AS last_accessed_at
FROM workspace_doc_view_daily
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
GROUP BY workspace_id, doc_id
)
SELECT
wp.workspace_id AS "workspaceId",
wp.page_id AS "docId",
wp.title AS title,
wp.published_at AS "publishedAt",
sn.updated_at AS "docUpdatedAt",
owner.user_id AS "workspaceOwnerId",
sn.updated_by AS "lastUpdaterId",
COALESCE(v.views, 0) AS views,
COALESCE(v.unique_views, 0) AS "uniqueViews",
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`
: Promise.resolve([]);
const [
syncCurrent,
syncTimeline,
@@ -350,63 +412,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
AND created_at >= ${sharedFrom}
AND created_at <= ${now}
`,
this.db.$queryRaw<
{
workspaceId: string;
docId: string;
title: string | null;
publishedAt: Date | null;
docUpdatedAt: Date | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
views: bigint | number;
uniqueViews: bigint | number;
guestViews: bigint | number;
lastAccessedAt: Date | null;
}[]
>`
WITH view_agg AS (
SELECT
workspace_id,
doc_id,
COALESCE(SUM(total_views), 0) AS views,
COALESCE(SUM(unique_views), 0) AS unique_views,
COALESCE(SUM(guest_views), 0) AS guest_views,
MAX(last_accessed_at) AS last_accessed_at
FROM workspace_doc_view_daily
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
GROUP BY workspace_id, doc_id
)
SELECT
wp.workspace_id AS "workspaceId",
wp.page_id AS "docId",
wp.title AS title,
wp.published_at AS "publishedAt",
sn.updated_at AS "docUpdatedAt",
owner.user_id AS "workspaceOwnerId",
sn.updated_by AS "lastUpdaterId",
COALESCE(v.views, 0) AS views,
COALESCE(v.unique_views, 0) AS "uniqueViews",
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`,
topSharedLinksPromise,
]);
const storageHistorySeries = storageHistory.map(row => ({

View File

@@ -22,8 +22,8 @@ import {
CalendarProviderListCalendarsParams,
CalendarProviderListEventsParams,
CalendarProviderListEventsResult,
CalendarProviderName,
} from './def';
import { CalendarProviderName } from './factory';
import { CalendarSyncTokenInvalid } from './google';
const XML_PARSER = new XMLParser({
@@ -113,7 +113,7 @@ const isRedirectStatus = (status: number) =>
const splitHeaderTokens = (value: string) =>
value
.split(/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/)
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
.map(token => token.trim())
.filter(Boolean);

View File

@@ -2,12 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import type { CalendarAccount } from '@prisma/client';
import { CalendarProviderRequestError, Config, OnEvent } from '../../../base';
import { CalendarProviderFactory } from './factory';
export enum CalendarProviderName {
Google = 'google',
CalDAV = 'caldav',
}
import { CalendarProviderFactory, CalendarProviderName } from './factory';
export interface CalendarProviderTokens {
accessToken: string;

View File

@@ -1,12 +1,20 @@
import { Injectable, Logger } from '@nestjs/common';
import type { CalendarProvider } from './def';
import { CalendarProviderName } from './def';
export enum CalendarProviderName {
Google = 'google',
CalDAV = 'caldav',
}
export interface CalendarProviderRef {
provider: CalendarProviderName;
}
@Injectable()
export class CalendarProviderFactory {
export class CalendarProviderFactory<
TProvider extends CalendarProviderRef = CalendarProviderRef,
> {
private readonly logger = new Logger(CalendarProviderFactory.name);
readonly #providers = new Map<CalendarProviderName, CalendarProvider>();
readonly #providers = new Map<CalendarProviderName, TProvider>();
get providers() {
return Array.from(this.#providers.keys());
@@ -16,12 +24,12 @@ export class CalendarProviderFactory {
return this.#providers.get(name);
}
register(provider: CalendarProvider) {
register(provider: TProvider) {
this.#providers.set(provider.provider, provider);
this.logger.log(`Calendar provider [${provider.provider}] registered.`);
}
unregister(provider: CalendarProvider) {
unregister(provider: TProvider) {
this.#providers.delete(provider.provider);
this.logger.log(`Calendar provider [${provider.provider}] unregistered.`);
}

View File

@@ -1,17 +1,17 @@
import { Injectable } from '@nestjs/common';
import { CalendarProviderRequestError } from '../../../base';
import { CalendarProvider } from './def';
import {
CalendarProvider,
CalendarProviderEvent,
CalendarProviderListCalendarsParams,
CalendarProviderListEventsParams,
CalendarProviderListEventsResult,
CalendarProviderName,
CalendarProviderTokens,
CalendarProviderWatchParams,
CalendarProviderWatchResult,
} from './def';
import { CalendarProviderName } from './factory';
export class CalendarSyncTokenInvalid extends Error {
readonly code = 'calendar_sync_token_invalid';

View File

@@ -14,9 +14,8 @@ export type {
CalendarProviderWatchParams,
CalendarProviderWatchResult,
} from './def';
export { CalendarProviderName } from './def';
export { CalendarProvider } from './def';
export { CalendarProviderFactory } from './factory';
export { CalendarProviderFactory, CalendarProviderName } from './factory';
export { CalendarSyncTokenInvalid, GoogleCalendarProvider } from './google';
export const CalendarProviders = [GoogleCalendarProvider, CalDAVProvider];

View File

@@ -18,10 +18,10 @@ import {
CalendarProvider,
CalendarProviderEvent,
CalendarProviderEventTime,
CalendarProviderFactory,
CalendarProviderName,
CalendarSyncTokenInvalid,
} from './providers';
import { CalendarProviderFactory } from './providers';
import type { LinkCalDAVAccountInput } from './types';
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
@@ -35,7 +35,7 @@ export class CalendarService {
constructor(
private readonly models: Models,
private readonly providerFactory: CalendarProviderFactory,
private readonly providerFactory: CalendarProviderFactory<CalendarProvider>,
private readonly mutex: Mutex,
private readonly config: Config,
private readonly url: URLHelper
@@ -105,11 +105,11 @@ export class CalendarService {
const accessToken = accountTokens.accessToken;
if (accessToken) {
await Promise.allSettled(
needToStopChannel.map(s => {
needToStopChannel.map(async s => {
if (!s.customChannelId || !s.customResourceId) {
return Promise.resolve();
return;
}
return provider.stopChannel?.({
return await provider.stopChannel?.({
accessToken,
channelId: s.customChannelId,
resourceId: s.customResourceId,
@@ -654,8 +654,11 @@ export class CalendarService {
}
const zone = time.timeZone ?? fallbackTimezone ?? 'UTC';
if (!time.date) {
throw new Error('Calendar provider returned all-day event without date');
}
return {
date: this.convertDateToUtc(time.date!, zone),
date: this.convertDateToUtc(time.date, zone),
allDay: true,
};
}

View File

@@ -49,7 +49,7 @@ import {
FileChunkSimilarity,
Models,
} from '../../../models';
import { CopilotEmbeddingJob } from '../embedding';
import { CopilotEmbeddingJob } from '../embedding/job';
import { COPILOT_LOCKER, CopilotType } from '../resolver';
import { ChatSessionService } from '../session';
import { CopilotStorage } from '../storage';

View File

@@ -15,7 +15,8 @@ import {
ContextFile,
Models,
} from '../../../models';
import { type EmbeddingClient, getEmbeddingClient } from '../embedding';
import { getEmbeddingClient } from '../embedding/client';
import type { EmbeddingClient } from '../embedding/types';
import { ContextSession } from './session';
const CONTEXT_SESSION_KEY = 'context-session';

View File

@@ -11,7 +11,7 @@ import {
FileChunkSimilarity,
Models,
} from '../../../models';
import { EmbeddingClient } from '../embedding';
import { EmbeddingClient } from '../embedding/types';
export class ContextSession implements AsyncDisposable {
constructor(

View File

@@ -47,14 +47,14 @@ import {
} from '../../base';
import { ServerFeature, ServerService } from '../../core';
import { CurrentUser, Public } from '../../core/auth';
import { CopilotContextService } from './context';
import { CopilotContextService } from './context/service';
import { CopilotProviderFactory } from './providers/factory';
import type { CopilotProvider } from './providers/provider';
import {
CopilotProvider,
CopilotProviderFactory,
ModelInputType,
ModelOutputType,
StreamObject,
} from './providers';
type StreamObject,
} from './providers/types';
import { StreamObjectParser } from './providers/utils';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
@@ -560,7 +560,7 @@ export class CopilotController implements BeforeApplicationShutdown {
status: data.status,
id: data.node.id,
type: data.node.config.nodeType,
} as any,
},
};
}
})

View File

@@ -12,14 +12,14 @@ import {
Embedding,
EMBEDDING_DIMENSIONS,
} from '../../../models';
import { PromptService } from '../prompt';
import { PromptService } from '../prompt/service';
import { CopilotProviderFactory } from '../providers/factory';
import type { CopilotProvider } from '../providers/provider';
import {
type CopilotProvider,
CopilotProviderFactory,
type ModelFullConditions,
ModelInputType,
ModelOutputType,
} from '../providers';
} from '../providers/types';
import { EmbeddingClient, type ReRankResult } from './types';
const EMBEDDING_MODEL = 'gemini-embedding-001';

View File

@@ -8,7 +8,7 @@ import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { clearEmbeddingChunk } from '../../../models';
import { IndexerService } from '../../indexer';
import { CopilotContextService } from '../context';
import { CopilotContextService } from '../context/service';
@Injectable()
export class WorkspaceMcpProvider {

View File

@@ -4,7 +4,11 @@ import { AiPrompt } from '@prisma/client';
import Mustache from 'mustache';
import { getTokenEncoder } from '../../../native';
import { PromptConfig, PromptMessage, PromptParams } from '../providers';
import type {
PromptConfig,
PromptMessage,
PromptParams,
} from '../providers/types';
// disable escaping
Mustache.escape = (text: string) => text;

View File

@@ -1,7 +1,7 @@
import { Logger } from '@nestjs/common';
import { AiPrompt, PrismaClient } from '@prisma/client';
import { PromptConfig, PromptMessage } from '../providers';
import type { PromptConfig, PromptMessage } from '../providers/types';
type Prompt = Omit<
AiPrompt,

View File

@@ -8,7 +8,7 @@ import {
PromptConfigSchema,
PromptMessage,
PromptMessageSchema,
} from '../providers';
} from '../providers/types';
import { ChatPrompt } from './chat-prompt';
import {
CopilotPromptScenario,

View File

@@ -13,8 +13,8 @@ import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { Models } from '../../../models';
import { IndexerService } from '../../indexer';
import { CopilotContextService } from '../context';
import { PromptService } from '../prompt';
import { CopilotContextService } from '../context/service';
import { PromptService } from '../prompt/service';
import {
buildBlobContentGetter,
buildContentGetter,

View File

@@ -42,9 +42,9 @@ import { AccessController, DocAction } from '../../core/permission';
import { UserType } from '../../core/user';
import type { ListSessionOptions, UpdateChatSession } from '../../models';
import { CopilotCronJobs } from './cron';
import { PromptService } from './prompt';
import { PromptMessage, StreamObject } from './providers';
import { PromptService } from './prompt/service';
import { CopilotProviderFactory } from './providers/factory';
import type { PromptMessage, StreamObject } from './providers/types';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import { type ChatHistory, type ChatMessage, SubmittedMessage } from './types';

View File

@@ -28,13 +28,14 @@ import {
import { SubscriptionService } from '../payment/service';
import { SubscriptionPlan, SubscriptionStatus } from '../payment/types';
import { ChatMessageCache } from './message';
import { ChatPrompt, PromptService } from './prompt';
import { ChatPrompt } from './prompt/chat-prompt';
import { PromptService } from './prompt/service';
import { CopilotProviderFactory } from './providers/factory';
import {
CopilotProviderFactory,
ModelOutputType,
PromptMessage,
PromptParams,
} from './providers';
type PromptMessage,
type PromptParams,
} from './providers/types';
import {
type ChatHistory,
type ChatMessage,
@@ -322,7 +323,7 @@ export class ChatSessionService {
private stripNullBytes(value?: string | null): string {
if (!value) return '';
return value.replace(/\u0000/g, '');
return value.replaceAll('\0', '');
}
private isNullByteError(error: unknown): boolean {

View File

@@ -3,9 +3,8 @@ import { tool } from 'ai';
import { z } from 'zod';
import { AccessController } from '../../../core/permission';
import type { ContextSession } from '../context/session';
import type { CopilotChatOptions } from '../providers';
import { toolError } from './error';
import type { ContextSession, CopilotChatOptions } from './types';
const logger = new Logger('ContextBlobReadTool');

View File

@@ -2,9 +2,9 @@ import { Logger } from '@nestjs/common';
import { tool } from 'ai';
import { z } from 'zod';
import type { PromptService } from '../prompt';
import type { CopilotProviderFactory } from '../providers';
import { toolError } from './error';
import type { CopilotProviderFactory, PromptService } from './types';
const logger = new Logger('CodeArtifactTool');
/**
* A copilot tool that produces a completely self-contained HTML artifact.

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