refactor(editor): add dom renderer entry for canvas element (#12149)

This commit is contained in:
Yifeng Wang
2025-05-14 16:22:16 +08:00
committed by GitHub
parent 6358249aea
commit 9cabe03386
20 changed files with 911 additions and 42 deletions

View File

@@ -0,0 +1,30 @@
import type { ExtensionType } from '@blocksuite/store';
import type { SurfaceElementModel } from '../element-model/base.js';
import {
type DomElementRenderer,
DomElementRendererIdentifier,
} from '../renderer/dom-elements/index.js';
/**
* Creates an extension for registering a DomElementRenderer for a specific element type.
*
* @param elementType The type of the surface element (e.g., 'shape', 'text') for which this renderer is.
* @param implementation The DomElementRenderer function that handles rendering for this element type.
* @returns An ExtensionType object that can be used to set up the renderer in the DI container.
*/
export const DomElementRendererExtension = <
T extends SurfaceElementModel = SurfaceElementModel,
>(
elementType: string,
implementation: DomElementRenderer<T>
): ExtensionType => {
return {
setup: di => {
di.addValue(
DomElementRendererIdentifier(elementType),
implementation as DomElementRenderer
);
},
};
};

View File

@@ -28,7 +28,7 @@ import {
} from '@blocksuite/std/gfx';
import type { ExtensionType, Store } from '@blocksuite/store';
import type { CanvasRenderer } from '../../renderer/canvas-renderer.js';
import { CanvasRenderer } from '../../renderer/canvas-renderer.js';
import type { SurfaceBlockComponent } from '../../surface-block.js';
import { getBgGridGap } from '../../utils/get-bg-grip-gap.js';
import { FileExporter } from './file-exporter.js';
@@ -358,6 +358,9 @@ export class ExportManager {
const surfaceBlock = gfx.surfaceComponent as SurfaceBlockComponent | null;
if (!surfaceBlock) return;
const bound = gfx.elementsBound;
if (!(surfaceBlock.renderer instanceof CanvasRenderer)) {
return;
}
return this.edgelessToCanvas(surfaceBlock.renderer, bound, gfx);
}
}
@@ -373,6 +376,13 @@ export class ExportManager {
zoom: number;
}
): Promise<HTMLCanvasElement | undefined> {
if (!(surfaceRenderer instanceof CanvasRenderer)) {
console.warn(
'ExportManager.edgelessToCanvas was called with an invalid renderer type. Expected CanvasRenderer.'
);
return undefined;
}
const rootModel = this.doc.root;
if (!rootModel) return;

View File

@@ -1,5 +1,6 @@
export * from './clipboard-config';
export * from './crud-extension';
export * from './dom-element-renderer';
export * from './edit-props-middleware-builder';
export * from './element-renderer';
export * from './export-manager';

View File

@@ -8,6 +8,7 @@ export {
} from './element-model/base.js';
export { CanvasElementType } from './element-model/index.js';
export { CanvasRenderer } from './renderer/canvas-renderer.js';
export { DomRenderer } from './renderer/dom-renderer.js';
export type { ElementRenderer } from './renderer/elements/index.js';
export * from './renderer/elements/type.js';
export { Overlay, OverlayIdentifier } from './renderer/overlay.js';

View File

@@ -152,7 +152,6 @@ export class CanvasRenderer {
: 1;
this.canvas.style.zIndex = maximumZIndex.toString();
for (let i = 0; i < canvasLayers.length; ++i) {
const layer = canvasLayers[i];
const created = i < currentCanvases.length;

View File

@@ -0,0 +1,31 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { SurfaceElementModel } from '../../element-model/base.js';
import type { DomRenderer } from '../dom-renderer.js';
/**
* Creates a unique identifier for a DomElementRenderer based on the element type.
* @param type - The type of the surface element (e.g., 'shape', 'text').
* @returns A ServiceIdentifier for the DI container.
*/
export const DomElementRendererIdentifier = (type: string) =>
createIdentifier<DomElementRenderer>(
`affine.surface.dom-element-renderer.${type}`
);
/**
* Defines the signature for a DOM element renderer function.
* Such a function is responsible for rendering a specific type of SurfaceElementModel
* into a given HTMLElement.
*
* @template T - The specific type of SurfaceElementModel this renderer handles.
* @param elementModel - The model of the element to render.
* @param domElement - The HTMLElement into which the element should be rendered.
* Basic properties like position and size (if not using placeholder)
* are expected to be set by the main DOMRenderer before this function is called.
* @param renderer - The instance of the main DOMRenderer, providing access to shared
* utilities like color providers and viewport information.
*/
export type DomElementRenderer<
T extends SurfaceElementModel = SurfaceElementModel,
> = (elementModel: T, domElement: HTMLElement, renderer: DomRenderer) => void;

View File

@@ -0,0 +1,487 @@
import {
type Color,
ColorScheme,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
type Bound,
getBoundWithRotation,
intersects,
} from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import type {
GfxCompatibleInterface,
GridManager,
LayerManager,
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { Subject } from 'rxjs';
import type { SurfaceElementModel } from '../element-model/base.js';
import type { DomElementRenderer } from './dom-elements/index.js';
import { DomElementRendererIdentifier } from './dom-elements/index.js';
import type { Overlay } from './overlay.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
getColorScheme: () => ColorScheme;
getColorValue: (color: Color, fallback?: Color, real?: boolean) => string;
getPropertyValue: (property: string) => string;
selectedElements?: () => string[];
};
type RendererOptions = {
std: BlockStdScope;
viewport: Viewport;
layerManager: LayerManager;
provider?: Partial<EnvProvider>;
gridManager: GridManager;
surfaceModel: SurfaceBlockModel;
};
const PLACEHOLDER_RESET_STYLES = {
border: 'none',
borderRadius: '0',
transform: '',
transformOrigin: '',
boxShadow: 'none',
opacity: '1',
};
function calculatePlaceholderRect(
elementModel: SurfaceElementModel,
viewportBounds: Bound,
zoom: number
) {
return {
left: `${(elementModel.x - viewportBounds.x) * zoom}px`,
top: `${(elementModel.y - viewportBounds.y) * zoom}px`,
width: `${elementModel.w * zoom}px`,
height: `${elementModel.h * zoom}px`,
};
}
function calculateFullElementRect(
elementModel: SurfaceElementModel,
viewportBounds: Bound,
zoom: number
) {
const dx = elementModel.x - viewportBounds.x;
const dy = elementModel.y - viewportBounds.y;
return {
left: `${dx * zoom}px`,
top: `${dy * zoom}px`,
};
}
function getOpacity(elementModel: SurfaceElementModel) {
return { opacity: `${elementModel.opacity ?? 1}` };
}
/**
* @class DomRenderer
* Renders surface elements directly to the DOM using HTML elements and CSS.
*
* This renderer supports an extension mechanism to handle different types of surface elements.
* To add rendering support for a new element type (e.g., 'my-custom-element'), follow these steps:
*
* 1. **Define the Renderer Function**:
* Create a function that implements the rendering logic for your element.
* This function will receive the element's model, the target HTMLElement, and the DomRenderer instance.
* Signature: `(model: MyCustomElementModel, domElement: HTMLElement, renderer: DomRenderer) => void;`
* Example: `shapeDomRenderer` in `blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts`.
* In this function, you'll apply styles and attributes to the `domElement` based on the `model`.
*
* 2. **Create the Renderer Extension**:
* Create a new file (e.g., `my-custom-element-dom-renderer.extension.ts`).
* Import `DomElementRendererExtension` (e.g., from `@blocksuite/affine-block-surface` or its source location
* `blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts`).
* Import your renderer function (from step 1).
* Use the factory to create your extension:
* `export const MyCustomElementDomRendererExtension = DomElementRendererExtension('my-custom-element', myCustomElementRendererFn);`
* Example: `ShapeDomRendererExtension` in `blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts`.
*
* 3. **Register the Extension**:
* In your application setup where BlockSuite services and view extensions are registered (e.g., a `ViewExtensionProvider`
* or a central DI configuration place), import your new extension (from step 2) and register it with the
* dependency injection container.
* Example: `context.register(MyCustomElementDomRendererExtension);`
* As seen with `ShapeDomRendererExtension` being registered in `blocksuite/affine/gfx/shape/src/view.ts`.
*
* 4. **Core Infrastructure (Provided by DomRenderer System)**:
* - `DomElementRenderer` (type): The function signature for renderers, defined in
* `blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts`.
* - `DomElementRendererIdentifier` (function): Creates unique service identifiers for DI,
* used by `DomRenderer` to look up specific renderers. Defined in the same file.
* - `DomElementRendererExtension` (factory): A helper to create extension objects for easy registration.
* (e.g., from `@blocksuite/affine-block-surface` or its source).
* - `DomRenderer._renderElement()`: This method automatically looks up the registered renderer using
* `DomElementRendererIdentifier(elementType)` and calls it if found.
*
* 5. **Ensure Exports**:
* - The `DomRenderer` class itself should be accessible (e.g., exported from `@blocksuite/affine/blocks/surface`).
* - The `DomElementRendererExtension` factory should be accessible.
*
* By following these steps, `DomRenderer` will automatically pick up and use your custom rendering logic
* when it encounters elements of 'my-custom-element' type.
*/
export class DomRenderer {
private _container!: HTMLElement;
private readonly _disposables = new DisposableGroup();
private readonly _turboEnabled: () => boolean;
private readonly _overlays = new Set<Overlay>();
private _refreshRafId: number | null = null;
private _sizeUpdatedRafId: number | null = null;
rootElement: HTMLElement;
private readonly _elementsMap = new Map<string, HTMLElement>();
std: BlockStdScope;
grid: GridManager;
layerManager: LayerManager;
provider: Partial<EnvProvider>;
usePlaceholder = false;
viewport: Viewport;
elementsUpdated = new Subject<{
elements: HTMLElement[];
added: HTMLElement[];
removed: HTMLElement[];
}>();
constructor(options: RendererOptions) {
this.rootElement = document.createElement('div');
this.rootElement.classList.add('dom-renderer-root');
this.rootElement.style.pointerEvents = 'none';
this.std = options.std;
this.viewport = options.viewport;
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
this._turboEnabled = () => {
const featureFlagService = options.std.get(FeatureFlagService);
return featureFlagService.getFlag('enable_turbo_renderer');
};
this._initViewport();
this._watchSurface(options.surfaceModel);
}
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this.refresh();
})
);
this._disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
if (this._sizeUpdatedRafId) return;
this._sizeUpdatedRafId = requestConnectedFrame(() => {
this._sizeUpdatedRafId = null;
this._resetSize();
this._render();
this.refresh();
}, this._container);
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this.refresh();
}
})
);
this.usePlaceholder = false;
}
private _resetSize() {
this.refresh();
}
private _renderElement(
elementModel: SurfaceElementModel,
domElement: HTMLElement
) {
const renderFn = this.std.getOptional<DomElementRenderer>(
DomElementRendererIdentifier(elementModel.type)
);
if (renderFn) {
renderFn(elementModel, domElement, this);
} else {
// If no specific renderer is found (e.g., for 'shape' if the extension isn't registered,
// or for other element types without a dedicated DOM renderer),
// no specific DOM styling will be applied here by _renderElement.
// Basic properties like position/size are handled in the _render loop if usePlaceholder is false.
console.warn(
`No DOM renderer found for element type: ${elementModel.type}`
);
}
}
private _renderOrUpdatePlaceholder(
elementModel: SurfaceElementModel,
viewportBounds: Bound,
zoom: number,
addedElements: HTMLElement[]
) {
let domElement = this._elementsMap.get(elementModel.id);
if (!domElement) {
domElement = document.createElement('div');
domElement.dataset.elementId = elementModel.id;
domElement.style.position = 'absolute';
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
this._elementsMap.set(elementModel.id, domElement);
this.rootElement.append(domElement);
addedElements.push(domElement);
}
const geometricStyles = calculatePlaceholderRect(
elementModel,
viewportBounds,
zoom
);
Object.assign(domElement.style, geometricStyles);
Object.assign(domElement.style, PLACEHOLDER_RESET_STYLES);
// Clear classes specific to shapes, if applicable
if (elementModel.type === 'shape') {
const shapeModel = elementModel as ShapeElementModel;
domElement.classList.remove(`shape-${shapeModel.shapeType}`);
domElement.classList.remove(
`shape-style-${shapeModel.shapeStyle.toLowerCase()}`
);
}
}
private _renderOrUpdateFullElement(
elementModel: SurfaceElementModel,
viewportBounds: Bound,
zoom: number,
addedElements: HTMLElement[]
) {
let domElement = this._elementsMap.get(elementModel.id);
if (!domElement) {
domElement = document.createElement('div');
domElement.dataset.elementId = elementModel.id;
domElement.style.position = 'absolute';
domElement.style.transformOrigin = 'top left';
this._elementsMap.set(elementModel.id, domElement);
this.rootElement.append(domElement);
addedElements.push(domElement);
}
const geometricStyles = calculateFullElementRect(
elementModel,
viewportBounds,
zoom
);
const opacityStyle = getOpacity(elementModel);
Object.assign(domElement.style, geometricStyles, opacityStyle);
this._renderElement(elementModel, domElement);
}
private _render() {
const { viewportBounds, zoom } = this.viewport;
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
// Step 1: Handle elements whose models are deleted from the surface
const prevRenderedElementIds = Array.from(this._elementsMap.keys());
for (const id of prevRenderedElementIds) {
const modelExists = this.layerManager.layers.some(layer =>
layer.elements.some(elem => (elem as SurfaceElementModel).id === id)
);
if (!modelExists) {
const domElem = this._elementsMap.get(id);
if (domElem) {
domElem.remove();
this._elementsMap.delete(id);
elementsToRemove.push(domElem);
}
}
}
// Step 2: Render elements in the current viewport
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const visibleElementIds = new Set<string>();
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
// Full render
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
}
// Step 3: Remove DOM elements that are in _elementsMap but were not processed in Step 2
const currentRenderedElementIds = Array.from(this._elementsMap.keys());
for (const id of currentRenderedElementIds) {
if (!visibleElementIds.has(id)) {
const domElem = this._elementsMap.get(id);
if (domElem) {
domElem.remove();
this._elementsMap.delete(id);
if (!elementsToRemove.includes(domElem)) {
elementsToRemove.push(domElem);
}
}
}
}
// Step 4: Notify about changes
if (addedElements.length > 0 || elementsToRemove.length > 0) {
this.elementsUpdated.next({
elements: Array.from(this._elementsMap.values()),
added: addedElements,
removed: elementsToRemove,
});
}
}
private _watchSurface(surfaceModel: SurfaceBlockModel) {
this._disposables.add(
surfaceModel.elementAdded.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.elementRemoved.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementDeleted.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.elementUpdated.subscribe(payload => {
// ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return;
this.refresh();
})
);
}
addOverlay(overlay: Overlay) {
overlay.setRenderer(null);
this._overlays.add(overlay);
this.refresh();
}
attach(container: HTMLElement) {
this._container = container;
container.append(this.rootElement);
this._resetSize();
this.refresh();
}
dispose(): void {
this._overlays.forEach(overlay => overlay.dispose());
this._overlays.clear();
this._disposables.dispose();
if (this._refreshRafId) {
cancelAnimationFrame(this._refreshRafId);
this._refreshRafId = null;
}
if (this._sizeUpdatedRafId) {
cancelAnimationFrame(this._sizeUpdatedRafId);
this._sizeUpdatedRafId = null;
}
this.rootElement.remove();
this._elementsMap.clear();
}
generateColorProperty(color: Color, fallback?: Color) {
return (
this.provider.generateColorProperty?.(color, fallback) ?? 'transparent'
);
}
getColorScheme() {
return this.provider.getColorScheme?.() ?? ColorScheme.Light;
}
getColorValue(color: Color, fallback?: Color, real?: boolean) {
return (
this.provider.getColorValue?.(color, fallback, real) ?? 'transparent'
);
}
getPropertyValue(property: string) {
return this.provider.getPropertyValue?.(property) ?? '';
}
refresh() {
if (this._refreshRafId !== null) return;
this._refreshRafId = requestConnectedFrame(() => {
this._refreshRafId = null;
this._render();
}, this._container);
}
removeOverlay(overlay: Overlay) {
if (!this._overlays.has(overlay)) {
return;
}
this._overlays.delete(overlay);
this.refresh();
}
}

