mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 02:26:21 +08:00
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,15 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lodash-es": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"tweakpane": "^4.0.5"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./painter": "./src/painter/painter.worker.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './turbo-renderer.js';
|
||||
export * from './types.js';
|
||||
export * from './layout/block-layout-provider';
|
||||
export * from './text-utils';
|
||||
export * from './turbo-renderer';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GfxBlockComponent } from '@blocksuite/block-std';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import type { BlockLayout, Rect } from '../types';
|
||||
|
||||
export abstract class BlockLayoutHandlerExtension<
|
||||
T extends BlockLayout = BlockLayout,
|
||||
> extends Extension {
|
||||
abstract readonly blockType: string;
|
||||
abstract queryLayout(component: GfxBlockComponent): T | null;
|
||||
abstract calculateBound(layout: T): {
|
||||
rect: Rect;
|
||||
subRects: Rect[];
|
||||
};
|
||||
}
|
||||
|
||||
export const BlockLayoutHandlersIdentifier =
|
||||
createIdentifier<BlockLayoutHandlerExtension>(
|
||||
'BlockLayoutHandlersIdentifier'
|
||||
);
|
||||
@@ -1,142 +0,0 @@
|
||||
import { type ViewportLayout } from './types.js';
|
||||
|
||||
type WorkerMessagePaint = {
|
||||
type: 'paintLayout';
|
||||
data: {
|
||||
layout: ViewportLayout;
|
||||
width: number;
|
||||
height: number;
|
||||
dpr: number;
|
||||
zoom: number;
|
||||
version: number;
|
||||
};
|
||||
};
|
||||
|
||||
type WorkerMessage = WorkerMessagePaint;
|
||||
|
||||
const meta = {
|
||||
emSize: 2048,
|
||||
hHeadAscent: 1984,
|
||||
hHeadDescent: -494,
|
||||
};
|
||||
|
||||
const font = new FontFace(
|
||||
'Inter',
|
||||
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
|
||||
);
|
||||
// @ts-expect-error worker env
|
||||
self.fonts && self.fonts.add(font);
|
||||
|
||||
const debugSentenceBoarder = 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;
|
||||
}
|
||||
|
||||
/** Layout painter in worker */
|
||||
class LayoutPainter {
|
||||
private readonly canvas: OffscreenCanvas = new OffscreenCanvas(0, 0);
|
||||
private ctx: OffscreenCanvasRenderingContext2D | null = null;
|
||||
private zoom = 1;
|
||||
|
||||
setSize(layoutRectW: number, layoutRectH: number, dpr: number, zoom: number) {
|
||||
const width = layoutRectW * dpr * zoom;
|
||||
const height = layoutRectH * dpr * zoom;
|
||||
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
this.ctx.scale(dpr, dpr);
|
||||
this.zoom = zoom;
|
||||
this.clearBackground();
|
||||
}
|
||||
|
||||
private clearBackground() {
|
||||
if (!this.canvas || !this.ctx) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
paint(layout: ViewportLayout, version: number) {
|
||||
const { canvas, ctx } = this;
|
||||
if (!canvas || !ctx) return;
|
||||
if (layout.rect.w === 0 || layout.rect.h === 0) {
|
||||
console.warn('empty layout rect');
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBackground();
|
||||
|
||||
ctx.scale(this.zoom, this.zoom);
|
||||
|
||||
// Track rendered positions to avoid duplicate rendering across all paragraphs and sentences
|
||||
const renderedPositions = new Set<string>();
|
||||
|
||||
layout.paragraphs.forEach(paragraph => {
|
||||
const fontSize = 15;
|
||||
ctx.font = `300 ${fontSize}px Inter`;
|
||||
const baselineY = getBaseline();
|
||||
|
||||
paragraph.sentences.forEach(sentence => {
|
||||
ctx.strokeStyle = 'yellow';
|
||||
sentence.rects.forEach(textRect => {
|
||||
const x = textRect.rect.x - layout.rect.x;
|
||||
const y = textRect.rect.y - layout.rect.y;
|
||||
|
||||
const posKey = `${x},${y}`;
|
||||
// Only render if we haven't rendered at this position before
|
||||
if (renderedPositions.has(posKey)) return;
|
||||
|
||||
if (debugSentenceBoarder) {
|
||||
ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h);
|
||||
}
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillText(textRect.text, x, y + baselineY);
|
||||
|
||||
renderedPositions.add(posKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const bitmap = canvas.transferToImageBitmap();
|
||||
self.postMessage(
|
||||
{ type: 'bitmapPainted', bitmap, version },
|
||||
{ transfer: [bitmap] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const painter = new LayoutPainter();
|
||||
let fontLoaded = false;
|
||||
|
||||
font
|
||||
.load()
|
||||
.then(() => {
|
||||
fontLoaded = true;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
||||
const { type, data } = e.data;
|
||||
|
||||
if (!fontLoaded) {
|
||||
await font.load();
|
||||
fontLoaded = true;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'paintLayout': {
|
||||
const { layout, width, height, dpr, zoom, version } = data;
|
||||
painter.setSize(width, height, dpr, zoom);
|
||||
painter.paint(layout, version);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
BlockLayoutPainter,
|
||||
HostToWorkerMessage,
|
||||
ViewportLayout,
|
||||
WorkerToHostMessage,
|
||||
} from '../types';
|
||||
|
||||
class BlockPainterRegistry {
|
||||
private readonly painters = new Map<string, BlockLayoutPainter>();
|
||||
|
||||
register(type: string, painter: BlockLayoutPainter) {
|
||||
this.painters.set(type, painter);
|
||||
}
|
||||
|
||||
getPainter(type: string): BlockLayoutPainter | undefined {
|
||||
return this.painters.get(type);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewportLayoutPainter {
|
||||
private readonly canvas: OffscreenCanvas = new OffscreenCanvas(0, 0);
|
||||
private ctx: OffscreenCanvasRenderingContext2D | null = null;
|
||||
private zoom = 1;
|
||||
public readonly registry = new BlockPainterRegistry();
|
||||
|
||||
setSize(layoutRectW: number, layoutRectH: number, dpr: number, zoom: number) {
|
||||
const width = layoutRectW * dpr * zoom;
|
||||
const height = layoutRectH * dpr * zoom;
|
||||
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
this.ctx.scale(dpr, dpr);
|
||||
this.zoom = zoom;
|
||||
this.clearBackground();
|
||||
}
|
||||
|
||||
private clearBackground() {
|
||||
if (!this.canvas || !this.ctx) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
paint(layout: ViewportLayout, version: number) {
|
||||
const { canvas, ctx } = this;
|
||||
if (!canvas || !ctx) return;
|
||||
if (layout.rect.w === 0 || layout.rect.h === 0) {
|
||||
console.warn('empty layout rect');
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBackground();
|
||||
|
||||
ctx.scale(this.zoom, this.zoom);
|
||||
|
||||
layout.blocks.forEach(blockLayout => {
|
||||
const painter = this.registry.getPainter(blockLayout.type);
|
||||
if (!painter) return;
|
||||
painter.paint(ctx, blockLayout, layout.rect.x, layout.rect.y);
|
||||
});
|
||||
|
||||
const bitmap = canvas.transferToImageBitmap();
|
||||
const message: WorkerToHostMessage = {
|
||||
type: 'bitmapPainted',
|
||||
bitmap,
|
||||
version,
|
||||
};
|
||||
self.postMessage(message, { transfer: [bitmap] });
|
||||
}
|
||||
}
|
||||
|
||||
const painter = new ViewportLayoutPainter();
|
||||
|
||||
self.onmessage = async (e: MessageEvent<HostToWorkerMessage>) => {
|
||||
const { type, data } = e.data;
|
||||
|
||||
switch (type) {
|
||||
case 'paintLayout': {
|
||||
const { layout, width, height, dpr, zoom, version } = data;
|
||||
painter.setSize(width, height, dpr, zoom);
|
||||
painter.paint(layout, version);
|
||||
break;
|
||||
}
|
||||
case 'registerPainter': {
|
||||
const { painterConfigs } = data;
|
||||
painterConfigs.forEach(async ({ type, path }) => {
|
||||
painter.registry.register(type, new (await import(path)).default());
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,19 +1,14 @@
|
||||
import type { EditorHost, GfxBlockComponent } from '@blocksuite/block-std';
|
||||
import {
|
||||
clientToModelCoord,
|
||||
GfxBlockElementModel,
|
||||
GfxControllerIdentifier,
|
||||
type Viewport,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { Pane } from 'tweakpane';
|
||||
|
||||
import { getSentenceRects, segmentSentences } from './text-utils.js';
|
||||
import type { ViewportTurboRendererExtension } from './turbo-renderer.js';
|
||||
import type {
|
||||
ParagraphLayout,
|
||||
RenderingState,
|
||||
ViewportLayout,
|
||||
} from './types.js';
|
||||
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
|
||||
import type { ViewportTurboRendererExtension } from './turbo-renderer';
|
||||
import type { BlockLayout, RenderingState, ViewportLayout } from './types';
|
||||
|
||||
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
@@ -28,58 +23,27 @@ export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
|
||||
canvas.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
function getParagraphs(host: EditorHost) {
|
||||
function getBlockLayouts(host: EditorHost): BlockLayout[] {
|
||||
const gfx = host.std.get(GfxControllerIdentifier);
|
||||
const models = gfx.gfxElements.filter(e => e instanceof GfxBlockElementModel);
|
||||
const components = models
|
||||
.map(model => gfx.view.get(model.id))
|
||||
.filter(Boolean) as GfxBlockComponent[];
|
||||
|
||||
const paragraphs: ParagraphLayout[] = [];
|
||||
const selector = '.affine-paragraph-rich-text-wrapper [data-v-text="true"]';
|
||||
|
||||
const layouts: BlockLayout[] = [];
|
||||
components.forEach(component => {
|
||||
const paragraphNodes = component.querySelectorAll(selector);
|
||||
const viewportRecord = component.gfx.viewport.deserializeRecord(
|
||||
component.dataset.viewportState
|
||||
const layoutHandlers = host.std.provider.getAll(
|
||||
BlockLayoutHandlersIdentifier
|
||||
);
|
||||
if (!viewportRecord) return;
|
||||
const { zoom, viewScale } = viewportRecord;
|
||||
|
||||
paragraphNodes.forEach(paragraphNode => {
|
||||
const paragraph: ParagraphLayout = {
|
||||
sentences: [],
|
||||
};
|
||||
const sentences = segmentSentences(paragraphNode.textContent || '');
|
||||
paragraph.sentences = 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,
|
||||
rect: {
|
||||
x: modelX,
|
||||
y: modelY,
|
||||
w: rect.w / zoom / viewScale,
|
||||
h: rect.h / zoom / viewScale,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
text: sentence,
|
||||
rects,
|
||||
};
|
||||
});
|
||||
|
||||
paragraphs.push(paragraph);
|
||||
});
|
||||
const handlersArray = Array.from(layoutHandlers.values());
|
||||
for (const handler of handlersArray) {
|
||||
const layout = handler.queryLayout(component);
|
||||
if (layout) {
|
||||
layouts.push(layout);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paragraphs;
|
||||
return layouts;
|
||||
}
|
||||
|
||||
export function getViewportLayout(
|
||||
@@ -93,23 +57,28 @@ export function getViewportLayout(
|
||||
let layoutMaxX = -Infinity;
|
||||
let layoutMaxY = -Infinity;
|
||||
|
||||
const paragraphs = getParagraphs(host);
|
||||
paragraphs.forEach(paragraph => {
|
||||
paragraph.sentences.forEach(sentence => {
|
||||
sentence.rects.forEach(r => {
|
||||
layoutMinX = Math.min(layoutMinX, r.rect.x);
|
||||
layoutMinY = Math.min(layoutMinY, r.rect.y);
|
||||
layoutMaxX = Math.max(layoutMaxX, r.rect.x + r.rect.w);
|
||||
layoutMaxY = Math.max(layoutMaxY, r.rect.y + r.rect.h);
|
||||
});
|
||||
});
|
||||
const blockLayouts = getBlockLayouts(host);
|
||||
|
||||
const providers = host.std.provider.getAll(BlockLayoutHandlersIdentifier);
|
||||
const providersArray = Array.from(providers.values());
|
||||
|
||||
blockLayouts.forEach(blockLayout => {
|
||||
const provider = providersArray.find(p => p.blockType === blockLayout.type);
|
||||
if (!provider) return;
|
||||
|
||||
const { rect } = provider.calculateBound(blockLayout);
|
||||
|
||||
layoutMinX = Math.min(layoutMinX, rect.x);
|
||||
layoutMinY = Math.min(layoutMinY, rect.y);
|
||||
layoutMaxX = Math.max(layoutMaxX, rect.x + rect.w);
|
||||
layoutMaxY = Math.max(layoutMaxY, rect.y + rect.h);
|
||||
});
|
||||
|
||||
const layoutModelCoord = [layoutMinX, layoutMinY];
|
||||
const w = (layoutMaxX - layoutMinX) / zoom / viewport.viewScale;
|
||||
const h = (layoutMaxY - layoutMinY) / zoom / viewport.viewScale;
|
||||
const layout: ViewportLayout = {
|
||||
paragraphs,
|
||||
blocks: blockLayouts,
|
||||
rect: {
|
||||
x: layoutModelCoord[0],
|
||||
y: layoutModelCoord[1],
|
||||
@@ -145,6 +114,7 @@ export function debugLog(message: string, state: RenderingState) {
|
||||
}
|
||||
|
||||
export function paintPlaceholder(
|
||||
host: EditorHost,
|
||||
canvas: HTMLCanvasElement,
|
||||
layout: ViewportLayout | null,
|
||||
viewport: Viewport
|
||||
@@ -163,30 +133,35 @@ export function paintPlaceholder(
|
||||
'rgba(160, 160, 160, 0.7)',
|
||||
];
|
||||
|
||||
layout.paragraphs.forEach((paragraph, paragraphIndex) => {
|
||||
ctx.fillStyle = colors[paragraphIndex % colors.length];
|
||||
const layoutHandlers = host.std.provider.getAll(
|
||||
BlockLayoutHandlersIdentifier
|
||||
);
|
||||
const handlersArray = Array.from(layoutHandlers.values());
|
||||
|
||||
layout.blocks.forEach((blockLayout, blockIndex) => {
|
||||
ctx.fillStyle = colors[blockIndex % colors.length];
|
||||
const renderedPositions = new Set<string>();
|
||||
|
||||
paragraph.sentences.forEach(sentence => {
|
||||
sentence.rects.forEach(textRect => {
|
||||
const x =
|
||||
((textRect.rect.x - layout.rect.x) * viewport.zoom + offsetX) * dpr;
|
||||
const y =
|
||||
((textRect.rect.y - layout.rect.y) * viewport.zoom + offsetY) * dpr;
|
||||
dpr;
|
||||
const width = textRect.rect.w * viewport.zoom * dpr;
|
||||
const height = textRect.rect.h * viewport.zoom * dpr;
|
||||
const handler = handlersArray.find(h => h.blockType === blockLayout.type);
|
||||
if (!handler) return;
|
||||
const { subRects } = handler.calculateBound(blockLayout);
|
||||
|
||||
const posKey = `${x},${y}`;
|
||||
if (renderedPositions.has(posKey)) return;
|
||||
ctx.fillRect(x, y, width, height);
|
||||
if (width > 10 && height > 5) {
|
||||
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
subRects.forEach(rect => {
|
||||
const x = ((rect.x - layout.rect.x) * viewport.zoom + offsetX) * dpr;
|
||||
const y = ((rect.y - layout.rect.y) * viewport.zoom + offsetY) * dpr;
|
||||
|
||||
renderedPositions.add(posKey);
|
||||
});
|
||||
const width = rect.w * viewport.zoom * dpr;
|
||||
const height = rect.h * viewport.zoom * dpr;
|
||||
|
||||
const posKey = `${x},${y}`;
|
||||
if (renderedPositions.has(posKey)) return;
|
||||
ctx.fillRect(x, y, width, height);
|
||||
if (width > 10 && height > 5) {
|
||||
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
|
||||
renderedPositions.add(posKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TextRect } from './types.js';
|
||||
import type { TextRect } from './types';
|
||||
|
||||
interface WordSegment {
|
||||
text: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ConfigExtensionFactory,
|
||||
LifeCycleWatcher,
|
||||
LifeCycleWatcherIdentifier,
|
||||
StdIdentifier,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type GfxViewportElement,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { Container, ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
@@ -17,13 +19,31 @@ import {
|
||||
initTweakpane,
|
||||
paintPlaceholder,
|
||||
syncCanvasSize,
|
||||
} from './renderer-utils.js';
|
||||
import type { RenderingState, ViewportLayout } from './types.js';
|
||||
} from './renderer-utils';
|
||||
import type {
|
||||
BlockPainterConfig,
|
||||
MessagePaint,
|
||||
MessageRegisterPainter,
|
||||
RendererOptions,
|
||||
RenderingState,
|
||||
TurboRendererConfig,
|
||||
ViewportLayout,
|
||||
WorkerToHostMessage,
|
||||
} from './types';
|
||||
|
||||
const debug = false; // Toggle for debug logs
|
||||
const zoomThreshold = 1; // With high enough zoom, fallback to DOM rendering
|
||||
const debounceTime = 1000; // During this period, fallback to DOM
|
||||
const workerUrl = new URL('./painter.worker.ts', import.meta.url);
|
||||
const workerUrl = new URL('./painter/painter.worker.ts', import.meta.url);
|
||||
|
||||
const defaultOptions: RendererOptions = {
|
||||
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
|
||||
debounceTime: 1000, // During this period, fallback to DOM
|
||||
};
|
||||
|
||||
export const BlockPainterConfigIdentifier =
|
||||
createIdentifier<BlockPainterConfig>('block-painter-config');
|
||||
|
||||
export const TurboRendererConfigFactory =
|
||||
ConfigExtensionFactory<TurboRendererConfig>('viewport-turbo-renderer');
|
||||
|
||||
export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
public state: RenderingState = 'inactive';
|
||||
@@ -39,6 +59,22 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
di.addImpl(ViewportTurboRendererIdentifier, this, [StdIdentifier]);
|
||||
}
|
||||
|
||||
get options(): RendererOptions {
|
||||
const id = TurboRendererConfigFactory.identifier;
|
||||
const { options } = this.std.getOptional(id) || {};
|
||||
return {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
get painterConfigs() {
|
||||
const painterConfigMap = this.std.provider.getAll(
|
||||
BlockPainterConfigIdentifier
|
||||
);
|
||||
return Array.from(painterConfigMap.values());
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
const mountPoint = document.querySelector('.affine-edgeless-viewport');
|
||||
if (mountPoint) {
|
||||
@@ -80,6 +116,13 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
this.disposables.add(
|
||||
this.std.store.slots.blockUpdated.subscribe(() => this.invalidate())
|
||||
);
|
||||
|
||||
const painterConfigs = this.painterConfigs;
|
||||
const message: MessageRegisterPainter = {
|
||||
type: 'registerPainter',
|
||||
data: { painterConfigs },
|
||||
};
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
@@ -116,7 +159,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
|
||||
this.clearCanvas();
|
||||
// -> pending
|
||||
if (this.viewport.zoom > zoomThreshold) {
|
||||
if (this.viewport.zoom > this.options.zoomThreshold) {
|
||||
this.debugLog('Zoom above threshold, falling back to DOM rendering');
|
||||
this.setState('pending');
|
||||
this.clearOptimizedBlocks();
|
||||
@@ -146,7 +189,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
|
||||
debouncedRefresh = debounce(() => {
|
||||
this.refresh().catch(console.error);
|
||||
}, debounceTime);
|
||||
}, this.options.debounceTime);
|
||||
|
||||
invalidate() {
|
||||
this.layoutVersion++;
|
||||
@@ -179,7 +222,7 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
const currentVersion = this.layoutVersion;
|
||||
|
||||
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
||||
this.worker.postMessage({
|
||||
const message: MessagePaint = {
|
||||
type: 'paintLayout',
|
||||
data: {
|
||||
layout,
|
||||
@@ -189,9 +232,10 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
zoom: this.viewport.zoom,
|
||||
version: currentVersion,
|
||||
},
|
||||
});
|
||||
};
|
||||
this.worker.postMessage(message);
|
||||
|
||||
this.worker.onmessage = (e: MessageEvent) => {
|
||||
this.worker.onmessage = (e: MessageEvent<WorkerToHostMessage>) => {
|
||||
if (e.data.type === 'bitmapPainted') {
|
||||
if (e.data.version === this.layoutVersion) {
|
||||
this.debugLog(
|
||||
@@ -209,6 +253,12 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
this.setState('pending');
|
||||
resolve();
|
||||
}
|
||||
} else if (e.data.type === 'paintError') {
|
||||
this.debugLog(
|
||||
`Paint error: ${e.data.error} for blockType: ${e.data.blockType}`
|
||||
);
|
||||
this.setState('pending');
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -267,7 +317,8 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
}
|
||||
|
||||
private canOptimize(): boolean {
|
||||
const isBelowZoomThreshold = this.viewport.zoom <= zoomThreshold;
|
||||
const isBelowZoomThreshold =
|
||||
this.viewport.zoom <= this.options.zoomThreshold;
|
||||
return (
|
||||
(this.state === 'ready' || this.state === 'zooming') &&
|
||||
isBelowZoomThreshold
|
||||
@@ -297,7 +348,12 @@ export class ViewportTurboRendererExtension extends LifeCycleWatcher {
|
||||
}
|
||||
|
||||
private paintPlaceholder() {
|
||||
paintPlaceholder(this.canvas, this.layoutCache, this.viewport);
|
||||
paintPlaceholder(
|
||||
this.std.host,
|
||||
this.canvas,
|
||||
this.layoutCache,
|
||||
this.viewport
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,17 +13,13 @@ export interface ViewportState {
|
||||
viewportY: number;
|
||||
}
|
||||
|
||||
export interface SentenceLayout {
|
||||
text: string;
|
||||
rects: TextRect[];
|
||||
}
|
||||
|
||||
export interface ParagraphLayout {
|
||||
sentences: SentenceLayout[];
|
||||
export interface BlockLayout extends Record<string, unknown> {
|
||||
type: string;
|
||||
rect?: Rect;
|
||||
}
|
||||
|
||||
export interface ViewportLayout {
|
||||
paragraphs: ParagraphLayout[];
|
||||
blocks: BlockLayout[];
|
||||
rect: Rect;
|
||||
}
|
||||
|
||||
@@ -46,3 +42,61 @@ export type RenderingState =
|
||||
| 'zooming'
|
||||
| 'rendering'
|
||||
| 'ready';
|
||||
|
||||
export type MessageBitmapPainted = {
|
||||
type: 'bitmapPainted';
|
||||
bitmap: ImageBitmap;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type MessagePaintError = {
|
||||
type: 'paintError';
|
||||
error: string;
|
||||
blockType: string;
|
||||
};
|
||||
|
||||
export type WorkerToHostMessage = MessageBitmapPainted | MessagePaintError;
|
||||
|
||||
export type MessagePaint = {
|
||||
type: 'paintLayout';
|
||||
data: {
|
||||
layout: ViewportLayout;
|
||||
width: number;
|
||||
height: number;
|
||||
dpr: number;
|
||||
zoom: number;
|
||||
version: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface BlockLayoutPainter {
|
||||
paint(
|
||||
ctx: OffscreenCanvasRenderingContext2D,
|
||||
block: BlockLayout,
|
||||
layoutBaseX: number,
|
||||
layoutBaseY: number
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
zoomThreshold: number;
|
||||
debounceTime: number;
|
||||
}
|
||||
|
||||
export interface BlockPainterConfig {
|
||||
type: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TurboRendererConfig {
|
||||
options?: Partial<RendererOptions>;
|
||||
}
|
||||
|
||||
export type MessageRegisterPainter = {
|
||||
type: 'registerPainter';
|
||||
data: {
|
||||
painterConfigs: BlockPainterConfig[];
|
||||
};
|
||||
};
|
||||
|
||||
export type HostToWorkerMessage = MessagePaint | MessageRegisterPainter;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {
|
||||
ParagraphLayoutHandlerExtension,
|
||||
ParagraphPaintConfigExtension,
|
||||
} from '@blocksuite/affine/blocks/paragraph';
|
||||
import {
|
||||
TurboRendererConfigFactory,
|
||||
ViewportTurboRendererExtension,
|
||||
ViewportTurboRendererIdentifier,
|
||||
} from '@blocksuite/affine/gfx/turbo-renderer';
|
||||
@@ -7,7 +12,17 @@ import { addSampleNotes } from './doc-generator.js';
|
||||
import { setupEditor } from './setup.js';
|
||||
|
||||
async function init() {
|
||||
setupEditor('edgeless', [ViewportTurboRendererExtension]);
|
||||
setupEditor('edgeless', [
|
||||
ParagraphLayoutHandlerExtension,
|
||||
ParagraphPaintConfigExtension,
|
||||
TurboRendererConfigFactory({
|
||||
options: {
|
||||
zoomThreshold: 1,
|
||||
debounceTime: 1000,
|
||||
},
|
||||
}),
|
||||
ViewportTurboRendererExtension,
|
||||
]);
|
||||
addSampleNotes(doc, 100);
|
||||
doc.load();
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { MemberSearchService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import track from '@affine/track';
|
||||
import type { DocTitle } from '@blocksuite/affine/fragments/doc-title';
|
||||
import { ViewportTurboRendererExtension } from '@blocksuite/affine/gfx/turbo-renderer';
|
||||
import type { DocMode } from '@blocksuite/affine/model';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import {
|
||||
@@ -69,6 +68,7 @@ import {
|
||||
type ReferenceReactRenderer,
|
||||
} from '../extensions/reference-renderer';
|
||||
import { patchSideBarService } from '../extensions/side-bar-service';
|
||||
import { patchTurboRendererExtension } from '../extensions/turbo-renderer';
|
||||
import { patchUserExtensions } from '../extensions/user';
|
||||
import { patchUserListExtensions } from '../extensions/user-list';
|
||||
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
|
||||
@@ -169,7 +169,7 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
]
|
||||
: [],
|
||||
mode === 'edgeless' && enableTurboRenderer
|
||||
? [ViewportTurboRendererExtension]
|
||||
? patchTurboRendererExtension()
|
||||
: [],
|
||||
].flat()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
ParagraphLayoutHandlerExtension,
|
||||
ParagraphPaintConfigExtension,
|
||||
} from '@blocksuite/affine/blocks/paragraph';
|
||||
import {
|
||||
TurboRendererConfigFactory,
|
||||
ViewportTurboRendererExtension,
|
||||
} from '@blocksuite/affine/gfx/turbo-renderer';
|
||||
|
||||
export function patchTurboRendererExtension() {
|
||||
return [
|
||||
ParagraphLayoutHandlerExtension,
|
||||
ParagraphPaintConfigExtension,
|
||||
TurboRendererConfigFactory({
|
||||
options: {
|
||||
zoomThreshold: 1,
|
||||
debounceTime: 1000,
|
||||
},
|
||||
}),
|
||||
ViewportTurboRendererExtension,
|
||||
];
|
||||
}
|
||||
@@ -2593,6 +2593,7 @@ __metadata:
|
||||
resolution: "@blocksuite/affine-block-paragraph@workspace:blocksuite/affine/blocks/block-paragraph"
|
||||
dependencies:
|
||||
"@blocksuite/affine-components": "workspace:*"
|
||||
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*"
|
||||
"@blocksuite/affine-model": "workspace:*"
|
||||
"@blocksuite/affine-rich-text": "workspace:*"
|
||||
"@blocksuite/affine-shared": "workspace:*"
|
||||
@@ -2900,6 +2901,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@blocksuite/block-std": "workspace:*"
|
||||
"@blocksuite/global": "workspace:*"
|
||||
"@blocksuite/store": "workspace:*"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
rxjs: "npm:^7.8.1"
|
||||
|
||||
Reference in New Issue
Block a user