From e45ac54709599effcc2406f37359ca5923d8640d Mon Sep 17 00:00:00 2001 From: Yifeng Wang Date: Mon, 20 Jan 2025 20:40:27 +0800 Subject: [PATCH] feat(editor): add canvas worker renderer dev entry (#9719) --- .../playground/examples/renderer/animator.ts | 94 ++++++++ .../examples/renderer/canvas-renderer.ts | 209 ++++++++++++++++++ .../examples/renderer/canvas.worker.ts | 88 ++++++++ .../playground/examples/renderer/editor.ts | 15 ++ .../playground/examples/renderer/index.html | 63 ++++++ .../playground/examples/renderer/main.ts | 45 ++++ .../examples/renderer/text-utils.ts | 112 ++++++++++ .../playground/examples/renderer/types.ts | 13 ++ blocksuite/playground/vite.config.ts | 4 + 9 files changed, 643 insertions(+) create mode 100644 blocksuite/playground/examples/renderer/animator.ts create mode 100644 blocksuite/playground/examples/renderer/canvas-renderer.ts create mode 100644 blocksuite/playground/examples/renderer/canvas.worker.ts create mode 100644 blocksuite/playground/examples/renderer/editor.ts create mode 100644 blocksuite/playground/examples/renderer/index.html create mode 100644 blocksuite/playground/examples/renderer/main.ts create mode 100644 blocksuite/playground/examples/renderer/text-utils.ts create mode 100644 blocksuite/playground/examples/renderer/types.ts diff --git a/blocksuite/playground/examples/renderer/animator.ts b/blocksuite/playground/examples/renderer/animator.ts new file mode 100644 index 0000000000..299ee2ab78 --- /dev/null +++ b/blocksuite/playground/examples/renderer/animator.ts @@ -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 { + 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); diff --git a/blocksuite/playground/examples/renderer/canvas-renderer.ts b/blocksuite/playground/examples/renderer/canvas-renderer.ts new file mode 100644 index 0000000000..831b506476 --- /dev/null +++ b/blocksuite/playground/examples/renderer/canvas-renderer.ts @@ -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 { + 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(); + } +} diff --git a/blocksuite/playground/examples/renderer/canvas.worker.ts b/blocksuite/playground/examples/renderer/canvas.worker.ts new file mode 100644 index 0000000000..7fe2ceb066 --- /dev/null +++ b/blocksuite/playground/examples/renderer/canvas.worker.ts @@ -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; + } + } +}; diff --git a/blocksuite/playground/examples/renderer/editor.ts b/blocksuite/playground/examples/renderer/editor.ts new file mode 100644 index 0000000000..7ae5c213e3 --- /dev/null +++ b/blocksuite/playground/examples/renderer/editor.ts @@ -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; diff --git a/blocksuite/playground/examples/renderer/index.html b/blocksuite/playground/examples/renderer/index.html new file mode 100644 index 0000000000..1191b7771f --- /dev/null +++ b/blocksuite/playground/examples/renderer/index.html @@ -0,0 +1,63 @@ + + + + + + + Renderer Example + + + +
+
+
+ + +
+
+ + + diff --git a/blocksuite/playground/examples/renderer/main.ts b/blocksuite/playground/examples/renderer/main.ts new file mode 100644 index 0000000000..ec274f432a --- /dev/null +++ b/blocksuite/playground/examples/renderer/main.ts @@ -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(); diff --git a/blocksuite/playground/examples/renderer/text-utils.ts b/blocksuite/playground/examples/renderer/text-utils.ts new file mode 100644 index 0000000000..f3c7974931 --- /dev/null +++ b/blocksuite/playground/examples/renderer/text-utils.ts @@ -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); +} diff --git a/blocksuite/playground/examples/renderer/types.ts b/blocksuite/playground/examples/renderer/types.ts new file mode 100644 index 0000000000..19ccacfdfe --- /dev/null +++ b/blocksuite/playground/examples/renderer/types.ts @@ -0,0 +1,13 @@ +export interface SentenceLayout { + text: string; + rects: TextRect[]; +} + +export interface ParagraphLayout { + sentences: SentenceLayout[]; +} + +export interface TextRect { + rect: DOMRect; + text: string; +} diff --git a/blocksuite/playground/vite.config.ts b/blocksuite/playground/vite.config.ts index ccaee0bf41..309e7b420f 100644 --- a/blocksuite/playground/vite.config.ts +++ b/blocksuite/playground/vite.config.ts @@ -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: {