snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
5
blocksuite/affine/gfx/turbo-renderer/src/index.ts
Normal file
5
blocksuite/affine/gfx/turbo-renderer/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './layout/block-layout-provider';
|
||||
export * from './painter/painter.worker';
|
||||
export * from './text-utils';
|
||||
export * from './turbo-renderer';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
import type { ViewportRecord } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
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(
|
||||
model: BlockModel,
|
||||
host: EditorHost,
|
||||
viewportRecord: ViewportRecord
|
||||
): T | null;
|
||||
|
||||
abstract calculateBound(layout: T): {
|
||||
rect: Rect;
|
||||
subRects: Rect[];
|
||||
};
|
||||
}
|
||||
|
||||
export const BlockLayoutHandlersIdentifier =
|
||||
createIdentifier<BlockLayoutHandlerExtension>(
|
||||
'BlockLayoutHandlersIdentifier'
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Container,
|
||||
createIdentifier,
|
||||
type ServiceProvider,
|
||||
} from '@blocksuite/global/di';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import type {
|
||||
BlockLayoutPainter,
|
||||
BlockLayoutTreeNode,
|
||||
HostToWorkerMessage,
|
||||
ViewportLayoutTree,
|
||||
WorkerToHostMessage,
|
||||
} from '../types';
|
||||
|
||||
export const BlockPainterProvider = createIdentifier<BlockLayoutPainter>(
|
||||
'block-painter-provider'
|
||||
);
|
||||
|
||||
export const BlockLayoutPainterExtension = (
|
||||
type: string,
|
||||
painter: new () => BlockLayoutPainter
|
||||
): ExtensionType => {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(BlockPainterProvider(type), painter);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export class ViewportLayoutPainter {
|
||||
private readonly canvas: OffscreenCanvas = new OffscreenCanvas(0, 0);
|
||||
private ctx: OffscreenCanvasRenderingContext2D | null = null;
|
||||
private zoom = 1;
|
||||
public provider: ServiceProvider;
|
||||
|
||||
getPainter(type: string): BlockLayoutPainter | null {
|
||||
return this.provider.getOptional(BlockPainterProvider(type));
|
||||
}
|
||||
|
||||
constructor(extensions: ExtensionType[]) {
|
||||
const container = new Container();
|
||||
|
||||
extensions.forEach(extension => {
|
||||
extension.setup(container);
|
||||
});
|
||||
|
||||
this.provider = container.provider();
|
||||
|
||||
self.onmessage = this.handler;
|
||||
}
|
||||
|
||||
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: ViewportLayoutTree, version: number) {
|
||||
const { canvas, ctx } = this;
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
this.paintTree(layout, version);
|
||||
}
|
||||
|
||||
paintTree(layout: ViewportLayoutTree, version: number) {
|
||||
const { canvas, ctx } = this;
|
||||
const { overallRect } = layout;
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
this.clearBackground();
|
||||
ctx.scale(this.zoom, this.zoom);
|
||||
|
||||
const paintNode = (node: BlockLayoutTreeNode) => {
|
||||
const painter = this.getPainter(node.type);
|
||||
painter?.paint(ctx, node.layout, overallRect.x, overallRect.y);
|
||||
node.children.forEach(paintNode);
|
||||
};
|
||||
|
||||
layout.roots.forEach(root => paintNode(root));
|
||||
const bitmap = canvas.transferToImageBitmap();
|
||||
const message: WorkerToHostMessage = {
|
||||
type: 'bitmapPainted',
|
||||
bitmap,
|
||||
version,
|
||||
};
|
||||
self.postMessage(message, { transfer: [bitmap] });
|
||||
}
|
||||
|
||||
handler = async (e: MessageEvent<HostToWorkerMessage>) => {
|
||||
const { type, data } = e.data;
|
||||
switch (type) {
|
||||
case 'paintLayout': {
|
||||
const { layout, width, height, dpr, zoom, version } = data;
|
||||
this.setSize(width, height, dpr, zoom);
|
||||
this.paint(layout, version);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const meta = {
|
||||
emSize: 2048,
|
||||
hHeadAscent: 1984,
|
||||
hHeadDescent: -494,
|
||||
};
|
||||
|
||||
export function getBaseline(fontSize: number) {
|
||||
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;
|
||||
}
|
||||
231
blocksuite/affine/gfx/turbo-renderer/src/renderer-utils.ts
Normal file
231
blocksuite/affine/gfx/turbo-renderer/src/renderer-utils.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
getAffinePlaceholderFillColor,
|
||||
getAffinePlaceholderStrokeColor,
|
||||
inferColorSchemeFromThemeMode,
|
||||
} from '@blocksuite/affine-shared/theme';
|
||||
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
|
||||
import { getEffectiveDpr, type Viewport } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
|
||||
import type {
|
||||
BlockLayout,
|
||||
BlockLayoutTreeNode,
|
||||
RenderingState,
|
||||
ViewportLayoutTree,
|
||||
} from './types';
|
||||
|
||||
export function syncCanvasSize(
|
||||
canvas: HTMLCanvasElement,
|
||||
host: HTMLElement,
|
||||
zoom = 1
|
||||
) {
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const dpr = getEffectiveDpr(zoom);
|
||||
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 getViewportLayoutTree(
|
||||
host: EditorHost,
|
||||
viewport: Viewport
|
||||
): ViewportLayoutTree {
|
||||
const zoom = viewport.zoom;
|
||||
|
||||
let layoutMinX = Infinity;
|
||||
let layoutMinY = Infinity;
|
||||
let layoutMaxX = -Infinity;
|
||||
let layoutMaxY = -Infinity;
|
||||
|
||||
const store = host.std.store;
|
||||
const rootModel = store.root;
|
||||
|
||||
if (!rootModel) {
|
||||
return { roots: [], overallRect: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
|
||||
const providers = host.std.provider.getAll(BlockLayoutHandlersIdentifier);
|
||||
const providersArray = Array.from(providers.values());
|
||||
|
||||
// Recursive function to build the tree structure
|
||||
const buildLayoutTreeNode = (
|
||||
model: BlockModel,
|
||||
ancestorViewportState?: string | null,
|
||||
root = false
|
||||
): BlockLayoutTreeNode | null => {
|
||||
const baseLayout: BlockLayout = {
|
||||
blockId: model.id,
|
||||
type: model.flavour,
|
||||
rect: { x: 0, y: 0, w: 0, h: 0 },
|
||||
};
|
||||
|
||||
const handler = providersArray.find(p => p.blockType === model.flavour);
|
||||
|
||||
// Determine the correct viewport state to use
|
||||
const component = host.std.view.getBlock(
|
||||
model.id
|
||||
) as GfxBlockComponent | null;
|
||||
const currentViewportState = component?.dataset.viewportState;
|
||||
const effectiveViewportState =
|
||||
currentViewportState ?? ancestorViewportState;
|
||||
const defaultViewportState = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
viewportX: 0,
|
||||
viewportY: 0,
|
||||
zoom: 1,
|
||||
viewScale: 1,
|
||||
};
|
||||
|
||||
const viewportRecord = effectiveViewportState
|
||||
? viewport.deserializeRecord(effectiveViewportState) ||
|
||||
defaultViewportState
|
||||
: defaultViewportState;
|
||||
|
||||
const layoutData = handler?.queryLayout(model, host, viewportRecord);
|
||||
let layout: BlockLayout = baseLayout;
|
||||
|
||||
if (handler && layoutData) {
|
||||
const { rect: calculatedRect } = handler.calculateBound(layoutData);
|
||||
layout = {
|
||||
...layoutData,
|
||||
...baseLayout,
|
||||
rect: calculatedRect,
|
||||
};
|
||||
layoutMinX = Math.min(layoutMinX, calculatedRect.x);
|
||||
layoutMinY = Math.min(layoutMinY, calculatedRect.y);
|
||||
layoutMaxX = Math.max(layoutMaxX, calculatedRect.x + calculatedRect.w);
|
||||
layoutMaxY = Math.max(layoutMaxY, calculatedRect.y + calculatedRect.h);
|
||||
} else if (component && !root) {
|
||||
const clientRect = component.getBoundingClientRect();
|
||||
const [modelX, modelY] = viewport.toModelCoordFromClientCoord([
|
||||
clientRect.x,
|
||||
clientRect.y,
|
||||
]);
|
||||
|
||||
const rect = {
|
||||
x: modelX,
|
||||
y: modelY,
|
||||
w: clientRect.width / zoom / viewport.viewScale,
|
||||
h: clientRect.height / zoom / viewport.viewScale,
|
||||
};
|
||||
|
||||
layout = {
|
||||
...baseLayout,
|
||||
rect,
|
||||
};
|
||||
|
||||
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);
|
||||
} else {
|
||||
layoutMinX = Math.min(layoutMinX, baseLayout.rect.x);
|
||||
layoutMinY = Math.min(layoutMinY, baseLayout.rect.y);
|
||||
layoutMaxX = Math.max(layoutMaxX, baseLayout.rect.x + baseLayout.rect.w);
|
||||
layoutMaxY = Math.max(layoutMaxY, baseLayout.rect.y + baseLayout.rect.h);
|
||||
}
|
||||
|
||||
const children: BlockLayoutTreeNode[] = [];
|
||||
for (const childModel of model.children) {
|
||||
const childNode = buildLayoutTreeNode(childModel, effectiveViewportState);
|
||||
if (childNode) {
|
||||
children.push(childNode);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blockId: model.id,
|
||||
type: model.flavour,
|
||||
layout,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
const roots: BlockLayoutTreeNode[] = [];
|
||||
const rootNode = buildLayoutTreeNode(rootModel, null, true);
|
||||
if (rootNode) {
|
||||
roots.push(rootNode);
|
||||
}
|
||||
|
||||
// If no valid layouts were found, use default values
|
||||
if (layoutMinX === Infinity) {
|
||||
layoutMinX = 0;
|
||||
layoutMinY = 0;
|
||||
layoutMaxX = 0;
|
||||
layoutMaxY = 0;
|
||||
}
|
||||
|
||||
// Calculate overall rectangle
|
||||
const w = (layoutMaxX - layoutMinX) / zoom / viewport.viewScale;
|
||||
const h = (layoutMaxY - layoutMinY) / zoom / viewport.viewScale;
|
||||
|
||||
const result = {
|
||||
roots,
|
||||
overallRect: {
|
||||
x: layoutMinX,
|
||||
y: layoutMinY,
|
||||
w: Math.max(w, 0),
|
||||
h: Math.max(h, 0),
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function debugLog(message: string, state: RenderingState) {
|
||||
console.log(
|
||||
`%c[ViewportTurboRenderer]%c ${message} | state=${state}`,
|
||||
'color: #4285f4; font-weight: bold;',
|
||||
'color: inherit;'
|
||||
);
|
||||
}
|
||||
|
||||
export function paintPlaceholder(
|
||||
canvas: HTMLCanvasElement,
|
||||
layout: ViewportLayoutTree | null,
|
||||
viewport: Viewport
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || !layout) return;
|
||||
|
||||
const dpr = getEffectiveDpr(viewport.zoom);
|
||||
const { overallRect } = layout;
|
||||
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
|
||||
|
||||
const offsetX = layoutViewCoord[0];
|
||||
const offsetY = layoutViewCoord[1];
|
||||
const colorScheme = inferColorSchemeFromThemeMode(
|
||||
document.documentElement.dataset.theme
|
||||
);
|
||||
const fillColor = getAffinePlaceholderFillColor(colorScheme);
|
||||
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
|
||||
|
||||
const paintNode = (node: BlockLayoutTreeNode) => {
|
||||
const { layout: nodeLayout } = node;
|
||||
ctx.fillStyle = fillColor;
|
||||
const rect = nodeLayout.rect;
|
||||
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
|
||||
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
|
||||
const width = rect.w * viewport.zoom * dpr;
|
||||
const height = rect.h * viewport.zoom * dpr;
|
||||
|
||||
ctx.fillRect(x, y, width, height);
|
||||
if (width > 10 && height > 5) {
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
node.children.forEach(childNode => paintNode(childNode));
|
||||
}
|
||||
};
|
||||
|
||||
layout.roots.forEach(rootNode => paintNode(rootNode));
|
||||
}
|
||||
145
blocksuite/affine/gfx/turbo-renderer/src/text-utils.ts
Normal file
145
blocksuite/affine/gfx/turbo-renderer/src/text-utils.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { TextRect } from './types';
|
||||
|
||||
interface WordSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const CJK_REGEX = /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]/u;
|
||||
|
||||
const sentenceSegmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'sentence',
|
||||
});
|
||||
const wordSegmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'word',
|
||||
});
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'grapheme',
|
||||
});
|
||||
|
||||
function hasCJK(text: string): boolean {
|
||||
return CJK_REGEX.test(text);
|
||||
}
|
||||
|
||||
function getWordSegments(text: string): WordSegment[] {
|
||||
const segmenter = hasCJK(text) ? graphemeSegmenter : wordSegmenter;
|
||||
return Array.from(segmenter.segment(text)).map(({ segment, index }) => ({
|
||||
text: segment,
|
||||
start: index,
|
||||
end: index + segment.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function getRangeRects(range: Range, fullText: string): TextRect[] {
|
||||
const rects = Array.from(range.getClientRects());
|
||||
const textRects: TextRect[] = [];
|
||||
|
||||
if (rects.length === 0) return textRects;
|
||||
|
||||
// If there's only one rect, use the full text
|
||||
if (rects.length === 1) {
|
||||
const rect = rects[0];
|
||||
textRects.push({
|
||||
rect: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
},
|
||||
text: fullText,
|
||||
});
|
||||
return textRects;
|
||||
}
|
||||
|
||||
const segments = getWordSegments(fullText);
|
||||
|
||||
// Calculate the total width and average width per character
|
||||
const totalWidth = Math.floor(
|
||||
rects.reduce((sum, rect) => sum + rect.width, 0)
|
||||
);
|
||||
const charWidthEstimate = totalWidth / fullText.length;
|
||||
|
||||
let currentRect = 0;
|
||||
let currentSegments: WordSegment[] = [];
|
||||
let currentWidth = 0;
|
||||
|
||||
segments.forEach(segment => {
|
||||
const segmentWidth = segment.text.length * charWidthEstimate;
|
||||
if (
|
||||
currentWidth + segmentWidth > rects[currentRect]?.width &&
|
||||
currentSegments.length > 0
|
||||
) {
|
||||
const rect = rects[currentRect];
|
||||
textRects.push({
|
||||
rect: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
},
|
||||
text: currentSegments.map(seg => seg.text).join(''),
|
||||
});
|
||||
|
||||
currentRect++;
|
||||
currentSegments = [segment];
|
||||
currentWidth = segmentWidth;
|
||||
} else {
|
||||
currentSegments.push(segment);
|
||||
currentWidth += segmentWidth;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle remaining segments if any
|
||||
if (currentSegments.length > 0) {
|
||||
const rect = rects[Math.min(currentRect, rects.length - 1)];
|
||||
textRects.push({
|
||||
rect: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
},
|
||||
text: currentSegments.map(seg => seg.text).join(''),
|
||||
});
|
||||
}
|
||||
|
||||
return textRects;
|
||||
}
|
||||
|
||||
export function getSentenceRects(
|
||||
element: Element,
|
||||
sentence: string
|
||||
): TextRect[] {
|
||||
const textNode = Array.from(element.childNodes).find(
|
||||
node => node.nodeType === Node.TEXT_NODE
|
||||
);
|
||||
|
||||
if (!textNode) return [];
|
||||
|
||||
const text = textNode.textContent || '';
|
||||
let rects: TextRect[] = [];
|
||||
let startIndex = 0;
|
||||
|
||||
// Find all occurrences of the sentence and ensure we capture complete words
|
||||
while ((startIndex = text.indexOf(sentence, startIndex)) !== -1) {
|
||||
const range = document.createRange();
|
||||
let endIndex = startIndex + sentence.length;
|
||||
|
||||
range.setStart(textNode, startIndex);
|
||||
range.setEnd(textNode, endIndex);
|
||||
|
||||
rects = rects.concat(
|
||||
getRangeRects(range, text.slice(startIndex, endIndex))
|
||||
);
|
||||
startIndex = endIndex;
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
|
||||
export function segmentSentences(text: string): string[] {
|
||||
return Array.from(sentenceSegmenter.segment(text)).map(
|
||||
({ segment }) => segment
|
||||
);
|
||||
}
|
||||
525
blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts
Normal file
525
blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { IS_IOS } from '@blocksuite/global/env';
|
||||
import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||
import {
|
||||
getEffectiveDpr,
|
||||
type GfxController,
|
||||
GfxExtension,
|
||||
GfxExtensionIdentifier,
|
||||
type GfxViewportElement,
|
||||
viewportRuntimeConfig,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
merge,
|
||||
Subject,
|
||||
take,
|
||||
tap,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
debugLog,
|
||||
getViewportLayoutTree,
|
||||
paintPlaceholder,
|
||||
syncCanvasSize,
|
||||
} from './renderer-utils';
|
||||
import type {
|
||||
MessagePaint,
|
||||
RendererOptions,
|
||||
RenderingState,
|
||||
TurboRendererConfig,
|
||||
ViewportLayoutTree,
|
||||
WorkerToHostMessage,
|
||||
} from './types';
|
||||
|
||||
const debug = false; // Toggle for debug logs
|
||||
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
|
||||
|
||||
export function shouldPreferBitmapCacheDuringLowZoomGesture(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
hasBitmap: boolean;
|
||||
}) {
|
||||
return (
|
||||
params.isIOS &&
|
||||
params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||
params.hasBitmap
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldIdleTurboBlocksDuringZooming(params: {
|
||||
isIOS: boolean;
|
||||
zoom: number;
|
||||
}) {
|
||||
return !(params.isIOS && params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD);
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
|
||||
debounceTime: 1000, // During this period, fallback to DOM
|
||||
enableBitmapRendering: false, // When enabled, the bitmap rendering will be used
|
||||
postZoomDelay: 100,
|
||||
};
|
||||
|
||||
export const TurboRendererConfigFactory =
|
||||
ConfigExtensionFactory<TurboRendererConfig>('viewport-turbo-renderer');
|
||||
|
||||
/**
|
||||
* Manages the Turbo Rendering process for the viewport, coordinating between the main thread and a painter worker.
|
||||
* Turbo Rendering optimizes performance by rendering block content onto a canvas bitmap,
|
||||
* falling back to standard DOM rendering during interactions.
|
||||
*
|
||||
* To add Turbo Rendering support for a new block type (e.g., 'affine:my-block'):
|
||||
*
|
||||
* 1. **In the block's package (e.g., `blocksuite/affine/blocks/my-block`):**
|
||||
* a. Add `@blocksuite/affine/gfx/turbo-renderer` as a dependency in `package.json` and create a `src/turbo` directory.
|
||||
* b. Implement the Layout Handler (e.g., `MyBlockLayoutHandlerExtension`) and Painter Worker (e.g., `MyBlockLayoutPainterExtension`). Refer to `ParagraphLayoutHandlerExtension` and `ParagraphLayoutPainterExtension` in `blocksuite/affine/blocks/block-paragraph` for implementation examples.
|
||||
* c. Export the Layout Handler and Painter Worker extensions from the block package's main `src/index.ts` by adding these two explicit export statements:
|
||||
* ```typescript
|
||||
* export * from './turbo/my-block-layout-handler';
|
||||
* export * from './turbo/my-block-painter.worker';
|
||||
* ```
|
||||
* d. Add an export mapping for the painter worker in `package.json` under the `exports` field (e.g., `"./turbo-painter": "./src/turbo/my-block-painter.worker.ts"`).
|
||||
* e. Add a TypeScript project reference to `blocksuite/affine/gfx/turbo-renderer` in `tsconfig.json`.
|
||||
*
|
||||
* 2. **In the application integration point (e.g., `packages/frontend/core/src/blocksuite/extensions` and `blocksuite/integration-test/src/__tests__/utils/renderer-entry.ts`):**
|
||||
* a. In `turbo-renderer.ts` (or the file setting up `TurboRendererConfigFactory`):
|
||||
* - Import and add the new Layout Handler extension to the `patchTurboRendererExtension` array (or equivalent DI setup). See how `ParagraphLayoutHandlerExtension` is added as a reference.
|
||||
* b. In `turbo-painter.worker.ts` (the painter worker entry point):
|
||||
* - Import and add the new Painter Worker extension to the `ViewportLayoutPainter` constructor's extension array. See how `ParagraphLayoutPainterExtension` is added as a reference.
|
||||
*
|
||||
* 3. **Run `yarn affine init`** from the workspace root to update generated configuration files (`workspace.gen.ts`) and the lockfile (`yarn.lock`).
|
||||
*
|
||||
* **Note:** Always ensure the directory structure and export patterns match the `paragraph` block (`blocksuite/affine/blocks/block-paragraph`) for consistency.
|
||||
*/
|
||||
export class ViewportTurboRendererExtension extends GfxExtension {
|
||||
static override key = 'viewportTurboRenderer';
|
||||
|
||||
public readonly state$ = new BehaviorSubject<RenderingState>('inactive');
|
||||
public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
|
||||
public layoutCacheData: ViewportLayoutTree | null = null;
|
||||
public optimizedBlockIds: string[] = [];
|
||||
private readonly worker: Worker;
|
||||
private readonly disposables = new DisposableGroup();
|
||||
private layoutVersion = 0;
|
||||
private bitmap: ImageBitmap | null = null;
|
||||
private viewportElement: GfxViewportElement | null = null;
|
||||
private readonly refresh$ = new Subject<void>();
|
||||
private readonly isRecentlyZoomed$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
public get currentState(): RenderingState {
|
||||
return this.state$.value;
|
||||
}
|
||||
|
||||
constructor(gfx: GfxController) {
|
||||
super(gfx);
|
||||
|
||||
const id = TurboRendererConfigFactory.identifier;
|
||||
const config = this.std.getOptional(id);
|
||||
if (!config) {
|
||||
throw new Error('TurboRendererConfig not found');
|
||||
}
|
||||
this.worker = config.painterWorkerEntry();
|
||||
|
||||
// Set up state change logging
|
||||
this.state$
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(state => this.debugLog(`State changed to: ${state}`))
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// Set up debounced refresh
|
||||
this.refresh$
|
||||
.pipe(debounceTime(this.options.debounceTime))
|
||||
.subscribe(() => {
|
||||
this.refresh().catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
static override extendGfx(gfx: GfxController) {
|
||||
Object.defineProperty(gfx, 'turboRenderer', {
|
||||
get() {
|
||||
return gfx.std.get(ViewportTurboRendererIdentifier);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
super.setup(di);
|
||||
}
|
||||
|
||||
get options(): RendererOptions {
|
||||
const id = TurboRendererConfigFactory.identifier;
|
||||
const { options } = this.std.getOptional(id) || {};
|
||||
return {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
const mountPoint = document.querySelector('.affine-edgeless-viewport');
|
||||
if (mountPoint) {
|
||||
mountPoint.append(this.canvas);
|
||||
}
|
||||
|
||||
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
|
||||
this.viewportElement = element;
|
||||
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||
this.state$.next('pending');
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.sizeUpdated.subscribe(() => this.handleResize())
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
if (
|
||||
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.refresh().catch(console.error);
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.zooming$
|
||||
.pipe(
|
||||
tap(isZooming => {
|
||||
this.debugLog(`Zooming signal changed: ${isZooming}`);
|
||||
if (isZooming) {
|
||||
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
this.state$.next('zooming');
|
||||
}
|
||||
} else if (this.state$.value === 'zooming') {
|
||||
this.clearOptimizedBlocks();
|
||||
this.isRecentlyZoomed$.next(true);
|
||||
this.disposables.add(
|
||||
timer(defaultOptions.postZoomDelay).subscribe(() => {
|
||||
this.isRecentlyZoomed$.next(false);
|
||||
})
|
||||
);
|
||||
|
||||
this.state$.next('pending');
|
||||
this.refresh().catch(console.error);
|
||||
}
|
||||
})
|
||||
)
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
// Post-gesture refresh for SKIP mode
|
||||
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cancelRefresh = () => {
|
||||
if (pendingTimerId !== null) {
|
||||
clearTimeout(pendingTimerId);
|
||||
pendingTimerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
cancelRefresh();
|
||||
pendingTimerId = setTimeout(() => {
|
||||
pendingTimerId = null;
|
||||
if (
|
||||
!this.viewport.panning$.value &&
|
||||
!this.viewport.zooming$.value
|
||||
) {
|
||||
this.refresh().catch(console.error);
|
||||
}
|
||||
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||
};
|
||||
|
||||
this.disposables.add(
|
||||
this.viewport.panning$.subscribe(panning => {
|
||||
if (panning) cancelRefresh();
|
||||
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
this.viewport.zooming$.subscribe(zooming => {
|
||||
if (zooming) cancelRefresh();
|
||||
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||
})
|
||||
);
|
||||
this.disposables.add({ dispose: cancelRefresh });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle selection and block updates
|
||||
const selectionUpdates$ = this.selection.slots.updated;
|
||||
const blockUpdates$ = this.std.store.slots.blockUpdated;
|
||||
|
||||
// Combine all events that should trigger invalidation
|
||||
this.disposables.add(
|
||||
merge(selectionUpdates$, blockUpdates$).subscribe(() => this.invalidate())
|
||||
);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.debugLog('Unmounting renderer');
|
||||
this.clearBitmap();
|
||||
this.clearOptimizedBlocks();
|
||||
this.worker.terminate();
|
||||
this.canvas.remove();
|
||||
this.disposables.dispose();
|
||||
this.state$.next('inactive');
|
||||
this.state$.complete();
|
||||
this.refresh$.complete();
|
||||
}
|
||||
|
||||
get viewport() {
|
||||
return this.gfx.viewport;
|
||||
}
|
||||
|
||||
get selection() {
|
||||
return this.gfx.selection;
|
||||
}
|
||||
|
||||
get layoutCache() {
|
||||
if (this.layoutCacheData) return this.layoutCacheData;
|
||||
const layoutTree = getViewportLayoutTree(this.std.host, this.viewport);
|
||||
this.debugLog('Layout cache updated');
|
||||
return (this.layoutCacheData = layoutTree);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.state$.value === 'inactive') return;
|
||||
|
||||
this.clearCanvas();
|
||||
|
||||
// Determine the next state based on current conditions
|
||||
let nextState: RenderingState;
|
||||
|
||||
if (this.viewport.zoom > this.options.zoomThreshold) {
|
||||
this.debugLog('Zoom above threshold, falling back to DOM rendering');
|
||||
nextState = 'pending';
|
||||
this.clearOptimizedBlocks();
|
||||
} else if (this.isZooming()) {
|
||||
nextState = 'zooming';
|
||||
if (
|
||||
shouldPreferBitmapCacheDuringLowZoomGesture({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
hasBitmap: !!this.bitmap,
|
||||
})
|
||||
) {
|
||||
this.debugLog('Currently zooming, reusing cached bitmap');
|
||||
this.clearOptimizedBlocks();
|
||||
this.drawCachedBitmap();
|
||||
} else {
|
||||
this.debugLog('Currently zooming, using placeholder rendering');
|
||||
this.paintPlaceholder();
|
||||
this.updateOptimizedBlocks();
|
||||
}
|
||||
} else if (this.canUseBitmapCache()) {
|
||||
this.debugLog('Using cached bitmap');
|
||||
nextState = 'ready';
|
||||
this.drawCachedBitmap();
|
||||
} else {
|
||||
this.debugLog('Starting bitmap rendering');
|
||||
nextState = 'rendering';
|
||||
this.state$.next(nextState);
|
||||
await this.paintLayout();
|
||||
this.drawCachedBitmap();
|
||||
// After rendering completes, transition to ready state
|
||||
nextState = 'ready';
|
||||
}
|
||||
|
||||
this.state$.next(nextState);
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.layoutVersion++;
|
||||
this.layoutCacheData = null;
|
||||
this.clearBitmap();
|
||||
this.clearCanvas();
|
||||
this.clearOptimizedBlocks();
|
||||
this.state$.next('pending');
|
||||
this.debugLog(`Invalidated renderer (layoutVersion=${this.layoutVersion})`);
|
||||
}
|
||||
|
||||
private debugLog(message: string) {
|
||||
if (!debug) return;
|
||||
debugLog(message, this.state$.value);
|
||||
}
|
||||
|
||||
private clearBitmap() {
|
||||
if (!this.bitmap) return;
|
||||
this.bitmap.close();
|
||||
this.bitmap = null;
|
||||
this.debugLog('Bitmap cleared');
|
||||
}
|
||||
|
||||
private async paintLayout(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (!this.worker || !this.options.enableBitmapRendering) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = this.layoutCache;
|
||||
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||
const currentVersion = this.layoutVersion;
|
||||
|
||||
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
||||
const message: MessagePaint = {
|
||||
type: 'paintLayout',
|
||||
data: {
|
||||
layout,
|
||||
width: layout.overallRect.w,
|
||||
height: layout.overallRect.h,
|
||||
dpr,
|
||||
zoom: this.viewport.zoom,
|
||||
version: currentVersion,
|
||||
},
|
||||
};
|
||||
this.worker.postMessage(message);
|
||||
|
||||
this.worker.onmessage = (e: MessageEvent<WorkerToHostMessage>) => {
|
||||
if (e.data.type === 'bitmapPainted') {
|
||||
if (e.data.version === this.layoutVersion) {
|
||||
this.debugLog(
|
||||
`Bitmap painted successfully (version=${e.data.version})`
|
||||
);
|
||||
this.clearBitmap();
|
||||
this.bitmap = e.data.bitmap;
|
||||
this.state$.next('ready');
|
||||
resolve();
|
||||
} else {
|
||||
this.debugLog(
|
||||
`Received outdated bitmap (got=${e.data.version}, current=${this.layoutVersion})`
|
||||
);
|
||||
e.data.bitmap.close();
|
||||
this.state$.next('pending');
|
||||
resolve();
|
||||
}
|
||||
} else if (e.data.type === 'paintError') {
|
||||
this.debugLog(
|
||||
`Paint error: ${e.data.error} for blockType: ${e.data.blockType}`
|
||||
);
|
||||
this.state$.next('pending');
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public canUseBitmapCache(): boolean {
|
||||
if (!this.options.enableBitmapRendering || this.isZooming()) return false;
|
||||
return !!(this.layoutCache && this.bitmap);
|
||||
}
|
||||
|
||||
private isZooming(): boolean {
|
||||
return this.viewport.zooming$.value;
|
||||
}
|
||||
|
||||
private clearCanvas() {
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.debugLog('Canvas cleared');
|
||||
}
|
||||
|
||||
private drawCachedBitmap() {
|
||||
if (!this.options.enableBitmapRendering || !this.bitmap) {
|
||||
this.debugLog(
|
||||
'Bitmap drawing skipped (disabled or no cached bitmap available)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = this.layoutCache;
|
||||
const bitmap = this.bitmap;
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
this.clearCanvas();
|
||||
|
||||
const layoutViewCoord = this.viewport.toViewCoord(
|
||||
layout.overallRect.x,
|
||||
layout.overallRect.y
|
||||
);
|
||||
|
||||
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
layoutViewCoord[0] * dpr,
|
||||
layoutViewCoord[1] * dpr,
|
||||
layout.overallRect.w * dpr * this.viewport.zoom,
|
||||
layout.overallRect.h * dpr * this.viewport.zoom
|
||||
);
|
||||
|
||||
this.debugLog('Bitmap drawn to canvas');
|
||||
}
|
||||
|
||||
private canOptimize(): boolean {
|
||||
if (this.isRecentlyZoomed$.value) return false;
|
||||
|
||||
const isBelowZoomThreshold =
|
||||
this.viewport.zoom <= this.options.zoomThreshold;
|
||||
return this.state$.value === 'zooming' && isBelowZoomThreshold;
|
||||
}
|
||||
|
||||
private updateOptimizedBlocks() {
|
||||
if (!this.canOptimize()) return;
|
||||
if (
|
||||
!shouldIdleTurboBlocksDuringZooming({
|
||||
isIOS: IS_IOS,
|
||||
zoom: this.viewport.zoom,
|
||||
})
|
||||
) {
|
||||
this.clearOptimizedBlocks();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.viewportElement || !this.layoutCache) return;
|
||||
const blockElements = this.viewportElement.getModelsInViewport();
|
||||
const blockIds = Array.from(blockElements).map(model => model.id);
|
||||
|
||||
// Set all previously optimized blocks to active first
|
||||
if (this.optimizedBlockIds.length > 0) {
|
||||
this.viewportElement.setBlocksActive(this.optimizedBlockIds);
|
||||
}
|
||||
// Now set the new blocks to idle (hidden)
|
||||
this.optimizedBlockIds = blockIds;
|
||||
this.viewportElement.setBlocksIdle(blockIds);
|
||||
|
||||
this.debugLog(`Optimized ${blockIds.length} blocks`);
|
||||
});
|
||||
}
|
||||
|
||||
private clearOptimizedBlocks() {
|
||||
if (!this.viewportElement || this.optimizedBlockIds.length === 0) return;
|
||||
|
||||
this.viewportElement.setBlocksActive(this.optimizedBlockIds);
|
||||
this.optimizedBlockIds = [];
|
||||
this.debugLog('Cleared optimized blocks');
|
||||
}
|
||||
|
||||
private handleResize() {
|
||||
this.debugLog('Container resized, syncing canvas size');
|
||||
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||
this.invalidate();
|
||||
this.refresh$.next();
|
||||
}
|
||||
|
||||
private paintPlaceholder() {
|
||||
paintPlaceholder(this.canvas, this.layoutCache, this.viewport);
|
||||
}
|
||||
}
|
||||
|
||||
export const ViewportTurboRendererIdentifier = GfxExtensionIdentifier(
|
||||
'viewportTurboRenderer'
|
||||
);
|
||||
105
blocksuite/affine/gfx/turbo-renderer/src/types.ts
Normal file
105
blocksuite/affine/gfx/turbo-renderer/src/types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// We can't use viewport instance here because it can't be reused in worker
|
||||
export interface ViewportState {
|
||||
zoom: number;
|
||||
viewScale: number;
|
||||
viewportX: number;
|
||||
viewportY: number;
|
||||
}
|
||||
|
||||
export interface BlockLayout extends Record<string, unknown> {
|
||||
blockId: string;
|
||||
type: string;
|
||||
rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TextRect {
|
||||
rect: Rect;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the rendering state of the ViewportTurboRenderer
|
||||
* - inactive: Renderer is not active
|
||||
* - pending: Bitmap is invalid or not yet available, falling back to DOM rendering
|
||||
* - zooming: Zooming in or out, will use fast canvas placeholder rendering
|
||||
* - rendering: Currently rendering to a bitmap (async operation in progress)
|
||||
* - ready: Bitmap is valid and rendered, DOM elements can be safely removed
|
||||
*/
|
||||
export type RenderingState =
|
||||
| 'inactive'
|
||||
| 'pending'
|
||||
| '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: ViewportLayoutTree;
|
||||
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;
|
||||
enableBitmapRendering?: boolean;
|
||||
}
|
||||
|
||||
export interface TurboRendererConfig {
|
||||
options?: Partial<RendererOptions>;
|
||||
painterWorkerEntry: () => Worker;
|
||||
}
|
||||
|
||||
export type HostToWorkerMessage = MessagePaint;
|
||||
|
||||
export interface BlockLayoutTreeNode {
|
||||
blockId: string;
|
||||
type: string;
|
||||
layout: BlockLayout;
|
||||
children: BlockLayoutTreeNode[];
|
||||
}
|
||||
|
||||
export interface ViewportLayoutTree {
|
||||
roots: BlockLayoutTreeNode[];
|
||||
overallRect: BlockLayout['rect'];
|
||||
}
|
||||
Reference in New Issue
Block a user