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:
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user