From 1476ca922b4fe20257587ea01cdbbc735a973e43 Mon Sep 17 00:00:00 2001 From: doodlewind <7312949+doodlewind@users.noreply.github.com> Date: Mon, 17 Feb 2025 02:35:28 +0000 Subject: [PATCH] refactor(editor): simplify worker renderer message and canvas transfer (#10199) - Fixed frame delay on panning. - Removed redundant worker message. - Removed redundant offscreen bitmap transfer. - Refactored logic using a clearer `refresh` method entry. - Extracted plain utils. --- .../shared/src/viewport-renderer/dom-utils.ts | 109 ++++++++ .../src/viewport-renderer/painter.worker.ts | 39 ++- .../viewport-renderer/viewport-renderer.ts | 245 +++++------------- .../src/__tests__/utils/renderer-entry.ts | 2 +- 4 files changed, 189 insertions(+), 206 deletions(-) create mode 100644 blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts diff --git a/blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts b/blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts new file mode 100644 index 0000000000..7b75796120 --- /dev/null +++ b/blocksuite/affine/shared/src/viewport-renderer/dom-utils.ts @@ -0,0 +1,109 @@ +import type { Viewport } from '@blocksuite/block-std/gfx'; +import { Pane } from 'tweakpane'; + +import { getSentenceRects, segmentSentences } from './text-utils.js'; +import type { ParagraphLayout, SectionLayout } from './types.js'; + +export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) { + const hostRect = host.getBoundingClientRect(); + const dpr = window.devicePixelRatio; + canvas.style.position = 'absolute'; + canvas.style.left = '0px'; + canvas.style.top = '0px'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.width = hostRect.width * dpr; + canvas.height = hostRect.height * dpr; + canvas.style.pointerEvents = 'none'; +} + +export function getSectionLayout( + host: HTMLElement, + viewport: Viewport +): SectionLayout { + const paragraphBlocks = host.querySelectorAll( + '.affine-paragraph-rich-text-wrapper [data-v-text="true"]' + ); + + const zoom = viewport.zoom; + + let sectionMinX = Infinity; + let sectionMinY = Infinity; + let sectionMaxX = -Infinity; + let sectionMaxY = -Infinity; + + const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => { + const sentences = segmentSentences(p.textContent || ''); + const sentenceLayouts = sentences.map(sentence => { + const rects = getSentenceRects(p, sentence); + rects.forEach(({ rect }) => { + sectionMinX = Math.min(sectionMinX, rect.x); + sectionMinY = Math.min(sectionMinY, rect.y); + sectionMaxX = Math.max(sectionMaxX, rect.x + rect.w); + sectionMaxY = Math.max(sectionMaxY, rect.y + rect.h); + }); + return { + text: sentence, + rects: rects.map(rect => { + const [x, y] = viewport.toModelCoordFromClientCoord([ + rect.rect.x, + rect.rect.y, + ]); + return { + ...rect, + rect: { + x, + y, + w: rect.rect.w / zoom / viewport.viewScale, + h: rect.rect.h / zoom / viewport.viewScale, + }, + }; + }), + }; + }); + + return { + sentences: sentenceLayouts, + zoom, + }; + }); + + const sectionModelCoord = viewport.toModelCoordFromClientCoord([ + sectionMinX, + sectionMinY, + ]); + const w = (sectionMaxX - sectionMinX) / zoom / viewport.viewScale; + const h = (sectionMaxY - sectionMinY) / zoom / viewport.viewScale; + const section: SectionLayout = { + paragraphs, + rect: { + x: sectionModelCoord[0], + y: sectionModelCoord[1], + w: Math.max(w, 0), + h: Math.max(h, 0), + }, + }; + return section; +} + +export function initTweakpane( + viewportElement: HTMLElement, + onStateChange: (value: boolean) => void +) { + const debugPane = new Pane({ container: viewportElement }); + const paneElement = debugPane.element; + paneElement.style.position = 'absolute'; + paneElement.style.top = '10px'; + paneElement.style.left = '10px'; + paneElement.style.width = '250px'; + debugPane.title = 'Viewport Turbo Renderer'; + + const params = { enabled: true }; + debugPane + .addBinding(params, 'enabled', { + label: 'Enable', + }) + .on('change', ({ value }) => { + onStateChange(value); + }); +} diff --git a/blocksuite/affine/shared/src/viewport-renderer/painter.worker.ts b/blocksuite/affine/shared/src/viewport-renderer/painter.worker.ts index be7c13958f..602e7830c5 100644 --- a/blocksuite/affine/shared/src/viewport-renderer/painter.worker.ts +++ b/blocksuite/affine/shared/src/viewport-renderer/painter.worker.ts @@ -1,8 +1,9 @@ import { type SectionLayout } from './types.js'; -type WorkerMessageInit = { - type: 'initSection'; +type WorkerMessagePaint = { + type: 'paintSection'; data: { + section: SectionLayout; width: number; height: number; dpr: number; @@ -10,14 +11,7 @@ type WorkerMessageInit = { }; }; -type WorkerMessagePaint = { - type: 'paintSection'; - data: { - section: SectionLayout; - }; -}; - -type WorkerMessage = WorkerMessageInit | WorkerMessagePaint; +type WorkerMessage = WorkerMessagePaint; const meta = { emSize: 2048, @@ -46,14 +40,21 @@ function getBaseline() { /** Section painter in worker */ class SectionPainter { - private canvas: OffscreenCanvas | null = null; + private readonly canvas: OffscreenCanvas = new OffscreenCanvas(0, 0); private ctx: OffscreenCanvasRenderingContext2D | null = null; private zoom = 1; - init(modelWidth: number, modelHeight: number, dpr: number, zoom: number) { - const width = modelWidth * dpr * zoom; - const height = modelHeight * dpr * zoom; - this.canvas = new OffscreenCanvas(width, height); + setSize( + sectionRectW: number, + sectionRectH: number, + dpr: number, + zoom: number + ) { + const width = sectionRectW * dpr * zoom; + const height = sectionRectH * dpr * zoom; + + this.canvas.width = width; + this.canvas.height = height; this.ctx = this.canvas.getContext('2d')!; this.ctx.scale(dpr, dpr); this.zoom = zoom; @@ -130,13 +131,9 @@ self.onmessage = async (e: MessageEvent) => { } switch (type) { - case 'initSection': { - const { width, height, dpr, zoom } = data; - painter.init(width, height, dpr, zoom); - break; - } case 'paintSection': { - const { section } = data; + const { section, width, height, dpr, zoom } = data; + painter.setSize(width, height, dpr, zoom); painter.paint(section); break; } diff --git a/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts b/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts index ea53fe0d18..4939400583 100644 --- a/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts +++ b/blocksuite/affine/shared/src/viewport-renderer/viewport-renderer.ts @@ -6,16 +6,27 @@ import { } from '@blocksuite/block-std'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import { type Container, type ServiceIdentifier } from '@blocksuite/global/di'; -import { Pane } from 'tweakpane'; +import { nextTick } from '@blocksuite/global/utils'; +import { type Pane } from 'tweakpane'; -import { getSentenceRects, segmentSentences } from './text-utils.js'; -import { type ParagraphLayout, type SectionLayout } from './types.js'; +import { + getSectionLayout, + initTweakpane, + syncCanvasSize, +} from './dom-utils.js'; +import { type SectionLayout } from './types.js'; export const ViewportTurboRendererIdentifier = LifeCycleWatcherIdentifier( 'ViewportTurboRenderer' ) as ServiceIdentifier; +interface Tile { + bitmap: ImageBitmap; +} + export class ViewportTurboRendererExtension extends LifeCycleWatcher { + state: 'monitoring' | 'paused' = 'paused'; + static override setup(di: Container) { di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]); } @@ -24,7 +35,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher { private readonly worker: Worker; private lastZoom: number | null = null; private lastSection: SectionLayout | null = null; - private lastBitmap: ImageBitmap | null = null; + private tile: Tile | null = null; private debugPane: Pane | null = null; constructor(std: BlockStdScope) { @@ -38,21 +49,26 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher { const viewportElement = document.querySelector('.affine-edgeless-viewport'); if (viewportElement) { viewportElement.append(this.canvas); - this.debugPane = new Pane({ container: viewportElement as HTMLElement }); - this.initTweakpane(); + initTweakpane(viewportElement as HTMLElement, (value: boolean) => { + this.state = value ? 'monitoring' : 'paused'; + this.canvas.style.display = value ? 'block' : 'none'; + }); } - this.viewport.viewportUpdated.on(async () => { - await this.render(); + syncCanvasSize(this.canvas, this.std.host); + this.viewport.viewportUpdated.on(() => { + this.refresh().catch(console.error); }); - document.fonts.load('15px Inter').then(async () => { - await this.render(); + document.fonts.load('15px Inter').then(() => { + this.state = 'monitoring'; + this.refresh().catch(console.error); }); } override unmounted() { - if (this.lastBitmap) { - this.lastBitmap.close(); + if (this.tile) { + this.tile.bitmap.close(); + this.tile = null; } if (this.debugPane) { this.debugPane.dispose(); @@ -62,126 +78,38 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher { this.canvas.remove(); } - private initTweakpane() { - if (!this.debugPane) return; - - const paneElement = this.debugPane.element; - paneElement.style.position = 'absolute'; - paneElement.style.top = '10px'; - paneElement.style.left = '10px'; - paneElement.style.width = '250px'; - - this.debugPane.title = 'Viewport Turbo Renderer'; - - const params = { - enabled: true, - }; - - this.debugPane - .addBinding(params, 'enabled', { - label: 'Enable', - }) - .on('change', ({ value }) => { - this.canvas.style.display = value ? 'block' : 'none'; - }); - } - get viewport() { return this.std.get(GfxControllerIdentifier).viewport; } - getHostRect() { - return this.std.host.getBoundingClientRect(); + private async refresh() { + await nextTick(); // Improves stability during zooming + + if (this.canUseCache()) { + this.drawCachedBitmap(this.lastSection!); + } else { + const section = getSectionLayout(this.std.host, this.viewport); + await this.paintSection(section); + this.lastSection = section; + this.lastZoom = this.viewport.zoom; + this.drawCachedBitmap(section); + } } - getHostLayout() { - if (!document.fonts.check('15px Inter')) return null; - - const paragraphBlocks = this.std.host.querySelectorAll( - '.affine-paragraph-rich-text-wrapper [data-v-text="true"]' - ); - - const { viewport } = this; - const zoom = this.viewport.zoom; - const hostRect = this.getHostRect(); - - let sectionMinX = Infinity; - let sectionMinY = Infinity; - let sectionMaxX = -Infinity; - let sectionMaxY = -Infinity; - - const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => { - const sentences = segmentSentences(p.textContent || ''); - const sentenceLayouts = sentences.map(sentence => { - const rects = getSentenceRects(p, sentence); - rects.forEach(({ rect }) => { - sectionMinX = Math.min(sectionMinX, rect.x); - sectionMinY = Math.min(sectionMinY, rect.y); - sectionMaxX = Math.max(sectionMaxX, rect.x + rect.w); - sectionMaxY = Math.max(sectionMaxY, rect.y + rect.h); - }); - return { - text: sentence, - rects: rects.map(rect => { - const [x, y] = viewport.toModelCoordFromClientCoord([ - rect.rect.x, - rect.rect.y, - ]); - return { - ...rect, - rect: { - x, - y, - w: rect.rect.w / zoom / viewport.viewScale, - h: rect.rect.h / zoom / viewport.viewScale, - }, - }; - }), - }; - }); - - return { - sentences: sentenceLayouts, - zoom, - }; - }); - - if (paragraphs.length === 0) return null; - - const sectionModelCoord = viewport.toModelCoordFromClientCoord([ - sectionMinX, - sectionMinY, - ]); - const w = (sectionMaxX - sectionMinX) / zoom / viewport.viewScale; - const h = (sectionMaxY - sectionMinY) / zoom / viewport.viewScale; - const section: SectionLayout = { - paragraphs, - rect: { - x: sectionModelCoord[0], - y: sectionModelCoord[1], - w: Math.max(w, 0), - h: Math.max(h, 0), - }, - }; - - return { section, hostRect }; - } - - private initSectionRenderer(width: number, height: number) { - const dpr = window.devicePixelRatio; - this.worker.postMessage({ - type: 'initSection', - data: { width, height, dpr, zoom: this.viewport.zoom }, - }); - } - - private async renderSection(section: SectionLayout): Promise { + private async paintSection(section: SectionLayout): Promise { return new Promise(resolve => { if (!this.worker) return; + const dpr = window.devicePixelRatio; this.worker.postMessage({ type: 'paintSection', - data: { section }, + data: { + section, + width: section.rect.w, + height: section.rect.h, + dpr, + zoom: this.viewport.zoom, + }, }); this.worker.onmessage = (e: MessageEvent) => { @@ -197,90 +125,39 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher { section: SectionLayout, resolve: () => void ) { - const tempCanvas = new OffscreenCanvas(bitmap.width, bitmap.height); - const tempCtx = tempCanvas.getContext('2d')!; - tempCtx.drawImage(bitmap, 0, 0); - const bitmapCopy = tempCanvas.transferToImageBitmap(); - - this.updateCacheState(section, bitmapCopy); - this.drawBitmap(bitmap, section); + if (this.tile) { + this.tile.bitmap.close(); + } + this.tile = { bitmap }; + this.drawCachedBitmap(section); resolve(); } - private syncCanvasSize() { - const hostRect = this.getHostRect(); - const dpr = window.devicePixelRatio; - this.canvas.style.position = 'absolute'; - this.canvas.style.left = '0px'; - this.canvas.style.top = '0px'; - this.canvas.style.width = '100%'; - this.canvas.style.height = '100%'; - this.canvas.width = hostRect.width * dpr; - this.canvas.height = hostRect.height * dpr; - this.canvas.style.pointerEvents = 'none'; - } - - private updateCacheState(section: SectionLayout, bitmapCopy: ImageBitmap) { - this.lastZoom = this.viewport.zoom; - this.lastSection = section; - if (this.lastBitmap) { - this.lastBitmap.close(); - } - this.lastBitmap = bitmapCopy; - } - - private canUseCache(currentZoom: number): boolean { + private canUseCache(): boolean { return ( - this.lastZoom === currentZoom && !!this.lastSection && !!this.lastBitmap + !!this.lastSection && !!this.tile && this.viewport.zoom === this.lastZoom ); } - private drawBitmap(bitmap: ImageBitmap, section: SectionLayout) { + private drawCachedBitmap(section: SectionLayout) { + if (this.state === 'paused') return; + + const bitmap = this.tile!.bitmap; const ctx = this.canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - const bitmapCanvas = new OffscreenCanvas( - section.rect.w * window.devicePixelRatio * this.viewport.zoom, - section.rect.h * window.devicePixelRatio * this.viewport.zoom - ); - const bitmapCtx = bitmapCanvas.getContext('bitmaprenderer'); - if (!bitmapCtx) return; - - const tempCanvas = new OffscreenCanvas(bitmap.width, bitmap.height); - const tempCtx = tempCanvas.getContext('2d')!; - tempCtx.drawImage(bitmap, 0, 0); - const bitmapCopy = tempCanvas.transferToImageBitmap(); - - bitmapCtx.transferFromImageBitmap(bitmapCopy); - const sectionViewCoord = this.viewport.toViewCoord( section.rect.x, section.rect.y ); ctx.drawImage( - bitmapCanvas, + bitmap, sectionViewCoord[0] * window.devicePixelRatio, sectionViewCoord[1] * window.devicePixelRatio, section.rect.w * window.devicePixelRatio * this.viewport.zoom, section.rect.h * window.devicePixelRatio * this.viewport.zoom ); } - - public async render(): Promise { - const hostLayout = this.getHostLayout(); - if (!hostLayout) return; - - const { section } = hostLayout; - const currentZoom = this.viewport.zoom; - - if (this.canUseCache(currentZoom)) { - this.drawBitmap(this.lastBitmap!, this.lastSection!); - } else { - this.syncCanvasSize(); - this.initSectionRenderer(section.rect.w, section.rect.h); - await this.renderSection(section); - } - } } diff --git a/blocksuite/presets/src/__tests__/utils/renderer-entry.ts b/blocksuite/presets/src/__tests__/utils/renderer-entry.ts index a208d683c8..af0a64cca5 100644 --- a/blocksuite/presets/src/__tests__/utils/renderer-entry.ts +++ b/blocksuite/presets/src/__tests__/utils/renderer-entry.ts @@ -5,7 +5,7 @@ import { setupEditor } from './setup.js'; async function init() { setupEditor('edgeless', [ViewportTurboRendererExtension]); - addSampleNotes(doc, 6); + addSampleNotes(doc, 1); doc.load(); }