feat(editor): add list block turbo renderer scaffold (#11266)

This PR allows placeholder in turbo renderer to cover list block as a basic scaffold.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/eda28656-e56e-4845-9fe6-885e70841697.png)
This commit is contained in:
doodlewind
2025-03-29 04:49:24 +00:00
parent ac815142b3
commit dffb89c388
13 changed files with 301 additions and 24 deletions

View File

@@ -11,6 +11,7 @@
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
"@blocksuite/affine-inline-preset": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
@@ -34,7 +35,8 @@
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
"./effects": "./src/effects.ts",
"./turbo-painter": "./src/turbo/list-painter.worker.ts"
},
"files": [
"src",

View File

@@ -3,3 +3,5 @@ export * from './commands';
export { correctNumberedListsOrderToPrev } from './commands/utils';
export * from './list-block.js';
export * from './list-spec.js';
export * from './turbo/list-layout-handler';
export * from './turbo/list-painter.worker';

View File

@@ -0,0 +1,145 @@
import type { Rect } from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutHandlerExtension,
BlockLayoutHandlersIdentifier,
getSentenceRects,
segmentSentences,
} from '@blocksuite/affine-gfx-turbo-renderer';
import type { Container } from '@blocksuite/global/di';
import type { GfxBlockComponent } from '@blocksuite/std';
import { clientToModelCoord } from '@blocksuite/std/gfx';
import type { ListLayout } from './list-painter.worker';
export class ListLayoutHandlerExtension extends BlockLayoutHandlerExtension<ListLayout> {
readonly blockType = 'affine:list';
static override setup(di: Container) {
di.addImpl(
BlockLayoutHandlersIdentifier('list'),
ListLayoutHandlerExtension
);
}
queryLayout(component: GfxBlockComponent): ListLayout | null {
// Select all list items within this list block
const listItemSelector =
'.affine-list-block-container .affine-list-rich-text-wrapper [data-v-text="true"]';
const listItemNodes = component.querySelectorAll(listItemSelector);
if (listItemNodes.length === 0) return null;
const viewportRecord = component.gfx.viewport.deserializeRecord(
component.dataset.viewportState
);
if (!viewportRecord) return null;
const { zoom, viewScale } = viewportRecord;
const list: ListLayout = {
type: 'affine:list',
items: [],
};
listItemNodes.forEach(listItemNode => {
const listItemWrapper = listItemNode.closest(
'.affine-list-rich-text-wrapper'
);
if (!listItemWrapper) return;
// Determine list item type based on class
let itemType: 'bulleted' | 'numbered' | 'todo' | 'toggle' = 'bulleted';
let checked = false;
let collapsed = false;
let prefix = '';
if (listItemWrapper.classList.contains('affine-list--checked')) {
checked = true;
}
const parentListBlock = listItemWrapper.closest(
'.affine-list-block-container'
)?.parentElement;
if (parentListBlock) {
if (parentListBlock.dataset.listType === 'numbered') {
itemType = 'numbered';
const orderVal = parentListBlock.dataset.listOrder;
if (orderVal) {
prefix = orderVal + '.';
}
} else if (parentListBlock.dataset.listType === 'todo') {
itemType = 'todo';
} else if (parentListBlock.dataset.listType === 'toggle') {
itemType = 'toggle';
collapsed = parentListBlock.dataset.collapsed === 'true';
} else {
itemType = 'bulleted';
}
}
const computedStyle = window.getComputedStyle(listItemNode);
const fontSizeStr = computedStyle.fontSize;
const fontSize = parseInt(fontSizeStr);
const sentences = segmentSentences(listItemNode.textContent || '');
const sentenceLayouts = sentences.map(sentence => {
const sentenceRects = getSentenceRects(listItemNode, sentence);
return {
text: sentence,
rects: sentenceRects.map(({ text, rect }) => {
const [modelX, modelY] = clientToModelCoord(viewportRecord, [
rect.x,
rect.y,
]);
return {
text,
rect: {
x: modelX,
y: modelY,
w: rect.w / zoom / viewScale,
h: rect.h / zoom / viewScale,
},
};
}),
fontSize,
type: itemType,
prefix,
checked,
collapsed,
};
});
list.items.push(...sentenceLayouts);
});
return list;
}
calculateBound(layout: ListLayout) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
layout.items.forEach(item => {
item.rects.forEach(r => {
minX = Math.min(minX, r.rect.x);
minY = Math.min(minY, r.rect.y);
maxX = Math.max(maxX, r.rect.x + r.rect.w);
maxY = Math.max(maxY, r.rect.y + r.rect.h);
});
});
const rect: Rect = {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
return {
rect,
subRects: layout.items.flatMap(s => s.rects.map(r => r.rect)),
};
}
}

View File

