mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
feat(editor): add list block turbo renderer scaffold (#11266)
This PR allows placeholder in turbo renderer to cover list block as a basic scaffold. 
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocksuite/affine-components": "workspace:*",
|
"@blocksuite/affine-components": "workspace:*",
|
||||||
|
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*",
|
||||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./effects": "./src/effects.ts"
|
"./effects": "./src/effects.ts",
|
||||||
|
"./turbo-painter": "./src/turbo/list-painter.worker.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export * from './commands';
|
|||||||
export { correctNumberedListsOrderToPrev } from './commands/utils';
|
export { correctNumberedListsOrderToPrev } from './commands/utils';
|
||||||
export * from './list-block.js';
|
export * from './list-block.js';
|
||||||
export * from './list-spec.js';
|
export * from './list-spec.js';
|
||||||
|
export * from './turbo/list-layout-handler';
|
||||||
|
export * from './turbo/list-painter.worker';
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../components" },
|
{ "path": "../../components" },
|
||||||
|
{ "path": "../../gfx/turbo-renderer" },
|
||||||
{ "path": "../../inlines/preset" },
|
{ "path": "../../inlines/preset" },
|
||||||
{ "path": "../../model" },
|
{ "path": "../../model" },
|
||||||
{ "path": "../../rich-text" },
|
{ "path": "../../rich-text" },
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ export * from './commands';
|
|||||||
export * from './paragraph-block.js';
|
export * from './paragraph-block.js';
|
||||||
export * from './paragraph-block-config.js';
|
export * from './paragraph-block-config.js';
|
||||||
export * from './paragraph-spec.js';
|
export * from './paragraph-spec.js';
|
||||||
export * from './turbo/paragraph-layout-provider.js';
|
export * from './turbo/paragraph-layout-handler';
|
||||||
export * from './turbo/paragraph-painter.worker.js';
|
export * from './turbo/paragraph-painter.worker';
|
||||||
|
|||||||
+4
-2
@@ -15,8 +15,10 @@ export class ParagraphLayoutHandlerExtension extends BlockLayoutHandlerExtension
|
|||||||
readonly blockType = 'affine:paragraph';
|
readonly blockType = 'affine:paragraph';
|
||||||
|
|
||||||
static override setup(di: Container) {
|
static override setup(di: Container) {
|
||||||
const layoutHandler = new ParagraphLayoutHandlerExtension();
|
di.addImpl(
|
||||||
di.addImpl(BlockLayoutHandlersIdentifier, layoutHandler);
|
BlockLayoutHandlersIdentifier('paragraph'),
|
||||||
|
ParagraphLayoutHandlerExtension
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {
|
queryLayout(component: GfxBlockComponent): ParagraphLayout | null {
|
||||||
@@ -4,7 +4,10 @@ import type {
|
|||||||
TextRect,
|
TextRect,
|
||||||
WorkerToHostMessage,
|
WorkerToHostMessage,
|
||||||
} from '@blocksuite/affine-gfx-turbo-renderer';
|
} 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 {
|
interface SentenceLayout {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -17,25 +20,8 @@ export interface ParagraphLayout extends BlockLayout {
|
|||||||
sentences: SentenceLayout[];
|
sentences: SentenceLayout[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
|
||||||
emSize: 2048,
|
|
||||||
hHeadAscent: 1984,
|
|
||||||
hHeadDescent: -494,
|
|
||||||
};
|
|
||||||
|
|
||||||
const debugSentenceBorder = false;
|
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 {
|
function isParagraphLayout(layout: BlockLayout): layout is ParagraphLayout {
|
||||||
return layout.type === 'affine:paragraph';
|
return layout.type === 'affine:paragraph';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import { ListLayoutPainterExtension } from '@blocksuite/affine/blocks/list';
|
||||||
import { ParagraphLayoutPainterExtension } from '@blocksuite/affine/blocks/paragraph';
|
import { ParagraphLayoutPainterExtension } from '@blocksuite/affine/blocks/paragraph';
|
||||||
import { ViewportLayoutPainter } from '@blocksuite/affine/gfx/turbo-renderer';
|
import { ViewportLayoutPainter } from '@blocksuite/affine/gfx/turbo-renderer';
|
||||||
|
|
||||||
new ViewportLayoutPainter([ParagraphLayoutPainterExtension]);
|
new ViewportLayoutPainter([
|
||||||
|
ParagraphLayoutPainterExtension,
|
||||||
|
ListLayoutPainterExtension,
|
||||||
|
]);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ListLayoutHandlerExtension } from '@blocksuite/affine/blocks/list';
|
||||||
import { ParagraphLayoutHandlerExtension } from '@blocksuite/affine/blocks/paragraph';
|
import { ParagraphLayoutHandlerExtension } from '@blocksuite/affine/blocks/paragraph';
|
||||||
import {
|
import {
|
||||||
TurboRendererConfigFactory,
|
TurboRendererConfigFactory,
|
||||||
@@ -20,6 +21,7 @@ function createPainterWorker() {
|
|||||||
export function patchTurboRendererExtension() {
|
export function patchTurboRendererExtension() {
|
||||||
return [
|
return [
|
||||||
ParagraphLayoutHandlerExtension,
|
ParagraphLayoutHandlerExtension,
|
||||||
|
ListLayoutHandlerExtension,
|
||||||
TurboRendererConfigFactory({
|
TurboRendererConfigFactory({
|
||||||
options: {
|
options: {
|
||||||
zoomThreshold: 1,
|
zoomThreshold: 1,
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export const PackageList = [
|
|||||||
name: '@blocksuite/affine-block-list',
|
name: '@blocksuite/affine-block-list',
|
||||||
workspaceDependencies: [
|
workspaceDependencies: [
|
||||||
'blocksuite/affine/components',
|
'blocksuite/affine/components',
|
||||||
|
'blocksuite/affine/gfx/turbo-renderer',
|
||||||
'blocksuite/affine/inlines/preset',
|
'blocksuite/affine/inlines/preset',
|
||||||
'blocksuite/affine/model',
|
'blocksuite/affine/model',
|
||||||
'blocksuite/affine/rich-text',
|
'blocksuite/affine/rich-text',
|
||||||
|
|||||||
@@ -2643,6 +2643,7 @@ __metadata:
|
|||||||
resolution: "@blocksuite/affine-block-list@workspace:blocksuite/affine/blocks/block-list"
|
resolution: "@blocksuite/affine-block-list@workspace:blocksuite/affine/blocks/block-list"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@blocksuite/affine-components": "workspace:*"
|
"@blocksuite/affine-components": "workspace:*"
|
||||||
|
"@blocksuite/affine-gfx-turbo-renderer": "workspace:*"
|
||||||
"@blocksuite/affine-inline-preset": "workspace:*"
|
"@blocksuite/affine-inline-preset": "workspace:*"
|
||||||
"@blocksuite/affine-model": "workspace:*"
|
"@blocksuite/affine-model": "workspace:*"
|
||||||
"@blocksuite/affine-rich-text": "workspace:*"
|
"@blocksuite/affine-rich-text": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user