feat(editor): add canvas worker renderer dev entry (#9719)

This commit is contained in:
Yifeng Wang
2025-01-20 20:40:27 +08:00
committed by GitHub
parent 2ae05c28b7
commit e45ac54709
9 changed files with 643 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
import { type AffineEditorContainer } from '@blocksuite/presets';
import { CanvasRenderer } from './canvas-renderer.js';
import { editor } from './editor.js';
import type { ParagraphLayout } from './types.js';
async function wait(time: number = 100) {
return new Promise(resolve => setTimeout(resolve, time));
}
export class SwitchModeAnimator {
constructor(private readonly editor: AffineEditorContainer) {
this.renderer = new CanvasRenderer(this.editor, this.overlay);
}
renderer: CanvasRenderer;
private readonly overlay = document.createElement('div');
get editorRect() {
return this.editor.getBoundingClientRect();
}
async switchMode() {
this.initOverlay();
const beginLayout = this.renderer.getHostLayout();
await this.renderer.render(false);
document.body.append(this.overlay);
this.editor.mode = this.editor.mode === 'page' ? 'edgeless' : 'page';
await wait();
const endLayout = this.renderer.getHostLayout();
this.overlay.style.display = 'inherit';
await this.animate(
beginLayout.paragraphs,
endLayout.paragraphs,
beginLayout.hostRect,
endLayout.hostRect
);
this.overlay.style.display = 'none';
}
async animate(
beginParagraphs: ParagraphLayout[],
endParagraphs: ParagraphLayout[],
beginHostRect: DOMRect,
endHostRect: DOMRect
): Promise<void> {
return new Promise(resolve => {
const duration = 600;
const startTime = performance.now();
const animate = () => {
const currentTime = performance.now();
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
this.renderer.renderTransitionFrame(
beginParagraphs,
endParagraphs,
beginHostRect,
endHostRect,
progress
);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
resolve();
}
};
requestAnimationFrame(animate);
});
}
initOverlay() {
const { left, top, width, height } = this.editorRect;
this.overlay.style.position = 'fixed';
this.overlay.style.left = left + 'px';
this.overlay.style.top = top + 'px';
this.overlay.style.width = width + 'px';
this.overlay.style.height = height + 'px';
this.overlay.style.backgroundColor = 'white';
this.overlay.style.pointerEvents = 'none';
this.overlay.style.zIndex = '9999';
this.overlay.style.display = 'flex';
this.overlay.style.alignItems = 'flex-end';
}
}
export const animator = new SwitchModeAnimator(editor);

View File

