refactor(editor): reduce dom query per refresh (#10319)

This commit is contained in:
doodlewind
2025-02-20 10:01:13 +00:00
parent 5fcc402280
commit 7b1dfb7ee8
4 changed files with 92 additions and 77 deletions
@@ -5,7 +5,7 @@ import {
import { Pane } from 'tweakpane'; import { Pane } from 'tweakpane';
import { getSentenceRects, segmentSentences } from './text-utils.js'; import { getSentenceRects, segmentSentences } from './text-utils.js';
import type { ParagraphLayout, SectionLayout } from './types.js'; import type { ParagraphLayout, ViewportLayout } from './types.js';
import type { ViewportTurboRendererExtension } from './viewport-renderer.js'; import type { ViewportTurboRendererExtension } from './viewport-renderer.js';
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) { export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
@@ -21,30 +21,30 @@ export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
canvas.style.pointerEvents = 'none'; canvas.style.pointerEvents = 'none';
} }
export function getSectionLayout( export function getViewportLayout(
host: HTMLElement, host: HTMLElement,
viewport: Viewport viewport: Viewport
): SectionLayout { ): ViewportLayout {
const paragraphBlocks = host.querySelectorAll( const paragraphBlocks = host.querySelectorAll(
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]' '.affine-paragraph-rich-text-wrapper [data-v-text="true"]'
); );
const zoom = viewport.zoom; const zoom = viewport.zoom;
let sectionMinX = Infinity; let layoutMinX = Infinity;
let sectionMinY = Infinity; let layoutMinY = Infinity;
let sectionMaxX = -Infinity; let layoutMaxX = -Infinity;
let sectionMaxY = -Infinity; let layoutMaxY = -Infinity;
const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => { const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => {
const sentences = segmentSentences(p.textContent || ''); const sentences = segmentSentences(p.textContent || '');
const sentenceLayouts = sentences.map(sentence => { const sentenceLayouts = sentences.map(sentence => {
const rects = getSentenceRects(p, sentence); const rects = getSentenceRects(p, sentence);
rects.forEach(({ rect }) => { rects.forEach(({ rect }) => {
sectionMinX = Math.min(sectionMinX, rect.x); layoutMinX = Math.min(layoutMinX, rect.x);
sectionMinY = Math.min(sectionMinY, rect.y); layoutMinY = Math.min(layoutMinY, rect.y);
sectionMaxX = Math.max(sectionMaxX, rect.x + rect.w); layoutMaxX = Math.max(layoutMaxX, rect.x + rect.w);
sectionMaxY = Math.max(sectionMaxY, rect.y + rect.h); layoutMaxY = Math.max(layoutMaxY, rect.y + rect.h);
}); });
return { return {
text: sentence, text: sentence,
@@ -72,22 +72,22 @@ export function getSectionLayout(
}; };
}); });
const sectionModelCoord = viewport.toModelCoordFromClientCoord([ const layoutModelCoord = viewport.toModelCoordFromClientCoord([
sectionMinX, layoutMinX,
sectionMinY, layoutMinY,
]); ]);
const w = (sectionMaxX - sectionMinX) / zoom / viewport.viewScale; const w = (layoutMaxX - layoutMinX) / zoom / viewport.viewScale;
const h = (sectionMaxY - sectionMinY) / zoom / viewport.viewScale; const h = (layoutMaxY - layoutMinY) / zoom / viewport.viewScale;
const section: SectionLayout = { const layout: ViewportLayout = {
paragraphs, paragraphs,
rect: { rect: {
x: sectionModelCoord[0], x: layoutModelCoord[0],
y: sectionModelCoord[1], y: layoutModelCoord[1],
w: Math.max(w, 0), w: Math.max(w, 0),
h: Math.max(h, 0), h: Math.max(h, 0),
}, },
}; };
return section; return layout;
} }
export function initTweakpane( export function initTweakpane(
@@ -1,9 +1,9 @@
import { type SectionLayout } from './types.js'; import { type ViewportLayout } from './types.js';
type WorkerMessagePaint = { type WorkerMessagePaint = {
type: 'paintSection'; type: 'paintLayout';
data: { data: {
section: SectionLayout; layout: ViewportLayout;
width: number; width: number;
height: number; height: number;
dpr: number; dpr: number;
@@ -38,20 +38,15 @@ function getBaseline() {
return y; return y;
} }
/** Section painter in worker */ /** Layout painter in worker */
class SectionPainter { class LayoutPainter {
private readonly canvas: OffscreenCanvas = new OffscreenCanvas(0, 0); private readonly canvas: OffscreenCanvas = new OffscreenCanvas(0, 0);
private ctx: OffscreenCanvasRenderingContext2D | null = null; private ctx: OffscreenCanvasRenderingContext2D | null = null;
private zoom = 1; private zoom = 1;
setSize( setSize(layoutRectW: number, layoutRectH: number, dpr: number, zoom: number) {
sectionRectW: number, const width = layoutRectW * dpr * zoom;
sectionRectH: number, const height = layoutRectH * dpr * zoom;
dpr: number,
zoom: number
) {
const width = sectionRectW * dpr * zoom;
const height = sectionRectH * dpr * zoom;
this.canvas.width = width; this.canvas.width = width;
this.canvas.height = height; this.canvas.height = height;
@@ -68,11 +63,11 @@ class SectionPainter {
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
} }
paint(section: SectionLayout) { paint(layout: ViewportLayout) {
const { canvas, ctx } = this; const { canvas, ctx } = this;
if (!canvas || !ctx) return; if (!canvas || !ctx) return;
if (section.rect.w === 0 || section.rect.h === 0) { if (layout.rect.w === 0 || layout.rect.h === 0) {
console.warn('empty section rect'); console.warn('empty layout rect');
return; return;
} }
@@ -83,7 +78,7 @@ class SectionPainter {
// Track rendered positions to avoid duplicate rendering across all paragraphs and sentences // Track rendered positions to avoid duplicate rendering across all paragraphs and sentences
const renderedPositions = new Set<string>(); const renderedPositions = new Set<string>();
section.paragraphs.forEach(paragraph => { layout.paragraphs.forEach(paragraph => {
const fontSize = 15; const fontSize = 15;
ctx.font = `300 ${fontSize}px Inter`; ctx.font = `300 ${fontSize}px Inter`;
const baselineY = getBaseline(); const baselineY = getBaseline();
@@ -91,8 +86,8 @@ class SectionPainter {
paragraph.sentences.forEach(sentence => { paragraph.sentences.forEach(sentence => {
ctx.strokeStyle = 'yellow'; ctx.strokeStyle = 'yellow';
sentence.rects.forEach(textRect => { sentence.rects.forEach(textRect => {
const x = textRect.rect.x - section.rect.x; const x = textRect.rect.x - layout.rect.x;
const y = textRect.rect.y - section.rect.y; const y = textRect.rect.y - layout.rect.y;
const posKey = `${x},${y}`; const posKey = `${x},${y}`;
// Only render if we haven't rendered at this position before // Only render if we haven't rendered at this position before
@@ -112,7 +107,7 @@ class SectionPainter {
} }
} }
const painter = new SectionPainter(); const painter = new LayoutPainter();
let fontLoaded = false; let fontLoaded = false;
font font
@@ -131,10 +126,10 @@ self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
} }
switch (type) { switch (type) {
case 'paintSection': { case 'paintLayout': {
const { section, width, height, dpr, zoom } = data; const { layout, width, height, dpr, zoom } = data;
painter.setSize(width, height, dpr, zoom); painter.setSize(width, height, dpr, zoom);
painter.paint(section); painter.paint(layout);
break; break;
} }
} }
@@ -23,7 +23,7 @@ export interface ParagraphLayout {
zoom: number; zoom: number;
} }
export interface SectionLayout { export interface ViewportLayout {
paragraphs: ParagraphLayout[]; paragraphs: ParagraphLayout[];
rect: Rect; rect: Rect;
} }
@@ -6,15 +6,15 @@ import {
} from '@blocksuite/block-std'; } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { type Container, type ServiceIdentifier } from '@blocksuite/global/di'; import { type Container, type ServiceIdentifier } from '@blocksuite/global/di';
import { nextTick } from '@blocksuite/global/utils'; import { debounce, DisposableGroup } from '@blocksuite/global/utils';
import { type Pane } from 'tweakpane'; import { type Pane } from 'tweakpane';
import { import {
getSectionLayout, getViewportLayout,
initTweakpane, initTweakpane,
syncCanvasSize, syncCanvasSize,
} from './dom-utils.js'; } from './dom-utils.js';
import { type SectionLayout } from './types.js'; import { type ViewportLayout } from './types.js';
export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier( export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier(
'ViewportTurboRenderer' 'ViewportTurboRenderer'
@@ -22,10 +22,12 @@ export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier(
interface Tile { interface Tile {
bitmap: ImageBitmap; bitmap: ImageBitmap;
zoom: number;
} }
export class ViewportTurboRendererExtension extends LifeCycleWatcher { export class ViewportTurboRendererExtension extends LifeCycleWatcher {
state: 'monitoring' | 'paused' = 'paused'; state: 'monitoring' | 'paused' = 'paused';
disposables = new DisposableGroup();
static override setup(di: Container) { static override setup(di: Container) {
di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]); di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]);
@@ -33,8 +35,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
public readonly canvas: HTMLCanvasElement = document.createElement('canvas'); public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
private readonly worker: Worker; private readonly worker: Worker;
private lastZoom: number | null = null; private layoutCache: ViewportLayout | null = null;
private lastSection: SectionLayout | null = null;
private tile: Tile | null = null; private tile: Tile | null = null;
private debugPane: Pane | null = null; private debugPane: Pane | null = null;
@@ -56,6 +57,16 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.refresh().catch(console.error); this.refresh().catch(console.error);
}); });
const debounceOptions = { leading: false, trailing: true };
const debouncedLayoutUpdate = debounce(
() => this.updateLayoutCache(),
500,
debounceOptions
);
this.disposables.add(
this.std.store.slots.blockUpdated.on(debouncedLayoutUpdate)
);
document.fonts.load('15px Inter').then(() => { document.fonts.load('15px Inter').then(() => {
// this.state = 'monitoring'; // this.state = 'monitoring';
this.refresh().catch(console.error); this.refresh().catch(console.error);
@@ -73,6 +84,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
} }
this.worker.terminate(); this.worker.terminate();
this.canvas.remove(); this.canvas.remove();
this.disposables.dispose();
} }
get viewport() { get viewport() {
@@ -82,30 +94,35 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
async refresh(force = false) { async refresh(force = false) {
if (this.state === 'paused' && !force) return; if (this.state === 'paused' && !force) return;
await nextTick(); // Improves stability during zooming if (this.canUseBitmapCache()) {
this.drawCachedBitmap(this.layoutCache!);
if (this.canUseCache()) {
this.drawCachedBitmap(this.lastSection!);
} else { } else {
const section = getSectionLayout(this.std.host, this.viewport); // Unneeded most of the time, the DOM query is debounced after block update
await this.paintSection(section); if (!this.layoutCache) {
this.lastSection = section; this.updateLayoutCache();
this.lastZoom = this.viewport.zoom; }
this.drawCachedBitmap(section);
await this.paintLayout(this.layoutCache!);
this.drawCachedBitmap(this.layoutCache!);
} }
} }
private async paintSection(section: SectionLayout): Promise<void> { private updateLayoutCache() {
const layout = getViewportLayout(this.std.host, this.viewport);
this.layoutCache = layout;
}
private async paintLayout(layout: ViewportLayout): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {
if (!this.worker) return; if (!this.worker) return;
const dpr = window.devicePixelRatio; const dpr = window.devicePixelRatio;
this.worker.postMessage({ this.worker.postMessage({
type: 'paintSection', type: 'paintLayout',
data: { data: {
section, layout,
width: section.rect.w, width: layout.rect.w,
height: section.rect.h, height: layout.rect.h,
dpr, dpr,
zoom: this.viewport.zoom, zoom: this.viewport.zoom,
}, },
@@ -113,7 +130,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
this.worker.onmessage = (e: MessageEvent) => { this.worker.onmessage = (e: MessageEvent) => {
if (e.data.type === 'bitmapPainted') { if (e.data.type === 'bitmapPainted') {
this.handlePaintedBitmap(e.data.bitmap, section, resolve); this.handlePaintedBitmap(e.data.bitmap, layout, resolve);
} }
}; };
}); });
@@ -121,40 +138,43 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
private handlePaintedBitmap( private handlePaintedBitmap(
bitmap: ImageBitmap, bitmap: ImageBitmap,
section: SectionLayout, layout: ViewportLayout,
resolve: () => void resolve: () => void
) { ) {
if (this.tile) { if (this.tile) {
this.tile.bitmap.close(); this.tile.bitmap.close();
} }
this.tile = { bitmap }; this.tile = {
this.drawCachedBitmap(section); bitmap,
zoom: this.viewport.zoom,
};
this.drawCachedBitmap(layout);
resolve(); resolve();
} }
private canUseCache(): boolean { private canUseBitmapCache(): boolean {
return ( return (
!!this.lastSection && !!this.tile && this.viewport.zoom === this.lastZoom !!this.layoutCache && !!this.tile && this.viewport.zoom === this.tile.zoom
); );
} }
private drawCachedBitmap(section: SectionLayout) { private drawCachedBitmap(layout: ViewportLayout) {
const bitmap = this.tile!.bitmap; const bitmap = this.tile!.bitmap;
const ctx = this.canvas.getContext('2d'); const ctx = this.canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const sectionViewCoord = this.viewport.toViewCoord( const layoutViewCoord = this.viewport.toViewCoord(
section.rect.x, layout.rect.x,
section.rect.y layout.rect.y
); );
ctx.drawImage( ctx.drawImage(
bitmap, bitmap,
sectionViewCoord[0] * window.devicePixelRatio, layoutViewCoord[0] * window.devicePixelRatio,
sectionViewCoord[1] * window.devicePixelRatio, layoutViewCoord[1] * window.devicePixelRatio,
section.rect.w * window.devicePixelRatio * this.viewport.zoom, layout.rect.w * window.devicePixelRatio * this.viewport.zoom,
section.rect.h * window.devicePixelRatio * this.viewport.zoom layout.rect.h * window.devicePixelRatio * this.viewport.zoom
); );
} }
} }