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

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
{
"name": "@blocksuite/affine-gfx-turbo-renderer",
"description": "Turbo renderer for viewport in BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"rxjs": "^7.8.2"
},
"exports": {
".": "./src/index.ts",
"./painter": "./src/painter/painter.worker.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View 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';

View File

@@ -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'
);

View File

@@ -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;
}

View 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));
}

View 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
);
}

View 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'
);

View 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'];
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}