mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
refactor(editor): use model coord system in worker renderer (#9969)
This commit is contained in:
@@ -223,9 +223,13 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
|||||||
const [p1, p2] = multiPointersState.pointers;
|
const [p1, p2] = multiPointersState.pointers;
|
||||||
|
|
||||||
const dx =
|
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 =
|
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
|
// direction is opposite
|
||||||
viewport.applyDeltaCenter(-dx, -dy);
|
viewport.applyDeltaCenter(-dx, -dy);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class Viewport {
|
|||||||
* The editor itself may be scaled by outer container which is common in nested editor scenarios.
|
* 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.
|
* This property is used to calculate the scale of the editor.
|
||||||
*/
|
*/
|
||||||
get scale() {
|
get viewScale() {
|
||||||
if (!this._el || this._cachedOffsetWidth === null) return 1;
|
if (!this._el || this._cachedOffsetWidth === null) return 1;
|
||||||
return this.boundingClientRect.width / this._cachedOffsetWidth;
|
return this.boundingClientRect.width / this._cachedOffsetWidth;
|
||||||
}
|
}
|
||||||
@@ -416,8 +416,11 @@ export class Viewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toModelCoord(viewX: number, viewY: number): IVec {
|
toModelCoord(viewX: number, viewY: number): IVec {
|
||||||
const { viewportX, viewportY, zoom, scale } = this;
|
const { viewportX, viewportY, zoom, viewScale } = this;
|
||||||
return [viewportX + viewX / zoom / scale, viewportY + viewY / zoom / scale];
|
return [
|
||||||
|
viewportX + viewX / zoom / viewScale,
|
||||||
|
viewportY + viewY / zoom / viewScale,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
toModelCoordFromClientCoord([x, y]: IVec): IVec {
|
toModelCoordFromClientCoord([x, y]: IVec): IVec {
|
||||||
@@ -433,10 +436,10 @@ export class Viewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toViewCoord(modelX: number, modelY: number): IVec {
|
toViewCoord(modelX: number, modelY: number): IVec {
|
||||||
const { viewportX, viewportY, zoom, scale } = this;
|
const { viewportX, viewportY, zoom, viewScale } = this;
|
||||||
return [
|
return [
|
||||||
(modelX - viewportX) * zoom * scale,
|
(modelX - viewportX) * zoom * viewScale,
|
||||||
(modelY - viewportY) * zoom * scale,
|
(modelY - viewportY) * zoom * viewScale,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<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(
|
|
||||||
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);
|
|
||||||
@@ -2,7 +2,11 @@ import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
|||||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||||
|
|
||||||
import { getSentenceRects, segmentSentences } from './text-utils.js';
|
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 {
|
export class CanvasRenderer {
|
||||||
private readonly worker: Worker;
|
private readonly worker: Worker;
|
||||||
@@ -24,29 +28,40 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
private initWorkerSize(width: number, height: number) {
|
private initWorkerSize(width: number, height: number) {
|
||||||
const dpr = window.devicePixelRatio;
|
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() {
|
get hostRect() {
|
||||||
return this.editorContainer.host!.getBoundingClientRect();
|
return this.editorContainer.host!.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
get hostZoom() {
|
|
||||||
return this.editorContainer.std.get(GfxControllerIdentifier).viewport.zoom;
|
|
||||||
}
|
|
||||||
|
|
||||||
get hostLayout(): {
|
get hostLayout(): {
|
||||||
section: SectionLayout;
|
section: SectionLayout;
|
||||||
hostRect: DOMRect;
|
hostRect: DOMRect;
|
||||||
editorContainerRect: DOMRect;
|
|
||||||
} {
|
} {
|
||||||
const paragraphBlocks = this.editorContainer.host!.querySelectorAll(
|
const paragraphBlocks = this.editorContainer.host!.querySelectorAll(
|
||||||
'.affine-paragraph-rich-text-wrapper [data-v-text="true"]'
|
'.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 hostRect = this.hostRect;
|
||||||
const editorContainerRect = this.editorContainer.getBoundingClientRect();
|
|
||||||
|
|
||||||
let sectionMinX = Infinity;
|
let sectionMinX = Infinity;
|
||||||
let sectionMinY = Infinity;
|
let sectionMinY = Infinity;
|
||||||
@@ -60,37 +75,55 @@ export class CanvasRenderer {
|
|||||||
rects.forEach(({ rect }) => {
|
rects.forEach(({ rect }) => {
|
||||||
sectionMinX = Math.min(sectionMinX, rect.x);
|
sectionMinX = Math.min(sectionMinX, rect.x);
|
||||||
sectionMinY = Math.min(sectionMinY, rect.y);
|
sectionMinY = Math.min(sectionMinY, rect.y);
|
||||||
sectionMaxX = Math.max(sectionMaxX, rect.x + rect.width);
|
sectionMaxX = Math.max(sectionMaxX, rect.x + rect.w);
|
||||||
sectionMaxY = Math.max(sectionMaxY, rect.y + rect.height);
|
sectionMaxY = Math.max(sectionMaxY, rect.y + rect.h);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
text: sentence,
|
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 {
|
return {
|
||||||
sentences: sentenceLayouts,
|
sentences: sentenceLayouts,
|
||||||
scale: zoom,
|
zoom,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sectionModelCoord = viewport.toModelCoordFromClientCoord([
|
||||||
|
sectionMinX,
|
||||||
|
sectionMinY,
|
||||||
|
]);
|
||||||
const section: SectionLayout = {
|
const section: SectionLayout = {
|
||||||
paragraphs,
|
paragraphs,
|
||||||
rect: {
|
rect: {
|
||||||
x: sectionMinX,
|
x: sectionModelCoord[0],
|
||||||
y: sectionMinY,
|
y: sectionModelCoord[1],
|
||||||
width: sectionMaxX - sectionMinX,
|
w: (sectionMaxX - sectionMinX) / zoom / viewport.viewScale,
|
||||||
height: sectionMaxY - sectionMinY,
|
h: (sectionMaxY - sectionMinY) / zoom / viewport.viewScale,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { section, hostRect, editorContainerRect };
|
return { section, hostRect };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async render(toScreen = true): Promise<void> {
|
public async render(toScreen = true): Promise<void> {
|
||||||
const { section, editorContainerRect } = this.hostLayout;
|
const { section } = this.hostLayout;
|
||||||
this.initWorkerSize(section.rect.width, section.rect.height);
|
this.initWorkerSize(section.rect.w, section.rect.h);
|
||||||
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
if (!this.worker) return;
|
if (!this.worker) return;
|
||||||
@@ -105,12 +138,10 @@ export class CanvasRenderer {
|
|||||||
this.worker.onmessage = (e: MessageEvent) => {
|
this.worker.onmessage = (e: MessageEvent) => {
|
||||||
const { type, bitmap } = e.data;
|
const { type, bitmap } = e.data;
|
||||||
if (type === 'render') {
|
if (type === 'render') {
|
||||||
this.canvas.style.width = editorContainerRect.width + 'px';
|
this.canvas.style.width = this.hostRect.width + 'px';
|
||||||
this.canvas.style.height = editorContainerRect.height + 'px';
|
this.canvas.style.height = this.hostRect.height + 'px';
|
||||||
this.canvas.width =
|
this.canvas.width = this.hostRect.width * window.devicePixelRatio;
|
||||||
editorContainerRect.width * window.devicePixelRatio;
|
this.canvas.height = this.hostRect.height * window.devicePixelRatio;
|
||||||
this.canvas.height =
|
|
||||||
editorContainerRect.height * window.devicePixelRatio;
|
|
||||||
|
|
||||||
if (!this.targetContainer.querySelector('canvas')) {
|
if (!this.targetContainer.querySelector('canvas')) {
|
||||||
this.targetContainer.append(this.canvas);
|
this.targetContainer.append(this.canvas);
|
||||||
@@ -118,8 +149,8 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
const ctx = this.canvas.getContext('2d');
|
const ctx = this.canvas.getContext('2d');
|
||||||
const bitmapCanvas = new OffscreenCanvas(
|
const bitmapCanvas = new OffscreenCanvas(
|
||||||
section.rect.width * window.devicePixelRatio,
|
section.rect.w * window.devicePixelRatio * this.viewport.zoom,
|
||||||
section.rect.height * window.devicePixelRatio
|
section.rect.h * window.devicePixelRatio * this.viewport.zoom
|
||||||
);
|
);
|
||||||
const bitmapCtx = bitmapCanvas.getContext('bitmaprenderer');
|
const bitmapCtx = bitmapCanvas.getContext('bitmaprenderer');
|
||||||
bitmapCtx?.transferFromImageBitmap(bitmap);
|
bitmapCtx?.transferFromImageBitmap(bitmap);
|
||||||
@@ -129,12 +160,17 @@ export class CanvasRenderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sectionViewCoord = this.viewport.toViewCoord(
|
||||||
|
section.rect.x,
|
||||||
|
section.rect.y
|
||||||
|
);
|
||||||
|
|
||||||
ctx?.drawImage(
|
ctx?.drawImage(
|
||||||
bitmapCanvas,
|
bitmapCanvas,
|
||||||
(section.rect.x - editorContainerRect.x) * window.devicePixelRatio,
|
sectionViewCoord[0] * window.devicePixelRatio,
|
||||||
(section.rect.y - editorContainerRect.y) * window.devicePixelRatio,
|
sectionViewCoord[1] * window.devicePixelRatio,
|
||||||
section.rect.width * window.devicePixelRatio,
|
section.rect.w * window.devicePixelRatio * this.viewport.zoom,
|
||||||
section.rect.height * window.devicePixelRatio
|
section.rect.h * window.devicePixelRatio * this.viewport.zoom
|
||||||
);
|
);
|
||||||
|
|
||||||
resolve();
|
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() {
|
public destroy() {
|
||||||
this.worker.terminate();
|
this.worker.terminate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
const meta = {
|
||||||
emSize: 2048,
|
emSize: 2048,
|
||||||
@@ -29,39 +48,50 @@ function getBaseline() {
|
|||||||
class CanvasWorkerManager {
|
class CanvasWorkerManager {
|
||||||
private canvas: OffscreenCanvas | null = null;
|
private canvas: OffscreenCanvas | null = null;
|
||||||
private ctx: OffscreenCanvasRenderingContext2D | null = null;
|
private ctx: OffscreenCanvasRenderingContext2D | null = null;
|
||||||
|
private viewport: ViewportState | null = null;
|
||||||
|
|
||||||
init(width: number, height: number, dpr: number) {
|
init(
|
||||||
this.canvas = new OffscreenCanvas(width * dpr, height * dpr);
|
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 = this.canvas.getContext('2d')!;
|
||||||
this.ctx.scale(dpr, dpr);
|
this.ctx.scale(dpr, dpr);
|
||||||
this.ctx.fillStyle = 'lightgrey';
|
this.ctx.fillStyle = 'lightgrey';
|
||||||
this.ctx.fillRect(0, 0, width, height);
|
this.ctx.fillRect(0, 0, width, height);
|
||||||
|
this.viewport = viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
draw(section: SectionLayout) {
|
draw(section: SectionLayout) {
|
||||||
const { canvas, ctx } = this;
|
const { canvas, ctx } = this;
|
||||||
if (!canvas || !ctx) return;
|
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
|
// Track rendered positions to avoid duplicate rendering across all paragraphs and sentences
|
||||||
const renderedPositions = new Set<string>();
|
const renderedPositions = new Set<string>();
|
||||||
|
|
||||||
section.paragraphs.forEach(paragraph => {
|
section.paragraphs.forEach(paragraph => {
|
||||||
const scale = paragraph.scale ?? 1;
|
const fontSize = 15;
|
||||||
const fontSize = 15 * scale;
|
ctx.font = `300 ${fontSize}px Inter`;
|
||||||
ctx.font = `${fontSize}px Inter`;
|
const baselineY = getBaseline();
|
||||||
const baselineY = getBaseline() * scale;
|
|
||||||
|
|
||||||
paragraph.sentences.forEach(sentence => {
|
paragraph.sentences.forEach(sentence => {
|
||||||
ctx.strokeStyle = 'yellow';
|
ctx.strokeStyle = 'yellow';
|
||||||
sentence.rects.forEach(textRect => {
|
sentence.rects.forEach(textRect => {
|
||||||
const x = textRect.rect.left - section.rect.x;
|
const x = textRect.rect.x - section.rect.x;
|
||||||
const y = textRect.rect.top - section.rect.y;
|
const y = textRect.rect.y - section.rect.y;
|
||||||
|
|
||||||
const posKey = `${x},${y}`;
|
const posKey = `${x},${y}`;
|
||||||
// Only render if we haven't rendered at this position before
|
// Only render if we haven't rendered at this position before
|
||||||
if (renderedPositions.has(posKey)) return;
|
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.fillStyle = 'black';
|
||||||
ctx.fillText(textRect.text, x, y + baselineY);
|
ctx.fillText(textRect.text, x, y + baselineY);
|
||||||
|
|
||||||
@@ -77,12 +107,12 @@ class CanvasWorkerManager {
|
|||||||
|
|
||||||
const manager = new CanvasWorkerManager();
|
const manager = new CanvasWorkerManager();
|
||||||
|
|
||||||
self.onmessage = async (e: MessageEvent) => {
|
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
||||||
const { type, data } = e.data;
|
const { type, data } = e.data;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'init': {
|
case 'init': {
|
||||||
const { width, height, dpr } = data;
|
const { width, height, dpr, viewport } = data;
|
||||||
manager.init(width, height, dpr);
|
manager.init(width, height, dpr, viewport);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'draw': {
|
case 'draw': {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Text } from '@blocksuite/store';
|
import { Text } from '@blocksuite/store';
|
||||||
|
|
||||||
import { animator } from './animator.js';
|
|
||||||
import { CanvasRenderer } from './canvas-renderer.js';
|
import { CanvasRenderer } from './canvas-renderer.js';
|
||||||
import { doc, editor } from './editor.js';
|
import { doc, editor } from './editor.js';
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ function initUI() {
|
|||||||
});
|
});
|
||||||
const switchModeButton = document.querySelector('#switch-mode-button')!;
|
const switchModeButton = document.querySelector('#switch-mode-button')!;
|
||||||
switchModeButton.addEventListener('click', async () => {
|
switchModeButton.addEventListener('click', async () => {
|
||||||
await animator.switchMode();
|
editor.mode = editor.mode === 'page' ? 'edgeless' : 'page';
|
||||||
});
|
});
|
||||||
document.querySelector('#left-column')?.append(editor);
|
document.querySelector('#left-column')?.append(editor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ interface WordSegment {
|
|||||||
end: number;
|
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[] {
|
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 }) => ({
|
return Array.from(segmenter.segment(text)).map(({ segment, index }) => ({
|
||||||
text: segment,
|
text: segment,
|
||||||
start: index,
|
start: index,
|
||||||
@@ -23,8 +30,14 @@ function getRangeRects(range: Range, fullText: string): TextRect[] {
|
|||||||
|
|
||||||
// If there's only one rect, use the full text
|
// If there's only one rect, use the full text
|
||||||
if (rects.length === 1) {
|
if (rects.length === 1) {
|
||||||
|
const rect = rects[0];
|
||||||
textRects.push({
|
textRects.push({
|
||||||
rect: rects[0],
|
rect: {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
w: rect.width,
|
||||||
|
h: rect.height,
|
||||||
|
},
|
||||||
text: fullText,
|
text: fullText,
|
||||||
});
|
});
|
||||||
return textRects;
|
return textRects;
|
||||||
@@ -33,7 +46,9 @@ function getRangeRects(range: Range, fullText: string): TextRect[] {
|
|||||||
const segments = getWordSegments(fullText);
|
const segments = getWordSegments(fullText);
|
||||||
|
|
||||||
// Calculate the total width and average width per character
|
// 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;
|
const charWidthEstimate = totalWidth / fullText.length;
|
||||||
|
|
||||||
let currentRect = 0;
|
let currentRect = 0;
|
||||||
@@ -42,26 +57,18 @@ function getRangeRects(range: Range, fullText: string): TextRect[] {
|
|||||||
|
|
||||||
segments.forEach(segment => {
|
segments.forEach(segment => {
|
||||||
const segmentWidth = segment.text.length * charWidthEstimate;
|
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 (
|
if (
|
||||||
currentWidth + segmentWidth > rects[currentRect]?.width &&
|
currentWidth + segmentWidth > rects[currentRect]?.width &&
|
||||||
currentSegments.length > 0 &&
|
currentSegments.length > 0
|
||||||
!isPunctuation // If it's punctuation, try merging with the previous word first
|
|
||||||
) {
|
) {
|
||||||
|
const rect = rects[currentRect];
|
||||||
textRects.push({
|
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(''),
|
text: currentSegments.map(seg => seg.text).join(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,9 +82,15 @@ function getRangeRects(range: Range, fullText: string): TextRect[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle remaining segments if any
|
// 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({
|
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(''),
|
text: currentSegments.map(seg => seg.text).join(''),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,14 +112,18 @@ export function getSentenceRects(
|
|||||||
let rects: TextRect[] = [];
|
let rects: TextRect[] = [];
|
||||||
let startIndex = 0;
|
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) {
|
while ((startIndex = text.indexOf(sentence, startIndex)) !== -1) {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(textNode, startIndex);
|
let endIndex = startIndex + sentence.length;
|
||||||
range.setEnd(textNode, startIndex + sentence.length);
|
|
||||||
|
|
||||||
rects = rects.concat(getRangeRects(range, sentence));
|
range.setStart(textNode, startIndex);
|
||||||
startIndex += sentence.length; // Move to next potential occurrence
|
range.setEnd(textNode, endIndex);
|
||||||
|
|
||||||
|
rects = rects.concat(
|
||||||
|
getRangeRects(range, text.slice(startIndex, endIndex))
|
||||||
|
);
|
||||||
|
startIndex = endIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rects;
|
return rects;
|
||||||
|
|||||||
@@ -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 {
|
export interface SentenceLayout {
|
||||||
text: string;
|
text: string;
|
||||||
rects: TextRect[];
|
rects: TextRect[];
|
||||||
@@ -5,20 +20,15 @@ export interface SentenceLayout {
|
|||||||
|
|
||||||
export interface ParagraphLayout {
|
export interface ParagraphLayout {
|
||||||
sentences: SentenceLayout[];
|
sentences: SentenceLayout[];
|
||||||
scale: number;
|
zoom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextRect {
|
export interface TextRect {
|
||||||
rect: DOMRect;
|
rect: Rect;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SectionLayout {
|
export interface SectionLayout {
|
||||||
paragraphs: ParagraphLayout[];
|
paragraphs: ParagraphLayout[];
|
||||||
rect: {
|
rect: Rect;
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user