refactor(editor): generic layout type support for turbo renderer (#10766)

This PR refactored the turbo renderer architecture to support multiple block layout types.

- New base class `BlockLayoutPainter` and `BlockLayoutProvider` are introduced for writing extendable per-block layout querying and painting logic.
- Paragraph-specific lines are all moved into dedicated classes (`ParagraphLayoutProvider` and `ParagraphLayoutPainter`) under the `/variants/paragraph` dir.
- The `renderer-utils.ts` doesn't contain paragraph-specific logic now.
- The `text-utils.ts` is also now scoped for paragraph only.
- Worker messages are now strongly typed.

Upcoming PR should further implement the block registration system using extension API. The `variants` dir could still exist, since there will be similar rendering logic that can be reused among block types (i.e., between paragraph block and list block).
This commit is contained in:
doodlewind
2025-03-13 05:18:11 +00:00
parent c1c750d782
commit c023b724d0
18 changed files with 578 additions and 252 deletions

View File

@@ -14,6 +14,7 @@
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
@@ -33,7 +34,8 @@
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
"./effects": "./src/effects.ts",
"./turbo-painter": "./src/turbo/paragraph-painter.worker.ts"
},
"files": [
"src",

View File

@@ -3,3 +3,5 @@ export * from './commands';
export * from './paragraph-block.js';
export * from './paragraph-service.js';
export * from './paragraph-spec.js';
export * from './turbo/paragraph-layout-provider.js';
export * from './turbo/paragraph-painter-config.js';

View File

@@ -0,0 +1,99 @@
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutHandlerExtension,
BlockLayoutHandlersIdentifier,
getSentenceRects,
segmentSentences,
} from '@blocksuite/affine-gfx-turbo-renderer';
import type { GfxBlockComponent } from '@blocksuite/block-std';
import { clientToModelCoord } from '@blocksuite/block-std/gfx';
import type { Container } from '@blocksuite/global/di';
import type { ParagraphLayout } from './paragraph-painter.worker';
export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension<ParagraphLayout> {
readonly blockType = 'affine:paragraph';
static override setup(di: Container) {
const layoutHandler = new ParagraphLayoutHandlerExtension();
di.addImpl(BlockLayoutHandlersIdentifier, layoutHandler);
}
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {
const paragraphSelector =
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
const paragraphNodes = component.querySelectorAll(paragraphSelector);
if (paragraphNodes.length === 0) return null;
const viewportRecord = component.gfx.viewport.deserializeRecord(
component.dataset.viewportState
);
if (!viewportRecord) return null;
const { zoom, viewScale } = viewportRecord;
const paragraph: ParagraphLayout = {
type: 'affine:paragraph',
sentences: [],
};
paragraphNodes.forEach(paragraphNode => {
const sentences = segmentSentences(paragraphNode.textContent || '');
const sentenceLayouts = sentences.map(sentence => {
const sentenceRects = getSentenceRects(paragraphNode, sentence);
const rects = sentenceRects.map(({ text, rect }) => {
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
rect.x,
rect.y,
]);
return {
text,
rect: {
x: modelX,
y: modelY,
w: rect.w / zoom / viewScale,
h: rect.h / zoom / viewScale,
},
};
});
return {
text: sentence,
rects,
};
});
paragraph.sentences.push(...sentenceLayouts);
});
return paragraph;
}
calculateBound(layout: ParagraphLayout) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
layout.sentences.forEach(sentence => {
sentence.rects.forEach(r => {
minX = Math.min(minX, r.rect.x);
minY = Math.min(minY, r.rect.y);
maxX = Math.max(maxX, r.rect.x + r.rect.w);
maxY = Math.max(maxY, r.rect.y + r.rect.h);
});
});
const rect: Rect = {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
return {
rect,
subRects: layout.sentences.flatMap(s => s.rects.map(r => r.rect)),
};
}
}

View File

@@ -0,0 +1,16 @@
import { BlockPainterConfigIdentifier } from '@blocksuite/affine-gfx-turbo-renderer';
import type { Container } from '@blocksuite/global/di';
import { Extension } from '@blocksuite/store';
export class ParagraphPaintConfigExtension extends Extension {
static override setup(di: Container) {
const config = {
type: 'affine:paragraph',
path: new URL(
'@blocksuite/affine-block-paragraph/turbo-painter',
import.meta.url
).href,
};
di.addImpl(BlockPainterConfigIdentifier, config);
}
}

View File

@@ -0,0 +1,109 @@
import type {
BlockLayout,
BlockLayoutPainter,
TextRect,
WorkerToHostMessage,
} from '@blocksuite/affine-gfx-turbo-renderer';
interface SentenceLayout {
text: string;
rects: TextRect[];
}
export interface ParagraphLayout extends BlockLayout {
type: 'affine:paragraph';
sentences: SentenceLayout[];
}
const meta = {
emSize: 2048,
hHeadAscent: 1984,
hHeadDescent: -494,
};
const debugSentenceBorder = false;
function getBaseline() {
const fontSize = 15;
const lineHeight = 1.2 * fontSize;
const A = fontSize * (meta.hHeadAscent / meta.emSize); // ascent
const D = fontSize * (meta.hHeadDescent / meta.emSize); // descent
const AD = A + Math.abs(D); // ascent + descent
const L = lineHeight - AD; // leading
const y = A + L / 2;
return y;
}
function isParagraphLayout(layout: BlockLayout): layout is ParagraphLayout {
return layout.type === 'affine:paragraph';
}
export default class ParagraphLayoutPainter implements BlockLayoutPainter {
static readonly font = new FontFace(
'Inter',
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
);
static fontLoaded = false;
static {
if (typeof self !== 'undefined' && 'fonts' in self) {
// @ts-expect-error worker fonts API
self.fonts.add(ParagraphLayoutPainter.font);
ParagraphLayoutPainter.font
.load()
.then(() => {
ParagraphLayoutPainter.fontLoaded = true;
})
.catch(error => {
console.error('Failed to load Inter font:', error);
});
}
}
paint(
ctx: OffscreenCanvasRenderingContext2D,
layout: BlockLayout,
layoutBaseX: number,
layoutBaseY: number
): void {
if (!ParagraphLayoutPainter.fontLoaded) {
const message: WorkerToHostMessage = {
type: 'paintError',
error: 'Font not loaded',
blockType: 'affine:paragraph',
};
self.postMessage(message);
return;
}
if (!isParagraphLayout(layout)) return; // cast to ParagraphLayout
const fontSize = 15;
ctx.font = `300 ${fontSize}px Inter`;
const baselineY = getBaseline();
const renderedPositions = new Set<string>();
layout.sentences.forEach(sentence => {
ctx.strokeStyle = 'yellow';
sentence.rects.forEach(textRect => {
const x = textRect.rect.x - layoutBaseX;
const y = textRect.rect.y - layoutBaseY;
const posKey = `${x},${y}`;
// Only render if we haven't rendered at this position before
if (renderedPositions.has(posKey)) return;
if (debugSentenceBorder) {
ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h);
}
ctx.fillStyle = 'black';
ctx.fillText(textRect.text, x, y + baselineY);
renderedPositions.add(posKey);
});
});
}
}