Skip to content

Commit 25227a0

Browse files
authored
feat: improve grouping perf in edgeless (#14442)
fix #14433 #### PR Dependency Tree * **PR #14442** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Level-of-detail thumbnails for large images. * Adaptive pacing for snapping, distribution and other alignment work. * RAF coalescer utility to batch high-frequency updates. * Operation timing utility to measure synchronous work. * **Improvements** * Batch group/ungroup reparenting that preserves element order and selection. * Coalesced panning and drag updates to reduce jitter. * Connector/group indexing for more reliable updates, deletions and sync. * Throttled viewport refresh behavior. * **Documentation** * Docs added for RAF coalescer and measureOperation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c0694c5 commit 25227a0

File tree

33 files changed

+2171
-161
lines changed

33 files changed

+2171
-161
lines changed

blocksuite/affine/blocks/image/src/image-edgeless-block.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import {
2626

2727
@Peekable()
2828
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
29+
private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024;
30+
private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080;
31+
private static readonly LOD_MAX_ZOOM = 0.4;
32+
private static readonly LOD_THUMBNAIL_MAX_EDGE = 256;
33+
2934
static override styles = css`
3035
affine-edgeless-image {
3136
position: relative;
@@ -63,13 +68,24 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
6368
width: 100%;
6469
height: 100%;
6570
}
71+
72+
affine-edgeless-image .resizable-img {
73+
position: relative;
74+
overflow: hidden;
75+
}
6676
`;
6777

6878
resourceController = new ResourceController(
6979
computed(() => this.model.props.sourceId$.value),
7080
'Image'
7181
);
7282

83+
private _lodThumbnailUrl: string | null = null;
84+
private _lodSourceUrl: string | null = null;
85+
private _lodGeneratingSourceUrl: string | null = null;
86+
private _lodGenerationToken = 0;
87+
private _lastShouldUseLod = false;
88+
7389
get blobUrl() {
7490
return this.resourceController.blobUrl$.value;
7591
}
@@ -96,6 +112,134 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
96112
});
97113
}
98114

115+
private _isLargeImage() {
116+
const { width = 0, height = 0, size = 0 } = this.model.props;
117+
const pixels = width * height;
118+
return (
119+
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
120+
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
121+
);
122+
}
123+
124+
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
125+
return (
126+
Boolean(blobUrl) &&
127+
this._isLargeImage() &&
128+
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
129+
);
130+
}
131+
132+
private _revokeLodThumbnail() {
133+
if (!this._lodThumbnailUrl) {
134+
return;
135+
}
136+
137+
URL.revokeObjectURL(this._lodThumbnailUrl);
138+
this._lodThumbnailUrl = null;
139+
}
140+
141+
private _resetLodSource(blobUrl: string | null) {
142+
if (this._lodSourceUrl === blobUrl) {
143+
return;
144+
}
145+
146+
this._lodGenerationToken += 1;
147+
this._lodGeneratingSourceUrl = null;
148+
this._lodSourceUrl = blobUrl;
149+
this._revokeLodThumbnail();
150+
}
151+
152+
private _createImageElement(src: string) {
153+
return new Promise<HTMLImageElement>((resolve, reject) => {
154+
const image = new Image();
155+
image.decoding = 'async';
156+
image.onload = () => resolve(image);
157+
image.onerror = () => reject(new Error('Failed to load image'));
158+
image.src = src;
159+
});
160+
}
161+
162+
private _createThumbnailBlob(image: HTMLImageElement) {
163+
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
164+
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
165+
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
166+
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
167+
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
168+
169+
const canvas = document.createElement('canvas');
170+
canvas.width = targetWidth;
171+
canvas.height = targetHeight;
172+
const ctx = canvas.getContext('2d');
173+
if (!ctx) {
174+
return Promise.resolve<Blob | null>(null);
175+
}
176+
ctx.imageSmoothingEnabled = true;
177+
ctx.imageSmoothingQuality = 'low';
178+
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
179+
180+
return new Promise<Blob | null>(resolve => {
181+
canvas.toBlob(resolve);
182+
});
183+
}
184+
185+
private _ensureLodThumbnail(blobUrl: string) {
186+
if (
187+
this._lodThumbnailUrl ||
188+
this._lodGeneratingSourceUrl === blobUrl ||
189+
!this._shouldUseLod(blobUrl)
190+
) {
191+
return;
192+
}
193+
194+
const token = ++this._lodGenerationToken;
195+
this._lodGeneratingSourceUrl = blobUrl;
196+
197+
void this._createImageElement(blobUrl)
198+
.then(image => this._createThumbnailBlob(image))
199+
.then(blob => {
200+
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
201+
return;
202+
}
203+
204+
const thumbnailUrl = URL.createObjectURL(blob);
205+
if (token !== this._lodGenerationToken || !this.isConnected) {
206+
URL.revokeObjectURL(thumbnailUrl);
207+
return;
208+
}
209+
210+
this._revokeLodThumbnail();
211+
this._lodThumbnailUrl = thumbnailUrl;
212+
213+
if (this._shouldUseLod(this.blobUrl)) {
214+
this.requestUpdate();
215+
}
216+
})
217+
.catch(err => {
218+
if (token !== this._lodGenerationToken || !this.isConnected) {
219+
return;
220+
}
221+
console.error(err);
222+
})
223+
.finally(() => {
224+
if (token === this._lodGenerationToken) {
225+
this._lodGeneratingSourceUrl = null;
226+
}
227+
});
228+
}
229+
230+
private _updateLodFromViewport(zoom: number) {
231+
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
232+
if (shouldUseLod === this._lastShouldUseLod) {
233+
return;
234+
}
235+
236+
this._lastShouldUseLod = shouldUseLod;
237+
if (shouldUseLod && this.blobUrl) {
238+
this._ensureLodThumbnail(this.blobUrl);
239+
}
240+
this.requestUpdate();
241+
}
242+
99243
override connectedCallback() {
100244
super.connectedCallback();
101245

@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
108252

109253
this.disposables.add(
110254
this.model.props.sourceId$.subscribe(() => {
255+
this._resetLodSource(null);
111256
this.refreshData();
112257
})
113258
);
259+
260+
this.disposables.add(
261+
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
262+
this._updateLodFromViewport(zoom);
263+
})
264+
);
265+
266+
this._lastShouldUseLod = this._shouldUseLod(this.blobUrl);
267+
}
268+
269+
override disconnectedCallback() {
270+
this._lodGenerationToken += 1;
271+
this._lodGeneratingSourceUrl = null;
272+
this._lodSourceUrl = null;
273+
this._revokeLodThumbnail();
274+
super.disconnectedCallback();
114275
}
115276