@@ -0,0 +1,209 @@
import type { AffineEditorContainer } from '@blocksuite/presets';
import { getSentenceRects, segmentSentences } from './text-utils.js';
import { type ParagraphLayout } from './types.js';
export class CanvasRenderer {
private readonly worker: Worker;
private readonly editorContainer: AffineEditorContainer;
private readonly targetContainer: HTMLElement;
public readonly canvas: HTMLCanvasElement = document.createElement('canvas');
constructor(
editorContainer: AffineEditorContainer,
targetContainer: HTMLElement
) {
this.editorContainer = editorContainer;
this.targetContainer = targetContainer;
this.worker = new Worker(new URL('./canvas.worker.ts', import.meta.url), {
type: 'module',
});
}
private initWorkerSize(width: number, height: number) {
const dpr = window.devicePixelRatio;
this.worker.postMessage({ type: 'init', data: { width, height, dpr } });
}
getHostRect() {
const hostRect = this.editorContainer.host!.getBoundingClientRect();
return hostRect;
}
getHostLayout() {
const paragraphBlocks = this.editorContainer.host!.querySelectorAll(
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]'
);
const paragraphs: ParagraphLayout[] = Array.from(paragraphBlocks).map(p => {
const sentences = segmentSentences(p.textContent || '');
const sentenceLayouts = sentences.map(sentence => {
const rects = getSentenceRects(p, sentence);
return {
text: sentence,
rects,
};
});
return {
sentences: sentenceLayouts,
};
});
const hostRect = this.getHostRect();
const editorContainerRect = this.editorContainer.getBoundingClientRect();
return { paragraphs, hostRect, editorContainerRect };
}
public async render(toScreen = true): Promise<void> {
const { paragraphs, hostRect, editorContainerRect } = this.getHostLayout();
this.initWorkerSize(hostRect.width, hostRect.height);
return new Promise(resolve => {
if (!this.worker) return;
this.worker.postMessage({
type: 'draw',
data: {
paragraphs,
hostRect,
},
});
this.worker.onmessage = (e: MessageEvent) => {
const { type, bitmap } = e.data;
if (type === 'render') {
this.canvas.style.width = editorContainerRect.width + 'px';
this.canvas.style.height = editorContainerRect.height + 'px';
this.canvas.width =
editorContainerRect.width * window.devicePixelRatio;
this.canvas.height =
editorContainerRect.height * window.devicePixelRatio;
if (!this.targetContainer.querySelector('canvas')) {
this.targetContainer.append(this.canvas);
}
const ctx = this.canvas.getContext('2d');
const bitmapCanvas = new OffscreenCanvas(
hostRect.width * window.devicePixelRatio,
hostRect.height * window.devicePixelRatio
);
const bitmapCtx = bitmapCanvas.getContext('bitmaprenderer');
bitmapCtx?.transferFromImageBitmap(bitmap);
if (!toScreen) {
resolve();
return;
}
ctx?.drawImage(
bitmapCanvas,
(hostRect.x - editorContainerRect.x) * window.devicePixelRatio,
(hostRect.y - editorContainerRect.y) * window.devicePixelRatio,
hostRect.width * window.devicePixelRatio,
hostRect.height * window.devicePixelRatio
);
resolve();
}
};
});
}
public renderTransitionFrame(
beginParagraphs: ParagraphLayout[],
endParagraphs: ParagraphLayout[],
beginHostRect: DOMRect,
endHostRect: DOMRect,
progress: number
) {
const editorContainerRect = this.editorContainer.getBoundingClientRect();
const dpr = window.devicePixelRatio;
if (!this.targetContainer.querySelector('canvas')) {
this.targetContainer.append(this.canvas);
}
const ctx = this.canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
const getParagraphRect = (paragraph: ParagraphLayout): DOMRect => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
paragraph.sentences.forEach(sentence => {
sentence.rects.forEach(({ rect }) => {
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
});
});
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
};
// Helper function to interpolate between two rects
const interpolateRect = (
rect1: DOMRect,
rect2: DOMRect,
t: number
): DOMRect => {
return new DOMRect(
rect1.x + (rect2.x - rect1.x) * t,
rect1.y + (rect2.y - rect1.y) * t,
rect1.width + (rect2.width - rect1.width) * t,
rect1.height + (rect2.height - rect1.height) * t
);
};
// Draw host rect
const currentHostRect = interpolateRect(
beginHostRect,
endHostRect,
progress
);
ctx.strokeStyle = 'white';
ctx.lineWidth = 1;
ctx.strokeRect(
currentHostRect.x - editorContainerRect.x,
currentHostRect.y - editorContainerRect.y,
currentHostRect.width,
currentHostRect.height
);
// Draw paragraph rects
const maxParagraphs = Math.max(
beginParagraphs.length,
endParagraphs.length
);
for (let i = 0; i < maxParagraphs; i++) {
const beginRect =
i < beginParagraphs.length
? getParagraphRect(beginParagraphs[i])
: getParagraphRect(endParagraphs[endParagraphs.length - 1]);
const endRect =
i < endParagraphs.length
? getParagraphRect(endParagraphs[i])
: getParagraphRect(beginParagraphs[beginParagraphs.length - 1]);
const currentRect = interpolateRect(beginRect, endRect, progress);
ctx.fillStyle = '#efefef';
ctx.fillRect(
currentRect.x - editorContainerRect.x,
currentRect.y - editorContainerRect.y,
currentRect.width,
currentRect.height
);
}
ctx.scale(1 / dpr, 1 / dpr);
}
public destroy() {
this.worker.terminate();
}
}

View File

@@ -0,0 +1,88 @@
import { type ParagraphLayout } from './types.js';
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);
font.load().catch(console.error);
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;
}
class CanvasWorkerManager {
private canvas: OffscreenCanvas | null = null;
private ctx: OffscreenCanvasRenderingContext2D | null = null;
init(width: number, height: number, dpr: number) {
this.canvas = new OffscreenCanvas(width * dpr, height * dpr);
this.ctx = this.canvas.getContext('2d')!;
this.ctx.scale(dpr, dpr);
this.ctx.fillStyle = 'lightgrey';
this.ctx.fillRect(0, 0, width, height);
}
draw(paragraphs: ParagraphLayout[], hostRect: DOMRect) {
const { canvas, ctx } = this;
if (!canvas || !ctx) return;
ctx.font = '15px Inter';
const baselineY = getBaseline();
paragraphs.forEach(paragraph => {
paragraph.sentences.forEach(sentence => {
ctx.strokeStyle = 'yellow';
sentence.rects.forEach(textRect => {
const x = textRect.rect.left - hostRect.left;
const y = textRect.rect.top - hostRect.top;
ctx.strokeRect(x, y, textRect.rect.width, textRect.rect.height);
});
ctx.fillStyle = 'black';
sentence.rects.forEach(textRect => {
const x = textRect.rect.left - hostRect.left;
const y = textRect.rect.top - hostRect.top;
ctx.fillText(textRect.text, x, y + baselineY);
});
});
});
const bitmap = canvas.transferToImageBitmap();
self.postMessage({ type: 'render', bitmap }, { transfer: [bitmap] });
}
}
const manager = new CanvasWorkerManager();
self.onmessage = async (e: MessageEvent) => {
const { type, data } = e.data;
switch (type) {
case 'init': {
const { width, height, dpr } = data;
manager.init(width, height, dpr);
break;
}
case 'draw': {
await font.load();
const { paragraphs, hostRect } = data;
manager.draw(paragraphs, hostRect);
break;
}
}
};

View File

