feat(editor): add canvas worker renderer dev entry (#9719)
This commit is contained in:
94
blocksuite/playground/examples/renderer/animator.ts
Normal file
94
blocksuite/playground/examples/renderer/animator.ts
Normal 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);
|
||||||
209
blocksuite/playground/examples/renderer/canvas-renderer.ts
Normal file
209
blocksuite/playground/examples/renderer/canvas-renderer.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
88
blocksuite/playground/examples/renderer/canvas.worker.ts
Normal file
88
blocksuite/playground/examples/renderer/canvas.worker.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
15
blocksuite/playground/examples/renderer/editor.ts
Normal file
15
blocksuite/playground/examples/renderer/editor.ts
Normal 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;
|
||||||
63
blocksuite/playground/examples/renderer/index.html
Normal file
63
blocksuite/playground/examples/renderer/index.html
Normal 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>
|
||||||
45
blocksuite/playground/examples/renderer/main.ts
Normal file
45
blocksuite/playground/examples/renderer/main.ts
Normal 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();
|
||||||
112
blocksuite/playground/examples/renderer/text-utils.ts
Normal file
112
blocksuite/playground/examples/renderer/text-utils.ts
Normal 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);
|
||||||
|
}
|
||||||
13
blocksuite/playground/examples/renderer/types.ts
Normal file
13
blocksuite/playground/examples/renderer/types.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface SentenceLayout {
|
||||||
|
text: string;
|
||||||
|
rects: TextRect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParagraphLayout {
|
||||||
|
sentences: SentenceLayout[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextRect {
|
||||||
|
rect: DOMRect;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -236,6 +236,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
'examples/multiple-editors/edgeless-edgeless/index.html'
|
'examples/multiple-editors/edgeless-edgeless/index.html'
|
||||||
),
|
),
|
||||||
'examples/inline': resolve(__dirname, 'examples/inline/index.html'),
|
'examples/inline': resolve(__dirname, 'examples/inline/index.html'),
|
||||||
|
'examples/renderer': resolve(
|
||||||
|
__dirname,
|
||||||
|
'examples/renderer/index.html'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
Reference in New Issue
Block a user