@@ -0,0 +1,114 @@
import type {
BlockLayout,
BlockLayoutPainter,
TextRect,
WorkerToHostMessage,
} from '@blocksuite/affine-gfx-turbo-renderer';
import {
BlockLayoutPainterExtension,
getBaseline,
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
interface ListItemLayout {
text: string;
rects: TextRect[];
fontSize: number;
type: 'bulleted' | 'numbered' | 'todo' | 'toggle';
prefix?: string;
checked?: boolean;
collapsed?: boolean;
}
export interface ListLayout extends BlockLayout {
type: 'affine:list';
items: ListItemLayout[];
}
const debugListBorder = false;
function isListLayout(layout: BlockLayout): layout is ListLayout {
return layout.type === 'affine:list';
}
class ListLayoutPainter implements BlockLayoutPainter {
private static readonly supportFontFace =
typeof FontFace !== 'undefined' &&
typeof self !== 'undefined' &&
'fonts' in self;
static readonly font = ListLayoutPainter.supportFontFace
? new FontFace(
'Inter',
`url(https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwYZ8UA3.woff2)`
)
: null;
static fontLoaded = !ListLayoutPainter.supportFontFace;
static {
if (ListLayoutPainter.supportFontFace && ListLayoutPainter.font) {
// @ts-expect-error worker fonts API
self.fonts.add(ListLayoutPainter.font);
ListLayoutPainter.font
.load()
.then(() => {
ListLayoutPainter.fontLoaded = true;
})
.catch(error => {
console.error('Failed to load Inter font:', error);
});
}
}
paint(
ctx: OffscreenCanvasRenderingContext2D,
layout: BlockLayout,
layoutBaseX: number,
layoutBaseY: number
): void {
if (!ListLayoutPainter.fontLoaded) {
const message: WorkerToHostMessage = {
type: 'paintError',
error: 'Font not loaded',
blockType: 'affine:list',
};
self.postMessage(message);
return;
}
if (!isListLayout(layout)) return;
const renderedPositions = new Set<string>();
layout.items.forEach(item => {
const fontSize = item.fontSize;
const baselineY = getBaseline(fontSize);
ctx.font = `${fontSize}px Inter`;
ctx.strokeStyle = 'yellow';
// Render the text content
item.rects.forEach(textRect => {
const x = textRect.rect.x - layoutBaseX;
const y = textRect.rect.y - layoutBaseY;
const posKey = `${x},${y}`;
// Only render if we haven't rendered at this position before
if (renderedPositions.has(posKey)) return;
if (debugListBorder) {
ctx.strokeRect(x, y, textRect.rect.w, textRect.rect.h);
}
ctx.fillStyle = 'black';
ctx.fillText(textRect.text, x, y + baselineY);
renderedPositions.add(posKey);
});
});
}
}
export const ListLayoutPainterExtension = BlockLayoutPainterExtension(
'affine:list',
ListLayoutPainter
);

View File

@@ -8,6 +8,7 @@
"include": ["./src"],
"references": [
{ "path": "../../components" },
{ "path": "../../gfx/turbo-renderer" },
{ "path": "../../inlines/preset" },
{ "path": "../../model" },
{ "path": "../../rich-text" },

View File

@@ -3,5 +3,5 @@ export * from './commands';
export * from './paragraph-block.js';
export * from './paragraph-block-config.js';
export * from './paragraph-spec.js';
export * from './turbo/paragraph-layout-provider.js';
export * from './turbo/paragraph-painter.worker.js';
export * from './turbo/paragraph-layout-handler';
export * from './turbo/paragraph-painter.worker';

View File

@@ -15,8 +15,10 @@ export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension
readonly blockType = 'affine:paragraph';
static override setup(di: Container) {
const layoutHandler = new ParagraphLayoutHandlerExtension();
di.addImpl(BlockLayoutHandlersIdentifier, layoutHandler);
di.addImpl(
BlockLayoutHandlersIdentifier('paragraph'),
ParagraphLayoutHandlerExtension
);
}
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {

View File

@@ -4,7 +4,10 @@ import type {
TextRect,
WorkerToHostMessage,
} from '@blocksuite/affine-gfx-turbo-renderer';
import { BlockLayoutPainterExtension } from '@blocksuite/affine-gfx-turbo-renderer/painter';
import {
BlockLayoutPainterExtension,
getBaseline,
} from '@blocksuite/affine-gfx-turbo-renderer/painter';
interface SentenceLayout {
text: string;
@@ -17,25 +20,8 @@ export interface ParagraphLayout extends BlockLayout {
sentences: SentenceLayout[];
}
const meta = {
emSize: 2048,
hHeadAscent: 1984,
hHeadDescent: -494,
};
const debugSentenceBorder = false;
function getBaseline(fontSize: number) {
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;
}
function isParagraphLayout(layout: BlockLayout): layout is ParagraphLayout {
return layout.type === 'affine:paragraph';
}

View File

@@ -105,3 +105,20 @@ export class ViewportLayoutPainter {
}
};
}
const meta = {
emSize: 2048,
hHeadAscent: 1984,
hHeadDescent: -494,
};
export function getBaseline(fontSize: number) {
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;
}