From 077a1b38acbfd6bf89b72e2ed916445f13622a86 Mon Sep 17 00:00:00 2001 From: Yifeng Wang Date: Thu, 6 Feb 2025 10:36:59 +0800 Subject: [PATCH] refactor(editor): use model coord system in worker renderer (#9969) --- .../edgeless/edgeless-root-block.ts | 8 +- .../framework/block-std/src/gfx/viewport.ts | 15 +- .../playground/examples/renderer/animator.ts | 94 --------- .../examples/renderer/canvas-renderer.ts | 197 ++++++------------ .../examples/renderer/canvas.worker.ts | 56 +++-- .../playground/examples/renderer/main.ts | 3 +- .../examples/renderer/text-utils.ts | 69 +++--- .../playground/examples/renderer/types.ts | 26 ++- 8 files changed, 188 insertions(+), 280 deletions(-) delete mode 100644 blocksuite/playground/examples/renderer/animator.ts diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts index 789c2c6443..02a27cdb1a 100644 --- a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts @@ -223,9 +223,13 @@ export class EdgelessRootBlockComponent extends BlockComponent< const [p1, p2] = multiPointersState.pointers; const dx = - (0.25 * (p1.delta.x + p2.delta.x)) / viewport.zoom / viewport.scale; + (0.25 * (p1.delta.x + p2.delta.x)) / + viewport.zoom / + viewport.viewScale; const dy = - (0.25 * (p1.delta.y + p2.delta.y)) / viewport.zoom / viewport.scale; + (0.25 * (p1.delta.y + p2.delta.y)) / + viewport.zoom / + viewport.viewScale; // direction is opposite viewport.applyDeltaCenter(-dx, -dy); diff --git a/blocksuite/framework/block-std/src/gfx/viewport.ts b/blocksuite/framework/block-std/src/gfx/viewport.ts index a4affa8e32..68513f2aeb 100644 --- a/blocksuite/framework/block-std/src/gfx/viewport.ts +++ b/blocksuite/framework/block-std/src/gfx/viewport.ts @@ -102,7 +102,7 @@ export class Viewport { * The editor itself may be scaled by outer container which is common in nested editor scenarios. * This property is used to calculate the scale of the editor. */ - get scale() { + get viewScale() { if (!this._el || this._cachedOffsetWidth === null) return 1; return this.boundingClientRect.width / this._cachedOffsetWidth; } @@ -416,8 +416,11 @@ export class Viewport { } toModelCoord(viewX: number, viewY: number): IVec { - const { viewportX, viewportY, zoom, scale } = this; - return [viewportX + viewX / zoom / scale, viewportY + viewY / zoom / scale]; + const { viewportX, viewportY, zoom, viewScale } = this; + return [ + viewportX + viewX / zoom / viewScale, + viewportY + viewY / zoom / viewScale, + ]; } toModelCoordFromClientCoord([x, y]: IVec): IVec { @@ -433,10 +436,10 @@ export class Viewport { } toViewCoord(modelX: number, modelY: number): IVec { - const { viewportX, viewportY, zoom, scale } = this; + const { viewportX, viewportY, zoom, viewScale } = this; return [ - (modelX - viewportX) * zoom * scale, - (modelY - viewportY) * zoom * scale, + (modelX - viewportX) * zoom * viewScale, + (modelY - viewportY) * zoom * viewScale, ]; } diff --git a/blocksuite/playground/examples/renderer/animator.ts b/blocksuite/playground/examples/renderer/animator.ts deleted file mode 100644 index ce650bc503..0000000000 --- a/blocksuite/playground/examples/renderer/animator.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { type AffineEditorContainer } from '@blocksuite/presets'; - -import { CanvasRenderer } from './canvas-renderer.js'; -import { editor } from './editor.js'; -import type { SectionLayout } 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.hostLayout; - - 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.hostLayout; - - this.overlay.style.display = 'inherit'; - await this.animate( - beginLayout.section, - endLayout.section, - beginLayout.hostRect, - endLayout.hostRect - ); - this.overlay.style.display = 'none'; - } - - async animate( - beginSection: SectionLayout, - endSection: SectionLayout, - 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( - beginSection, - endSection, - 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 index 6835376cc9..23d9d4a056 100644 --- a/blocksuite/playground/examples/renderer/canvas-renderer.ts +++ b/blocksuite/playground/examples/renderer/canvas-renderer.ts @@ -2,7 +2,11 @@ import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import type { AffineEditorContainer } from '@blocksuite/presets'; import { getSentenceRects, segmentSentences } from './text-utils.js'; -import { type ParagraphLayout, type SectionLayout } from './types.js'; +import { + type ParagraphLayout, + type SectionLayout, + type ViewportState, +} from './types.js'; export class CanvasRenderer { private readonly worker: Worker; @@ -24,29 +28,40 @@ export class CanvasRenderer { private initWorkerSize(width: number, height: number) { const dpr = window.devicePixelRatio; - this.worker.postMessage({ type: 'init', data: { width, height, dpr } }); + const viewport = this.editorContainer.std.get( + GfxControllerIdentifier + ).viewport; + const viewportState: ViewportState = { + zoom: viewport.zoom, + viewScale: viewport.viewScale, + viewportX: viewport.viewportX, + viewportY: viewport.viewportY, + }; + this.worker.postMessage({ + type: 'init', + data: { width, height, dpr, viewport: viewportState }, + }); + } + + get viewport() { + return this.editorContainer.std.get(GfxControllerIdentifier).viewport; } get hostRect() { return this.editorContainer.host!.getBoundingClientRect(); } - get hostZoom() { - return this.editorContainer.std.get(GfxControllerIdentifier).viewport.zoom; - } - get hostLayout(): { section: SectionLayout; hostRect: DOMRect; - editorContainerRect: DOMRect; } { const paragraphBlocks = this.editorContainer.host!.querySelectorAll( '.affine-paragraph-rich-text-wrapper [data-v-text="true"]' ); - const zoom = this.hostZoom; + const { viewport } = this; + const zoom = this.viewport.zoom; const hostRect = this.hostRect; - const editorContainerRect = this.editorContainer.getBoundingClientRect(); let sectionMinX = Infinity; let sectionMinY = Infinity; @@ -60,37 +75,55 @@ export class CanvasRenderer { rects.forEach(({ rect }) => { sectionMinX = Math.min(sectionMinX, rect.x); sectionMinY = Math.min(sectionMinY, rect.y); - sectionMaxX = Math.max(sectionMaxX, rect.x + rect.width); - sectionMaxY = Math.max(sectionMaxY, rect.y + rect.height); + sectionMaxX = Math.max(sectionMaxX, rect.x + rect.w); + sectionMaxY = Math.max(sectionMaxY, rect.y + rect.h); }); return { text: sentence, - rects, + rects: rects.map(rect => { + const [x, y] = viewport.toModelCoordFromClientCoord([ + rect.rect.x, + rect.rect.y, + ]); + return { + ...rect, + rect: { + x, + y, + w: rect.rect.w / zoom / viewport.viewScale, + h: rect.rect.h / zoom / viewport.viewScale, + }, + }; + }), }; }); return { sentences: sentenceLayouts, - scale: zoom, + zoom, }; }); + const sectionModelCoord = viewport.toModelCoordFromClientCoord([ + sectionMinX, + sectionMinY, + ]); const section: SectionLayout = { paragraphs, rect: { - x: sectionMinX, - y: sectionMinY, - width: sectionMaxX - sectionMinX, - height: sectionMaxY - sectionMinY, + x: sectionModelCoord[0], + y: sectionModelCoord[1], + w: (sectionMaxX - sectionMinX) / zoom / viewport.viewScale, + h: (sectionMaxY - sectionMinY) / zoom / viewport.viewScale, }, }; - return { section, hostRect, editorContainerRect }; + return { section, hostRect }; } public async render(toScreen = true): Promise { - const { section, editorContainerRect } = this.hostLayout; - this.initWorkerSize(section.rect.width, section.rect.height); + const { section } = this.hostLayout; + this.initWorkerSize(section.rect.w, section.rect.h); return new Promise(resolve => { if (!this.worker) return; @@ -105,12 +138,10 @@ export class CanvasRenderer { 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; + this.canvas.style.width = this.hostRect.width + 'px'; + this.canvas.style.height = this.hostRect.height + 'px'; + this.canvas.width = this.hostRect.width * window.devicePixelRatio; + this.canvas.height = this.hostRect.height * window.devicePixelRatio; if (!this.targetContainer.querySelector('canvas')) { this.targetContainer.append(this.canvas); @@ -118,8 +149,8 @@ export class CanvasRenderer { const ctx = this.canvas.getContext('2d'); const bitmapCanvas = new OffscreenCanvas( - section.rect.width * window.devicePixelRatio, - section.rect.height * window.devicePixelRatio + section.rect.w * window.devicePixelRatio * this.viewport.zoom, + section.rect.h * window.devicePixelRatio * this.viewport.zoom ); const bitmapCtx = bitmapCanvas.getContext('bitmaprenderer'); bitmapCtx?.transferFromImageBitmap(bitmap); @@ -129,12 +160,17 @@ export class CanvasRenderer { return; } + const sectionViewCoord = this.viewport.toViewCoord( + section.rect.x, + section.rect.y + ); + ctx?.drawImage( bitmapCanvas, - (section.rect.x - editorContainerRect.x) * window.devicePixelRatio, - (section.rect.y - editorContainerRect.y) * window.devicePixelRatio, - section.rect.width * window.devicePixelRatio, - section.rect.height * window.devicePixelRatio + sectionViewCoord[0] * window.devicePixelRatio, + sectionViewCoord[1] * window.devicePixelRatio, + section.rect.w * window.devicePixelRatio * this.viewport.zoom, + section.rect.h * window.devicePixelRatio * this.viewport.zoom ); resolve(); @@ -143,103 +179,6 @@ export class CanvasRenderer { }); } - public renderTransitionFrame( - beginSection: SectionLayout, - endSection: SectionLayout, - 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( - beginSection.paragraphs.length, - endSection.paragraphs.length - ); - - for (let i = 0; i < maxParagraphs; i++) { - const beginRect = - i < beginSection.paragraphs.length - ? getParagraphRect(beginSection.paragraphs[i]) - : getParagraphRect( - endSection.paragraphs[endSection.paragraphs.length - 1] - ); - const endRect = - i < endSection.paragraphs.length - ? getParagraphRect(endSection.paragraphs[i]) - : getParagraphRect( - beginSection.paragraphs[beginSection.paragraphs.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 index b49769da8c..1b442c6000 100644 --- a/blocksuite/playground/examples/renderer/canvas.worker.ts +++ b/blocksuite/playground/examples/renderer/canvas.worker.ts @@ -1,4 +1,23 @@ -import { type SectionLayout } from './types.js'; +import { type SectionLayout, type ViewportState } from './types.js'; + +type WorkerMessageInit = { + type: 'init'; + data: { + width: number; + height: number; + dpr: number; + viewport: ViewportState; + }; +}; + +type WorkerMessageDraw = { + type: 'draw'; + data: { + section: SectionLayout; + }; +}; + +type WorkerMessage = WorkerMessageInit | WorkerMessageDraw; const meta = { emSize: 2048, @@ -29,39 +48,50 @@ function getBaseline() { class CanvasWorkerManager { private canvas: OffscreenCanvas | null = null; private ctx: OffscreenCanvasRenderingContext2D | null = null; + private viewport: ViewportState | null = null; - init(width: number, height: number, dpr: number) { - this.canvas = new OffscreenCanvas(width * dpr, height * dpr); + init( + modelWidth: number, + modelHeight: number, + dpr: number, + viewport: ViewportState + ) { + const width = modelWidth * dpr * viewport.zoom; + const height = modelHeight * dpr * viewport.zoom; + this.canvas = new OffscreenCanvas(width, height); this.ctx = this.canvas.getContext('2d')!; this.ctx.scale(dpr, dpr); this.ctx.fillStyle = 'lightgrey'; this.ctx.fillRect(0, 0, width, height); + this.viewport = viewport; } draw(section: SectionLayout) { const { canvas, ctx } = this; if (!canvas || !ctx) return; + const zoom = this.viewport!.zoom; + ctx.scale(zoom, zoom); + // Track rendered positions to avoid duplicate rendering across all paragraphs and sentences const renderedPositions = new Set(); section.paragraphs.forEach(paragraph => { - const scale = paragraph.scale ?? 1; - const fontSize = 15 * scale; - ctx.font = `${fontSize}px Inter`; - const baselineY = getBaseline() * scale; + const fontSize = 15; + ctx.font = `300 ${fontSize}px Inter`; + const baselineY = getBaseline(); paragraph.sentences.forEach(sentence => { ctx.strokeStyle = 'yellow'; sentence.rects.forEach(textRect => { - const x = textRect.rect.left - section.rect.x; - const y = textRect.rect.top - section.rect.y; + const x = textRect.rect.x - section.rect.x; + const y = textRect.rect.y - section.rect.y; const posKey = `${x},${y}`; // Only render if we haven't rendered at this position before if (renderedPositions.has(posKey)) return; - ctx.strokeRect(x, y, textRect.rect.width, textRect.rect.height); + ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h); ctx.fillStyle = 'black'; ctx.fillText(textRect.text, x, y + baselineY); @@ -77,12 +107,12 @@ class CanvasWorkerManager { const manager = new CanvasWorkerManager(); -self.onmessage = async (e: MessageEvent) => { +self.onmessage = async (e: MessageEvent) => { const { type, data } = e.data; switch (type) { case 'init': { - const { width, height, dpr } = data; - manager.init(width, height, dpr); + const { width, height, dpr, viewport } = data; + manager.init(width, height, dpr, viewport); break; } case 'draw': { diff --git a/blocksuite/playground/examples/renderer/main.ts b/blocksuite/playground/examples/renderer/main.ts index ec274f432a..1910ad3b5e 100644 --- a/blocksuite/playground/examples/renderer/main.ts +++ b/blocksuite/playground/examples/renderer/main.ts @@ -1,6 +1,5 @@ import { Text } from '@blocksuite/store'; -import { animator } from './animator.js'; import { CanvasRenderer } from './canvas-renderer.js'; import { doc, editor } from './editor.js'; @@ -14,7 +13,7 @@ function initUI() { }); const switchModeButton = document.querySelector('#switch-mode-button')!; switchModeButton.addEventListener('click', async () => { - await animator.switchMode(); + editor.mode = editor.mode === 'page' ? 'edgeless' : 'page'; }); document.querySelector('#left-column')?.append(editor); } diff --git a/blocksuite/playground/examples/renderer/text-utils.ts b/blocksuite/playground/examples/renderer/text-utils.ts index a139587621..7f229254a3 100644 --- a/blocksuite/playground/examples/renderer/text-utils.ts +++ b/blocksuite/playground/examples/renderer/text-utils.ts @@ -6,8 +6,15 @@ interface WordSegment { end: number; } +const CJK_REGEX = /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]/u; + +function hasCJK(text: string): boolean { + return CJK_REGEX.test(text); +} + function getWordSegments(text: string): WordSegment[] { - const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); + const granularity = hasCJK(text) ? 'grapheme' : 'word'; + const segmenter = new Intl.Segmenter(undefined, { granularity }); return Array.from(segmenter.segment(text)).map(({ segment, index }) => ({ text: segment, start: index, @@ -23,8 +30,14 @@ function getRangeRects(range: Range, fullText: string): TextRect[] { // If there's only one rect, use the full text if (rects.length === 1) { + const rect = rects[0]; textRects.push({ - rect: rects[0], + rect: { + x: rect.x, + y: rect.y, + w: rect.width, + h: rect.height, + }, text: fullText, }); return textRects; @@ -33,7 +46,9 @@ function getRangeRects(range: Range, fullText: string): TextRect[] { const segments = getWordSegments(fullText); // Calculate the total width and average width per character - const totalWidth = rects.reduce((sum, rect) => sum + rect.width, 0); + const totalWidth = Math.floor( + rects.reduce((sum, rect) => sum + rect.width, 0) + ); const charWidthEstimate = totalWidth / fullText.length; let currentRect = 0; @@ -42,26 +57,18 @@ function getRangeRects(range: Range, fullText: string): TextRect[] { 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 + currentSegments.length > 0 ) { + const rect = rects[currentRect]; textRects.push({ - rect: rects[currentRect], + rect: { + x: rect.x, + y: rect.y, + w: rect.width, + h: rect.height, + }, text: currentSegments.map(seg => seg.text).join(''), }); @@ -75,9 +82,15 @@ function getRangeRects(range: Range, fullText: string): TextRect[] { }); // Handle remaining segments if any - if (currentSegments.length > 0 && currentRect < rects.length) { + if (currentSegments.length > 0) { + const rect = rects[Math.min(currentRect, rects.length - 1)]; textRects.push({ - rect: rects[currentRect], + rect: { + x: rect.x, + y: rect.y, + w: rect.width, + h: rect.height, + }, text: currentSegments.map(seg => seg.text).join(''), }); } @@ -99,14 +112,18 @@ export function getSentenceRects( let rects: TextRect[] = []; let startIndex = 0; - // Find all occurrences of the sentence + // Find all occurrences of the sentence and ensure we capture complete words while ((startIndex = text.indexOf(sentence, startIndex)) !== -1) { const range = document.createRange(); - range.setStart(textNode, startIndex); - range.setEnd(textNode, startIndex + sentence.length); + let endIndex = startIndex + sentence.length; - rects = rects.concat(getRangeRects(range, sentence)); - startIndex += sentence.length; // Move to next potential occurrence + range.setStart(textNode, startIndex); + range.setEnd(textNode, endIndex); + + rects = rects.concat( + getRangeRects(range, text.slice(startIndex, endIndex)) + ); + startIndex = endIndex; } return rects; diff --git a/blocksuite/playground/examples/renderer/types.ts b/blocksuite/playground/examples/renderer/types.ts index ddde45b685..6af1f8c5fd 100644 --- a/blocksuite/playground/examples/renderer/types.ts +++ b/blocksuite/playground/examples/renderer/types.ts @@ -1,3 +1,18 @@ +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 SentenceLayout { text: string; rects: TextRect[]; @@ -5,20 +20,15 @@ export interface SentenceLayout { export interface ParagraphLayout { sentences: SentenceLayout[]; - scale: number; + zoom: number; } export interface TextRect { - rect: DOMRect; + rect: Rect; text: string; } export interface SectionLayout { paragraphs: ParagraphLayout[]; - rect: { - x: number; - y: number; - width: number; - height: number; - }; + rect: Rect; }