View File

@@ -1,5 +1,8 @@
import type { Color } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { Bound } from '@blocksuite/global/gfx';
import type { EditorHost, SurfaceSelection } from '@blocksuite/std';
import { BlockComponent } from '@blocksuite/std';
@@ -11,6 +14,7 @@ import type { Subject } from 'rxjs';
import { ConnectorElementModel } from './element-model/index.js';
import { CanvasRenderer } from './renderer/canvas-renderer.js';
import { DomRenderer } from './renderer/dom-renderer.js';
import { OverlayIdentifier } from './renderer/overlay.js';
import type { SurfaceBlockModel } from './surface-model.js';
@@ -34,6 +38,7 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
.affine-edgeless-surface-block-container {
width: 100%;
height: 100%;
position: relative;
}
.affine-edgeless-surface-block-container canvas {
@@ -99,7 +104,7 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
private _lastTime = 0;
private _renderer!: CanvasRenderer;
private _renderer!: CanvasRenderer | DomRenderer;
fitToViewport = (bound: Bound) => {
const { viewport } = this._gfx;
@@ -141,13 +146,14 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
private _initRenderer() {
const gfx = this._gfx;
const themeService = this.std.get(ThemeProvider);
const featureFlagService = this.std.get(FeatureFlagService);
const useDOMRenderer = featureFlagService.getFlag('enable_dom_renderer');
this._renderer = new CanvasRenderer({
const rendererOptions = {
std: this.std,
viewport: gfx.viewport,
layerManager: gfx.layer,
gridManager: gfx.grid,
enableStackingCanvas: true,
provider: {
generateColorProperty: (color: Color, fallback?: Color) =>
themeService.generateColorProperty(
@@ -170,28 +176,40 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
),
selectedElements: () => gfx.selection.selectedIds,
},
onStackingCanvasCreated(canvas) {
canvas.className = 'indexable-canvas';
},
surfaceModel: this.model,
});
};
this._disposables.add(() => {
this._renderer.dispose();
});
this._disposables.add(
this._renderer.stackingCanvasUpdated.subscribe(payload => {
if (payload.added.length) {
this._surfaceContainer.append(...payload.added);
}
if (useDOMRenderer) {
this._renderer = new DomRenderer(rendererOptions);
this._disposables.add(() => this._renderer.dispose());
} else {
this._renderer = new CanvasRenderer({
...rendererOptions,
enableStackingCanvas: true,
onStackingCanvasCreated(canvas) {
canvas.className = 'indexable-canvas';
},
});
this._disposables.add(() => {
this._renderer.dispose();
});
this._disposables.add(
this._renderer.stackingCanvasUpdated.subscribe(payload => {
if (payload.added.length) {
this._surfaceContainer.append(...payload.added);
}
if (payload.removed.length) {
payload.removed.forEach(canvas => {
canvas.remove();
});
}
})
);
}
if (payload.removed.length) {
payload.removed.forEach(canvas => {
canvas.remove();
});
}
})
);
this._disposables.add(
gfx.selection.slots.updated.subscribe(() => {
this._renderer.refresh();
@@ -211,8 +229,11 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
override firstUpdated() {
this._renderer.attach(this._surfaceContainer);
this._renderer['_render']();
this._surfaceContainer.append(...this._renderer.stackingCanvas);
if ('stackingCanvas' in this._renderer) {
this._renderer['_render']();
this._surfaceContainer.append(...this._renderer.stackingCanvas);
}
}
override render() {