mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 13:57:02 +08:00
Compare commits
8 Commits
v2026.2.14
...
darksky/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f168de4010 | ||
|
|
42f2d2b337 | ||
|
|
9d7f4acaf1 | ||
|
|
9a1f600fc9 | ||
|
|
0f906ad623 | ||
|
|
09aa65c52a | ||
|
|
25227a09f7 | ||
|
|
c0694c589b |
78
.github/workflows/release-desktop.yml
vendored
78
.github/workflows/release-desktop.yml
vendored
@@ -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: |
|
||||
|
||||
40
.github/workflows/windows-signer.yml
vendored
40
.github/workflows/windows-signer.yml
vendored
@@ -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
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = (
|
||||
);
|
||||
|
||||
return () => {
|
||||
pendingFlag = false;
|
||||
pendingList.clear();
|
||||
disposables.forEach(d => d.unsubscribe());
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
25
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
25
blocksuite/affine/gfx/group/vitest.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
25
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
25
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`\>
|
||||
@@ -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`
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
31
blocksuite/framework/std/src/gfx/perf.ts
Normal file
31
blocksuite/framework/std/src/gfx/perf.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
76
blocksuite/framework/std/src/gfx/raf-coalescer.ts
Normal file
76
blocksuite/framework/std/src/gfx/raf-coalescer.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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``;
|
||||
}
|
||||
|
||||
@@ -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,64 @@ 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 => {
|
||||
container.removeChild(element);
|
||||
});
|
||||
}
|
||||
|
||||
function traverse(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -44,6 +44,12 @@ const staticPaths = new Set([
|
||||
'trash',
|
||||
]);
|
||||
|
||||
const markdownType = [
|
||||
'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.includes(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);
|
||||
}
|
||||
|
||||
|
||||
@@ -560,7 +560,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
status: data.status,
|
||||
id: data.node.id,
|
||||
type: data.node.config.nodeType,
|
||||
} as any,
|
||||
},
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
@@ -17,12 +16,6 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
|
||||
frameworkProvider.get(LifecycleService).applicationFocus();
|
||||
});
|
||||
frameworkProvider.get(LifecycleService).applicationStart();
|
||||
window.addEventListener('unload', () => {
|
||||
frameworkProvider
|
||||
.get(DesktopApiService)
|
||||
.api.handler.ui.pingAppLayoutReady(false)
|
||||
.catch(console.error);
|
||||
});
|
||||
|
||||
events?.applicationMenu.openInSettingModal(({ activeTab, scrollAnchor }) => {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
|
||||
@@ -789,12 +789,9 @@ export class WebContentViewsManager {
|
||||
|
||||
resizeView = (view: View) => {
|
||||
// app view will take full w/h of the main window
|
||||
view.setBounds({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.mainWindow?.getContentBounds().width ?? 0,
|
||||
height: this.mainWindow?.getContentBounds().height ?? 0,
|
||||
});
|
||||
const bounds = this.mainWindow?.getContentBounds();
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
|
||||
view.setBounds({ x: 0, y: 0, width: bounds.width, height: bounds.height });
|
||||
};
|
||||
|
||||
private readonly generateViewId = (type: 'app' | 'shell') => {
|
||||
@@ -824,7 +821,7 @@ export class WebContentViewsManager {
|
||||
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
|
||||
// serialize exposed meta that to be used in preload
|
||||
additionalArguments: additionalArguments,
|
||||
backgroundThrottling: true,
|
||||
backgroundThrottling: type === 'app',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -881,6 +878,15 @@ export class WebContentViewsManager {
|
||||
|
||||
// shell process do not need to connect to helper process
|
||||
if (type !== 'shell') {
|
||||
view.webContents.on(
|
||||
'did-start-navigation',
|
||||
(_event, _url, isInPlace, isMainFrame) => {
|
||||
// Keep shell fallback lifecycle tied to main-frame navigation only.
|
||||
if (isMainFrame && !isInPlace) {
|
||||
this.setTabUIUnready(viewId);
|
||||
}
|
||||
}
|
||||
);
|
||||
view.webContents.on('did-finish-load', () => {
|
||||
disconnectHelperProcess?.();
|
||||
disconnectHelperProcess = helperProcessManager.connectRenderer(
|
||||
@@ -935,7 +941,8 @@ export class WebContentViewsManager {
|
||||
const mainFocused = this.mainWindow?.isFocused() ?? false;
|
||||
const activeId = this.activeWorkbenchId;
|
||||
this.webViewsMap$.value.forEach((view, id) => {
|
||||
if (id === 'shell') {
|
||||
// skip active view to avoid windows rendering
|
||||
if (id === 'shell' || id === activeId) {
|
||||
return;
|
||||
}
|
||||
const shouldThrottle = !mainFocused || id !== activeId;
|
||||
@@ -1156,13 +1163,28 @@ export const showDevTools = (id?: string) => {
|
||||
};
|
||||
|
||||
export const pingAppLayoutReady = (wc: WebContents, ready: boolean) => {
|
||||
const viewId =
|
||||
WebContentViewsManager.instance.getWorkbenchIdFromWebContentsId(wc.id);
|
||||
const manager = WebContentViewsManager.instance;
|
||||
const viewId = manager.getWorkbenchIdFromWebContentsId(wc.id);
|
||||
if (viewId) {
|
||||
if (ready) {
|
||||
WebContentViewsManager.instance.setTabUIReady(viewId);
|
||||
manager.setTabUIReady(viewId);
|
||||
} else {
|
||||
WebContentViewsManager.instance.setTabUIUnready(viewId);
|
||||
const isActive = manager.activeWorkbenchId === viewId;
|
||||
const view = manager.getViewById(viewId);
|
||||
const isLoadingMainFrame =
|
||||
view?.webContents.isLoadingMainFrame?.() ??
|
||||
view?.webContents.isLoading?.() ??
|
||||
false;
|
||||
// Renderer unload can be noisy on Windows when resizing;
|
||||
// keep active tab visible unless it is truly navigating.
|
||||
if (isActive && !isLoadingMainFrame) {
|
||||
logger.warn('ignore pingAppLayoutReady(false) for active tab', {
|
||||
viewId,
|
||||
senderId: wc.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
manager.setTabUIUnready(viewId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,10 +48,14 @@ export const affineDocViewport = style({
|
||||
});
|
||||
|
||||
export const pageModeViewportContentBox = style({});
|
||||
globalStyle(`${pageModeViewportContentBox} >:first-child`, {
|
||||
display: 'table !important',
|
||||
minWidth: '100%',
|
||||
});
|
||||
globalStyle(
|
||||
`${pageModeViewportContentBox} >:first-child:has(>[data-affine-editor-container])`,
|
||||
{ display: 'table !important', minWidth: '100%' }
|
||||
);
|
||||
globalStyle(
|
||||
`${pageModeViewportContentBox} >:first-child:has(>[data-editor-loading="true"]) > [data-editor-loading="true"]`,
|
||||
{ flex: 1, minHeight: '100%' }
|
||||
);
|
||||
|
||||
export const scrollbar = style({
|
||||
marginRight: '4px',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"ar": 96,
|
||||
"ca": 98,
|
||||
"da": 4,
|
||||
"de": 97,
|
||||
"de": 100,
|
||||
"el-GR": 96,
|
||||
"en": 100,
|
||||
"es-AR": 96,
|
||||
@@ -11,8 +11,7 @@
|
||||
"fa": 96,
|
||||
"fr": 100,
|
||||
"hi": 1,
|
||||
"it-IT": 98,
|
||||
"it": 1,
|
||||
"it": 98,
|
||||
"ja": 96,
|
||||
"ko": 97,
|
||||
"nb-NO": 47,
|
||||
|
||||
@@ -206,6 +206,13 @@
|
||||
"com.affine.ai.login-required.dialog-content": "Um AFFiNE AI zu verwenden, melde dich bitte mit deinem AFFiNE Cloud-Konto an.",
|
||||
"com.affine.ai.login-required.dialog-title": "Einloggen, um fortzufahren",
|
||||
"com.affine.ai.template-insert.failed": "Vorlage konnte nicht eingefügt werden, bitte erneut versuchen.",
|
||||
"com.affine.ai.chat-panel.title": "AFFiNE AI",
|
||||
"com.affine.ai.chat-panel.loading-history": "AFFiNE AI lädt den Verlauf...",
|
||||
"com.affine.ai.chat-panel.embedding-progress": "Einbetten {{done}}/{{total}}",
|
||||
"com.affine.ai.chat-panel.session.delete.confirm.title": "Diesen Verlauf löschen?",
|
||||
"com.affine.ai.chat-panel.session.delete.confirm.message": "Möchtest du diesen KI-Konversationsverlauf löschen? Nach dem Löschen kann er nicht wiederhergestellt werden.",
|
||||
"com.affine.ai.chat-panel.session.delete.toast.success": "Verlauf gelöscht",
|
||||
"com.affine.ai.chat-panel.session.delete.toast.failed": "Löschen des Verlaufs fehlgeschlagen",
|
||||
"com.affine.all-pages.header": "Alle Seiten",
|
||||
"com.affine.app-sidebar.learn-more": "Mehr erfahren",
|
||||
"com.affine.app-sidebar.star-us": "Gib uns einen Stern",
|
||||
@@ -221,6 +228,9 @@
|
||||
"com.affine.appearanceSettings.color.title": "Farbmodus",
|
||||
"com.affine.appearanceSettings.customize-theme.description": "Bearbeite hier alle AFFiNE-Themevariablen",
|
||||
"com.affine.appearanceSettings.customize-theme.title": "Theme anpassen",
|
||||
"com.affine.appearanceSettings.images.title": "Bilder",
|
||||
"com.affine.appearanceSettings.images.antialiasing.title": "Weiche Bilddarstellung",
|
||||
"com.affine.appearanceSettings.images.antialiasing.description": "Wenn diese Option deaktiviert ist, werden Bilder mit der Nearest-Neighbor-Skalierung gerendert, um scharfe Pixel zu erzielen.",
|
||||
"com.affine.appearanceSettings.customize-theme.reset": "Alles zurücksetzen",
|
||||
"com.affine.appearanceSettings.customize-theme.open": "Theme-Editor öffnen",
|
||||
"com.affine.appearanceSettings.font.description": "Schriftart auswählen",
|
||||
@@ -238,7 +248,14 @@
|
||||
"com.affine.appearanceSettings.menubar.toggle": "Menüleistensymbol aktivieren",
|
||||
"com.affine.appearanceSettings.menubar.description": "Menüleistensymbol im Tray für einen schnellen Zugriff auf AFFiNE oder Meeting-Aufzeichnungen anzeigen.",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.title": "Fensterverhalten",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.toggle": "Schnell öffnen über das Tray-Icon",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.openOnLeftClick.description": "AFFiNE über Linksklick auf das Tray-Icon öffnen.",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.toggle": "In den Tray minimieren",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.minimizeToTray.description": "AFFiNE in den System-Tray minimieren.",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.toggle": "In den Tray schließen",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.closeToTray.description": "AFFiNE in den System-Tray schließen.",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.toggle": "Minimiert starten",
|
||||
"com.affine.appearanceSettings.menubar.windowBehavior.startMinimized.description": "AFFiNE in den System-Tray minimiert starten.",
|
||||
"com.affine.appearanceSettings.theme.title": "Theme",
|
||||
"com.affine.appearanceSettings.title": "Erscheinungseinstellungen",
|
||||
"com.affine.appearanceSettings.translucentUI.description": "Transparenzeffekt in der Seitenleiste verwenden.",
|
||||
@@ -595,6 +612,8 @@
|
||||
"com.affine.import-clipper.dialog.errorLoad": "Inhalt konnte nicht geladen werden, bitte erneut versuchen.",
|
||||
"com.affine.import_file": "Markdown/Notion Unterstützung",
|
||||
"com.affine.import.affine-workspace-data": "AFFiNE Workspace-Daten",
|
||||
"com.affine.import.docx": "DOCX",
|
||||
"com.affine.import.docx.tooltip": "Importiere deine .docx-Datei.",
|
||||
"com.affine.import.html-files": "HTML",
|
||||
"com.affine.import.html-files.tooltip": "Dies ist eine experimentelle Funktion, die nicht perfekt ist und dazu führen kann, dass deine Daten nach dem Import fehlen.",
|
||||
"com.affine.import.markdown-files": "Markdown-Dateien (.md)",
|
||||
@@ -1115,6 +1134,22 @@
|
||||
"com.affine.payment.license-success.hint": "Du kannst diesen Schlüssel verwenden, um in Einstellungen > Arbeitsplatz > Lizenz > Gekauften Schlüssel verwenden ein Upgrade durchzuführen",
|
||||
"com.affine.payment.license-success.open-affine": "AFFiNE öffnen",
|
||||
"com.affine.payment.license-success.copy": "Schlüssel in die Zwischenablage kopiert",
|
||||
"com.affine.doc.analytics.title": "Statistiken anzeigen",
|
||||
"com.affine.doc.analytics.summary.total": "({{count}} gesamt)",
|
||||
"com.affine.doc.analytics.window.last-days": "Letzte {{days}} Tage",
|
||||
"com.affine.doc.analytics.metric.total": "Gesamt",
|
||||
"com.affine.doc.analytics.metric.unique": "Einzigartig",
|
||||
"com.affine.doc.analytics.metric.guest": "Gast",
|
||||
"com.affine.doc.analytics.chart.total-views": "Anzahl der Aufrufe",
|
||||
"com.affine.doc.analytics.chart.unique-views": "Einzelne Aufrufe",
|
||||
"com.affine.doc.analytics.error.load-analytics": "Statistiken können nicht geladen werden.",
|
||||
"com.affine.doc.analytics.error.load-viewers": "Aufrufer können nicht geladen werden.",
|
||||
"com.affine.doc.analytics.empty.no-page-views": "Keine Seitenaufrufe in diesem Zeitraum.",
|
||||
"com.affine.doc.analytics.empty.no-viewers": "Keine Aufrufer in diesem Zeitraum.",
|
||||
"com.affine.doc.analytics.viewers.title": "Aufrufer",
|
||||
"com.affine.doc.analytics.viewers.show-all": "Alle Aufrufer anzeigen",
|
||||
"com.affine.doc.analytics.paywall.open-pricing": "Preispläne öffnen",
|
||||
"com.affine.doc.analytics.paywall.toast": "Seitenstatistiken über einen Zeitraum von 7 Tagen erfordern ein AFFiNE Team-Abonnement.",
|
||||
"com.affine.peek-view-controls.close": "Schließen",
|
||||
"com.affine.peek-view-controls.open-doc": "Diese Seite öffnen",
|
||||
"com.affine.peek-view-controls.open-doc-in-edgeless": "In Edgeless öffnen",
|
||||
@@ -1460,8 +1495,8 @@
|
||||
"com.affine.settings.workspace.experimental-features.enable-mind-map-import.description": "Aktiviert den Import von Mindmaps.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-block-meta.name": "Block-Metadaten",
|
||||
"com.affine.settings.workspace.experimental-features.enable-block-meta.description": "Sobald aktiviert, haben alle Blöcke Erstellungszeit, Aktualisierungszeit, erstellt von und aktualisiert von.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.name": "Hervorhebung",
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Lass deine Worte hervorstechen. Dazu gehört auch die Hervorhebung im Transkriptionsblock.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.name": "Callout",
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Lass deine Worte hervorstechen. Dazu gehört auch der Callout im Transkriptionsblock.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name": "iFrame-Block einbetten",
|
||||
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description": "Aktiviert den eingebetteten iFrame-Block.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji-Ordnersymbol",
|
||||
@@ -1582,6 +1617,8 @@
|
||||
"com.affine.settings.workspace.sharing.title": "Freigeben",
|
||||
"com.affine.settings.workspace.sharing.url-preview.description": "Erlaube die URL-Anzeige über Slack und andere soziale Apps, auch wenn auf eine Seite nur Workspace-Mitglieder zugreifen können.",
|
||||
"com.affine.settings.workspace.sharing.url-preview.title": "URL-Vorschau immer aktivieren",
|
||||
"com.affine.settings.workspace.sharing.workspace-sharing.description": "Lege fest, ob Seiten in diesem Workspace öffentlich freigegeben werden können. Deaktiviere diese Option, um neue Freigaben und den externen Zugriff auf bestehende Freigaben zu blockieren.",
|
||||
"com.affine.settings.workspace.sharing.workspace-sharing.title": "Freigabe von Workspace-Seiten zulassen",
|
||||
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
|
||||
"com.affine.settings.workspace.affine-ai.label": "AFFiNE AI Assistant zulassen",
|
||||
"com.affine.settings.workspace.affine-ai.description": "Erlaube Workspace-Mitgliedern, AFFiNE AI-Funktionen zu verwenden. Diese Einstellung hat keine Auswirkungen auf die Abrechnung. Workspace-Mitglieder verwenden AFFiNE AI über ihre persönlichen Konten.",
|
||||
@@ -1646,6 +1683,7 @@
|
||||
"com.affine.share-menu.option.link.no-access.description": "Nur Workspace-Mitglieder können auf diesen Link zugreifen",
|
||||
"com.affine.share-menu.option.link.readonly": "Nur Lesen",
|
||||
"com.affine.share-menu.option.link.readonly.description": "Jeder kann auf diesen Link zugreifen",
|
||||
"com.affine.share-menu.workspace-sharing.disabled.tooltip": "Das Teilen für diesen Workspace ist deaktiviert. Bitte kontaktiere einen Administrator, um es zu aktivieren.",
|
||||
"com.affine.share-menu.option.permission.can-manage": "Kann verwalten",
|
||||
"com.affine.share-menu.option.permission.can-edit": "Kann bearbeiten",
|
||||
"com.affine.share-menu.option.permission.can-read": "Kann lesen",
|
||||
@@ -1801,6 +1839,7 @@
|
||||
"com.affine.workspaceList.workspaceListType.local": "Lokaler Speicher",
|
||||
"com.affine.workspaceList.addServer": "Server hinzufügen",
|
||||
"com.affine.workspaceSubPath.all": "Alle Seiten",
|
||||
"com.affine.workspaceSubPath.chat": "Intelligenz",
|
||||
"com.affine.workspaceSubPath.trash": "Papierkorb",
|
||||
"com.affine.workspaceSubPath.trash.empty-description": "Gelöschte Seiten werden hier angezeigt.",
|
||||
"com.affine.write_with_a_blank_page": "Schreibe mit einer leeren Seite",
|
||||
@@ -2053,13 +2092,43 @@
|
||||
"com.affine.integration.calendar.unsubscribe": "Abonnement beenden",
|
||||
"com.affine.integration.calendar.new-title": "Kalender per URL hinzufügen",
|
||||
"com.affine.integration.calendar.new-url-label": "Kalender-URL",
|
||||
"com.affine.integration.calendar.save-error": "Beim Speichern der Kalendereinstellungen ist ein Fehler aufgetreten",
|
||||
"com.affine.integration.calendar.all-day": "Ganztägig",
|
||||
"com.affine.integration.calendar.account.load-error": "Kalenderkonten konnten nicht geladen werden",
|
||||
"com.affine.integration.calendar.provider.load-error": "Kalenderanbieter konnten nicht geladen werden",
|
||||
"com.affine.integration.calendar.auth.start-error": "Kalenderautorisierung konnte nicht gestartet werden",
|
||||
"com.affine.integration.calendar.account.unlink-error": "Kalenderkonto konnte nicht getrennt werden",
|
||||
"com.affine.integration.calendar.account.unlink": "Trennen",
|
||||
"com.affine.integration.calendar.account.link": "Verknüpfen",
|
||||
"com.affine.integration.calendar.account.linked-empty": "Es sind noch keine Kalenderkonten verknüpft.",
|
||||
"com.affine.integration.calendar.account.status.failed": "Autorisierung fehlgeschlagen: {{error}}",
|
||||
"com.affine.integration.calendar.account.status.failed-reconnect": "Autorisierung fehlgeschlagen. Bitte verbinde dein Konto erneut.",
|
||||
"com.affine.integration.calendar.account.count": "{{count}} Kalender",
|
||||
"com.affine.integration.calendar.caldav.link.title": "CalDAV-Konto verknüpfen",
|
||||
"com.affine.integration.calendar.caldav.link.failed": "CalDAV-Konto konnte nicht verknüpft werden",
|
||||
"com.affine.integration.calendar.caldav.field.provider": "Anbieter",
|
||||
"com.affine.integration.calendar.caldav.field.provider.placeholder": "Anbieter auswählen",
|
||||
"com.affine.integration.calendar.caldav.field.provider.error": "Bitte wähle einen Anbieter aus.",
|
||||
"com.affine.integration.calendar.caldav.field.username": "Benutzername",
|
||||
"com.affine.integration.calendar.caldav.field.username.placeholder": "email@example.com",
|
||||
"com.affine.integration.calendar.caldav.field.username.error": "Benutzername ist erforderlich.",
|
||||
"com.affine.integration.calendar.caldav.field.password": "Passwort",
|
||||
"com.affine.integration.calendar.caldav.field.password.placeholder": "Passwort oder app-spezifisches Passwort",
|
||||
"com.affine.integration.calendar.caldav.field.password.error": "Passwort ist erforderlich.",
|
||||
"com.affine.integration.calendar.caldav.field.displayName": "Anzeigename (optional)",
|
||||
"com.affine.integration.calendar.caldav.field.displayName.placeholder": "Mein CalDAV",
|
||||
"com.affine.integration.calendar.caldav.hint.app-password": "App-spezifisches Passwort erforderlich.",
|
||||
"com.affine.integration.calendar.caldav.hint.learn-more": "Mehr erfahren",
|
||||
"com.affine.integration.calendar.caldav.hint.guide": "Anleitung zur Anbietereinrichtung",
|
||||
"com.affine.integration.calendar.new-doc": "Neue Seite",
|
||||
"com.affine.integration.calendar.show-events": "Kalenderereignisse anzeigen",
|
||||
"com.affine.integration.calendar.show-events-desc": "Durch Aktivieren dieser Einstellung kannst du deine Kalenderereignisse mit deinem Journal in AFFiNE verknüpfen",
|
||||
"com.affine.integration.calendar.show-all-day-events": "Ganztägige Kalenderereignisse anzeigen",
|
||||
"com.affine.integration.calendar.unsubscribe-content": "Möchtest du das Abonnement für „{{name}}“ wirklich beenden? Dadurch werden die Daten des Kontos aus dem Journal entfernt.",
|
||||
"com.affine.integration.calendar.no-journal": "Keine Journalseite für {{date}} gefunden. Bitte erstelle zuerst eine Journalseite.",
|
||||
"com.affine.integration.calendar.no-calendar": "Noch keine abonnierten Kalender.",
|
||||
"com.affine.integration.mcp-server.name": "MCP-Server",
|
||||
"com.affine.integration.mcp-server.desc": "Anderen MCP-Clients ermöglichen, die Seite von AFFiNE zu suchen und zu lesen.",
|
||||
"com.affine.audio.notes": "Notizen",
|
||||
"com.affine.audio.transcribing": "Transkription läuft",
|
||||
"com.affine.audio.transcribe.non-owner.confirm.title": "KI-Ergebnisse für andere können nicht abgerufen werden",
|
||||
@@ -2089,6 +2158,7 @@
|
||||
"com.affine.comment.filter.only-my-replies": "Nur meine Antworten und Erwähnungen",
|
||||
"com.affine.comment.filter.only-current-mode": "Nur aktueller Modus",
|
||||
"com.affine.payment.subscription.title": "Weitere Funktionen freischalten",
|
||||
"com.affine.payment.subscription.description": "Der universelle Editor, mit dem du arbeiten, spielen, präsentieren oder so ziemlich alles erstellen kannst.",
|
||||
"com.affine.payment.subscription.button": "Upgrade",
|
||||
"com.affine.comment.reply": "Antworten",
|
||||
"com.affine.comment.copy-link": "Link kopieren",
|
||||
@@ -2103,6 +2173,8 @@
|
||||
"error.BAD_REQUEST": "Fehlerhafte Anfrage.",
|
||||
"error.GRAPHQL_BAD_REQUEST": "GraphQL fehlerhafte Anfrage, Code: {{code}}, {{message}}",
|
||||
"error.HTTP_REQUEST_ERROR": "HTTP-Anforderungsfehler, Meldung: {{message}}",
|
||||
"error.SSRF_BLOCKED_ERROR": "Ungültige URL",
|
||||
"error.RESPONSE_TOO_LARGE_ERROR": "Antwort zu groß ({{receivedBytes}} Bytes), Limit beträgt {{limitBytes}} Bytes",
|
||||
"error.EMAIL_SERVICE_NOT_CONFIGURED": "E-Mail-Dienst ist nicht konfiguriert.",
|
||||
"error.QUERY_TOO_LONG": "Abfrage ist zu lang, die maximale Länge beträgt {{max}}.",
|
||||
"error.VALIDATION_ERROR": "Validierungsfehler, Fehler: {{errors}}",
|
||||
@@ -2148,6 +2220,7 @@
|
||||
"error.INVALID_HISTORY_TIMESTAMP": "Ungültiger Dokumentverlauf-Zeitstempel übergeben.",
|
||||
"error.DOC_HISTORY_NOT_FOUND": "Verlauf von {{docId}} bei {{timestamp}} unter Raum {{spaceId}}.",
|
||||
"error.BLOB_NOT_FOUND": "Blob {{blobId}} nicht im Raum {{spaceId}} gefunden.",
|
||||
"error.BLOB_INVALID": "Blob ist ungültig.",
|
||||
"error.EXPECT_TO_PUBLISH_DOC": "Erwartet wird die Veröffentlichung eines Dokuments, nicht eines Raums.",
|
||||
"error.EXPECT_TO_REVOKE_PUBLIC_DOC": "Erwartet wird der Widerruf eines öffentlichen Dokuments, nicht eines Raums.",
|
||||
"error.EXPECT_TO_GRANT_DOC_USER_ROLES": "Erwarten Sie, Rollen im Dokument {{docId}} unter Raum {{spaceId}} zu gewähren, nicht in einem Raum.",
|
||||
@@ -2178,6 +2251,7 @@
|
||||
"error.WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION": "Ein Workspace ist erforderlich, um für Team-Abonnement auszuchecken.",
|
||||
"error.WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION": "Workspace-ID ist erforderlich, um Teamabonnement zu aktualisieren.",
|
||||
"error.MANAGED_BY_APP_STORE_OR_PLAY": "Dieses Abonnement wird über den App Store oder Google Play verwaltet. Bitte verwalte es im entsprechenden Store.",
|
||||
"error.CALENDAR_PROVIDER_REQUEST_ERROR": "Fehler bei der Kalenderanbieter-Anfrage, Status: {{status}}, Meldung: {{message}}",
|
||||
"error.COPILOT_SESSION_NOT_FOUND": "Copilot-Sitzung nicht gefunden.",
|
||||
"error.COPILOT_SESSION_INVALID_INPUT": "Die Eingabe für die Copilot-Sitzung ist ungültig.",
|
||||
"error.COPILOT_SESSION_DELETED": "Copilot-Sitzung wurde gelöscht.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2684,10 +2684,12 @@ __metadata:
|
||||
"@preact/signals-core": "npm:^1.8.0"
|
||||
"@toeverything/theme": "npm:^1.1.23"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
fractional-indexing: "npm:^3.2.0"
|
||||
lit: "npm:^3.2.0"
|
||||
lodash-es: "npm:^4.17.23"
|
||||
minimatch: "npm:^10.1.1"
|
||||
rxjs: "npm:^7.8.2"
|
||||
vitest: "npm:^3.2.4"
|
||||
yjs: "npm:^13.6.27"
|
||||
zod: "npm:^3.25.76"
|
||||
languageName: unknown
|
||||
@@ -2814,6 +2816,7 @@ __metadata:
|
||||
lodash-es: "npm:^4.17.23"
|
||||
minimatch: "npm:^10.1.1"
|
||||
rxjs: "npm:^7.8.2"
|
||||
vitest: "npm:^3.2.4"
|
||||
yjs: "npm:^13.6.27"
|
||||
zod: "npm:^3.25.76"
|
||||
languageName: unknown
|
||||
|
||||
Reference in New Issue
Block a user