mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
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/inline': resolve(__dirname, 'examples/inline/index.html'),
|
||||
'examples/renderer': resolve(
|
||||
__dirname,
|
||||
'examples/renderer/index.html'
|
||||
),
|
||||
},
|
||||
treeshake: true,
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user