116277
override renderGfxBlock() {
117278
const blobUrl = this.blobUrl;
118279
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
280+
this._resetLodSource(blobUrl);
119281

120282
const containerStyleMap = styleMap({
121283
display: 'flex',
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
138300
});
139301

140302
const { loading, icon, description, error, needUpload } = resovledState;
303+
const shouldUseLod = this._shouldUseLod(blobUrl);
304+
if (shouldUseLod && blobUrl) {
305+
this._ensureLodThumbnail(blobUrl);
306+
}
307+
this._lastShouldUseLod = shouldUseLod;
308+
const imageUrl =
309+
shouldUseLod && this._lodThumbnailUrl ? this._lodThumbnailUrl : blobUrl;
141310

142311
return html`
143312
<div class="affine-image-container" style=${containerStyleMap}>
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
149318
class="drag-target"
150319
draggable="false"
151320
loading="lazy"
152-
src=${blobUrl}
321+
src=${imageUrl ?? ''}
153322
alt=${caption}
154323
@error=${this._handleError}
155324
/>

blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/misc.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import {
3333
ReleaseFromGroupIcon,
3434
UnlockIcon,
3535
} from '@blocksuite/icons/lit';
36-
import type { GfxModel } from '@blocksuite/std/gfx';
36+
import {
37+
batchAddChildren,
38+
batchRemoveChildren,
39+
type GfxModel,
40+
} from '@blocksuite/std/gfx';
3741
import { html } from 'lit';
3842

3943
import { renderAlignmentMenu } from './alignment';
@@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = {
6165

6266
const group = firstModel.group;
6367

64-
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
65-
group.removeChild(firstModel);
68+
batchRemoveChildren(group, [firstModel]);
6669

6770
firstModel.index = ctx.gfx.layer.generateIndex();
6871

6972
const parent = group.group;
7073
if (parent && parent instanceof GroupElementModel) {
71-
parent.addChild(firstModel);
74+
batchAddChildren(parent, [firstModel]);
7275
}
7376
},
7477
},
@@ -255,9 +258,12 @@ export const builtinMiscToolbarConfig = {
255258

256259
// release other elements from their groups and group with top element
257260
otherElements.forEach(element => {
258-
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
259-
element.group?.removeChild(element);
260-
topElement.group?.addChild(element);
261+
if (element.group) {
262+
batchRemoveChildren(element.group, [element]);
263+
}
264+
if (topElement.group) {
265+
batchAddChildren(topElement.group, [element]);
266+
}
261267
});
262268

263269
if (otherElements.length === 0) {

0 commit comments

Comments
 (0)