@@ -0,0 +1,15 @@
import '../../style.css';
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import { AffineEditorContainer } from '@blocksuite/presets';
import { effects as presetsEffects } from '@blocksuite/presets/effects';
import { createEmptyDoc } from '../../apps/_common/helper';
blocksEffects();
presetsEffects();
export const doc = createEmptyDoc().init();
export const editor = new AffineEditorContainer();
editor.doc = doc;

View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Renderer Example</title>
<style>
html,
body {
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
background-color: var(--affine-white-90);
transition: background-color 0.3s;
}
#container {
display: flex;
width: 100%;
height: 100%;
}
#left-column {
flex: 1;
padding: 20px;
border-right: 1px solid #e0e0e0;
}
#right-column {
flex: 1;
padding: 20px;
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
}
#to-canvas-button {
position: absolute;
top: 5px;
left: 5px;
}
#switch-mode-button {
position: absolute;
top: 5px;
left: 85px;
}
</style>
</head>
<body>
<div id="container">
<div id="left-column"></div>
<div id="right-column">
<button id="to-canvas-button">to canvas</button>
<button id="switch-mode-button">switch mode</button>
</div>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
import { Text } from '@blocksuite/store';
import { animator } from './animator.js';
import { CanvasRenderer } from './canvas-renderer.js';
import { doc, editor } from './editor.js';
const container = document.querySelector('#right-column') as HTMLElement;
const renderer = new CanvasRenderer(editor, container);
function initUI() {
const toCanvasButton = document.querySelector('#to-canvas-button')!;
toCanvasButton.addEventListener('click', async () => {
await renderer.render();
});
const switchModeButton = document.querySelector('#switch-mode-button')!;
switchModeButton.addEventListener('click', async () => {
await animator.switchMode();
});
document.querySelector('#left-column')?.append(editor);
}
function addParagraph(content: string) {
const note = doc.getBlockByFlavour('affine:note')[0];
const props = {
text: new Text(content),
};
doc.addBlock('affine:paragraph', props, note.id);
}
function main() {
initUI();
const firstParagraph = doc.getBlockByFlavour('affine:paragraph')[0];
doc.updateBlock(firstParagraph, { text: new Text('Renderer') });
addParagraph('Hello World!');
addParagraph(
'Hello World! Lorem ipsum dolor sit amet. Consectetur adipiscing elit. Sed do eiusmod tempor incididunt.'
);
addParagraph(
'你好这是测试,这是一个为了换行而写的中文段落。这个段落会自动换行。'
);
}
main();

View File

@@ -0,0 +1,112 @@
import type { TextRect } from './types';
interface WordSegment {
text: string;
start: number;
end: number;
}
function getWordSegments(text: string): WordSegment[] {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
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) {
textRects.push({
rect: rects[0],
text: fullText,
});
return textRects;
}
const segments = getWordSegments(fullText);
// Calculate the total width and average width per character
const totalWidth = 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;
const isPunctuation = /^[.,!?;:]$/.test(segment.text.trim());
// Handle punctuation: if the punctuation doesn't exceed the rect width, merge it with the previous segment
if (isPunctuation && currentSegments.length > 0) {
const withPunctuationWidth = currentWidth + segmentWidth;
// Allow slight overflow (120%) since punctuation is usually very narrow
if (withPunctuationWidth <= rects[currentRect]?.width * 1.2) {
currentSegments.push(segment);
currentWidth = withPunctuationWidth;
return;
}
}
if (
currentWidth + segmentWidth > rects[currentRect]?.width &&
currentSegments.length > 0 &&
!isPunctuation // If it's punctuation, try merging with the previous word first
) {
textRects.push({
rect: rects[currentRect],
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 && currentRect < rects.length) {
textRects.push({
rect: rects[currentRect],
text: currentSegments.map(seg => seg.text).join(''),
});
}
return textRects;
}
export function getSentenceRects(
element: Element,
sentence: string
): TextRect[] {
const range = document.createRange();
const textNode = Array.from(element.childNodes).find(
node => node.nodeType === Node.TEXT_NODE
);
if (!textNode) return [];
const text = textNode.textContent || '';
const startIndex = text.indexOf(sentence);
if (startIndex === -1) return [];
range.setStart(textNode, startIndex);
range.setEnd(textNode, startIndex + sentence.length);
return getRangeRects(range, sentence);
}
export function segmentSentences(text: string): string[] {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'sentence' });
return Array.from(segmenter.segment(text)).map(({ segment }) => segment);
}

View File

@@ -0,0 +1,13 @@
export interface SentenceLayout {
text: string;
rects: TextRect[];
}
export interface ParagraphLayout {
sentences: SentenceLayout[];
}
export interface TextRect {
rect: DOMRect;
text: string;
}

View File

@@ -236,6 +236,10 @@ export default defineConfig(({ mode }) => {
'examples/multiple-editors/edgeless-edgeless/index.html'
),
'examples/inline': resolve(__dirname, 'examples/inline/index.html'),
'examples/renderer': resolve(
__dirname,
'examples/renderer/index.html'
),
},
treeshake: true,
output: {