From 9cabe03386c472da3eecf198fcbcc55622e5acc0 Mon Sep 17 00:00:00 2001 From: Yifeng Wang Date: Wed, 14 May 2025 16:22:16 +0800 Subject: [PATCH] refactor(editor): add dom renderer entry for canvas element (#12149) --- .../root/src/edgeless/clipboard/clipboard.ts | 9 + .../src/extensions/dom-element-renderer.ts | 30 ++ .../export-manager/export-manager.ts | 12 +- .../blocks/surface/src/extensions/index.ts | 1 + blocksuite/affine/blocks/surface/src/index.ts | 1 + .../surface/src/renderer/canvas-renderer.ts | 1 - .../src/renderer/dom-elements/index.ts | 31 ++ .../surface/src/renderer/dom-renderer.ts | 487 ++++++++++++++++++ .../blocks/surface/src/surface-block.ts | 71 ++- .../src/interactivity/mind-map-drag.ts | 13 +- .../shape/src/element-renderer/shape-dom.ts | 11 + .../src/element-renderer/shape-dom/index.ts | 100 ++++ .../src/element-renderer/shape-dom/utils.ts | 59 +++ blocksuite/affine/gfx/shape/src/view.ts | 2 + .../src/services/feature-flag-service.ts | 2 + blocksuite/framework/std/src/gfx/layer.ts | 5 +- .../src/__tests__/edgeless/layer.spec.ts | 13 +- .../src/__tests__/edgeless/shape-dom.spec.ts | 72 +++ .../src/__tests__/utils/setup.ts | 25 +- .../core/src/modules/feature-flag/constant.ts | 8 + 20 files changed, 911 insertions(+), 42 deletions(-) create mode 100644 blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts create mode 100644 blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts create mode 100644 blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts create mode 100644 blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts create mode 100644 blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts create mode 100644 blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/utils.ts create mode 100644 blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts diff --git a/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts b/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts index 1cce82fc0..9a5282cc9 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/clipboard/clipboard.ts @@ -2,6 +2,7 @@ import { addAttachments } from '@blocksuite/affine-block-attachment'; import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame'; import { addImages } from '@blocksuite/affine-block-image'; import { + CanvasRenderer, DefaultTool, EdgelessCRUDIdentifier, ExportManager, @@ -509,6 +510,14 @@ export class EdgelessClipboardController extends PageClipboard { this._checkCanContinueToCanvas(host, pathname, editorMode); } + // TODO: handle DOM renderer case for clipboard image generation + if (!(this.surface.renderer instanceof CanvasRenderer)) { + console.warn( + 'Skipping canvas generation for clipboard: DOM renderer active.' + ); + return canvas; // Return the empty canvas or handle error + } + const surfaceCanvas = this.surface.renderer.getCanvasByBound( bound, canvasElements diff --git a/blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts b/blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts new file mode 100644 index 000000000..af4d4a905 --- /dev/null +++ b/blocksuite/affine/blocks/surface/src/extensions/dom-element-renderer.ts @@ -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 +): ExtensionType => { + return { + setup: di => { + di.addValue( + DomElementRendererIdentifier(elementType), + implementation as DomElementRenderer + ); + }, + }; +}; diff --git a/blocksuite/affine/blocks/surface/src/extensions/export-manager/export-manager.ts b/blocksuite/affine/blocks/surface/src/extensions/export-manager/export-manager.ts index 9e437b2f7..5b853c55b 100644 --- a/blocksuite/affine/blocks/surface/src/extensions/export-manager/export-manager.ts +++ b/blocksuite/affine/blocks/surface/src/extensions/export-manager/export-manager.ts @@ -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 { + 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; diff --git a/blocksuite/affine/blocks/surface/src/extensions/index.ts b/blocksuite/affine/blocks/surface/src/extensions/index.ts index 5fb4454c5..ea7691685 100644 --- a/blocksuite/affine/blocks/surface/src/extensions/index.ts +++ b/blocksuite/affine/blocks/surface/src/extensions/index.ts @@ -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'; diff --git a/blocksuite/affine/blocks/surface/src/index.ts b/blocksuite/affine/blocks/surface/src/index.ts index dc6224d11..36d38a2ba 100644 --- a/blocksuite/affine/blocks/surface/src/index.ts +++ b/blocksuite/affine/blocks/surface/src/index.ts @@ -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'; diff --git a/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts index fcac695b6..3ecb7f902 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts @@ -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; diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts new file mode 100644 index 000000000..057159445 --- /dev/null +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-elements/index.ts @@ -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( + `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; diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts new file mode 100644 index 000000000..cf9b4b6f0 --- /dev/null +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts @@ -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; + 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(); + + private _refreshRafId: number | null = null; + + private _sizeUpdatedRafId: number | null = null; + + rootElement: HTMLElement; + + private readonly _elementsMap = new Map(); + + std: BlockStdScope; + + grid: GridManager; + + layerManager: LayerManager; + + provider: Partial; + + 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( + 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(); + + 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(); + } +} diff --git a/blocksuite/affine/blocks/surface/src/surface-block.ts b/blocksuite/affine/blocks/surface/src/surface-block.ts index d699d2ad9..297255384 100644 --- a/blocksuite/affine/blocks/surface/src/surface-block.ts +++ b/blocksuite/affine/blocks/surface/src/surface-block.ts @@ -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 { .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 { 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 { 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 { ), 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 { 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() { diff --git a/blocksuite/affine/gfx/mindmap/src/interactivity/mind-map-drag.ts b/blocksuite/affine/gfx/mindmap/src/interactivity/mind-map-drag.ts index c14b4f6e1..3eb3c29d4 100644 --- a/blocksuite/affine/gfx/mindmap/src/interactivity/mind-map-drag.ts +++ b/blocksuite/affine/gfx/mindmap/src/interactivity/mind-map-drag.ts @@ -1,4 +1,5 @@ import { + CanvasRenderer, OverlayIdentifier, type SurfaceBlockComponent, } from '@blocksuite/affine-block-surface'; @@ -315,8 +316,16 @@ export class MindMapDragExtension extends InteractivityExtension { const renderer = surfaceBlock?.renderer; const indicatorOverlay = this._indicatorOverlay; - if (!renderer || !indicatorOverlay) { - return; + // TODO: handle DOM renderer case for mindmap drag image + if ( + !renderer || + !(renderer instanceof CanvasRenderer) || + !indicatorOverlay + ) { + console.warn( + 'Skipping drag image setup: DOM renderer or overlay missing.' + ); + return () => {}; // Return an empty cleanup function } const nodeBound = mindmapNode.element.elementBound; diff --git a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts new file mode 100644 index 000000000..12daaf973 --- /dev/null +++ b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom.ts @@ -0,0 +1,11 @@ +import { DomElementRendererExtension } from '@blocksuite/affine-block-surface'; + +import { shapeDomRenderer } from './shape-dom/index.js'; + +/** + * Extension to register the DOM-based renderer for 'shape' elements. + */ +export const ShapeDomRendererExtension = DomElementRendererExtension( + 'shape', + shapeDomRenderer +); diff --git a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts new file mode 100644 index 000000000..5a9078d28 --- /dev/null +++ b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts @@ -0,0 +1,100 @@ +import type { DomRenderer } from '@blocksuite/affine-block-surface'; +import type { ShapeElementModel } from '@blocksuite/affine-model'; +import { DefaultTheme } from '@blocksuite/affine-model'; + +import { manageClassNames, setStyles } from './utils'; + +function applyShapeSpecificStyles( + model: ShapeElementModel, + element: HTMLElement +) { + if (model.shapeType === 'rect') { + element.style.borderRadius = `${model.radius ?? 0}px`; + } else if (model.shapeType === 'ellipse') { + element.style.borderRadius = '50%'; + } else { + element.style.borderRadius = ''; + } +} + +function applyBorderStyles( + model: ShapeElementModel, + element: HTMLElement, + strokeColor: string +) { + element.style.border = + model.strokeStyle !== 'none' + ? `${model.strokeWidth}px ${model.strokeStyle === 'dash' ? 'dashed' : 'solid'} ${strokeColor}` + : 'none'; +} + +function applyTransformStyles(model: ShapeElementModel, element: HTMLElement) { + if (model.rotate && model.rotate !== 0) { + setStyles(element, { + transform: `rotate(${model.rotate}deg)`, + transformOrigin: 'center', + }); + } else { + setStyles(element, { + transform: '', + transformOrigin: '', + }); + } +} + +function applyShadowStyles( + model: ShapeElementModel, + element: HTMLElement, + renderer: DomRenderer +) { + if (model.shadow) { + const { offsetX, offsetY, blur, color } = model.shadow; + setStyles(element, { + boxShadow: `${offsetX}px ${offsetY}px ${blur}px ${renderer.getColorValue(color)}`, + }); + } else { + setStyles(element, { boxShadow: '' }); + } +} + +/** + * Renders a ShapeElementModel to a given HTMLElement using DOM properties. + * This function is intended to be registered via the DomElementRendererExtension. + * + * @param model - The shape element model containing rendering properties. + * @param element - The HTMLElement to apply the shape's styles to. + * @param renderer - The main DOMRenderer instance, providing access to viewport and color utilities. + */ +export const shapeDomRenderer = ( + model: ShapeElementModel, + element: HTMLElement, + renderer: DomRenderer +): void => { + const { zoom } = renderer.viewport; + const fillColor = renderer.getColorValue( + model.fillColor, + DefaultTheme.shapeFillColor, + true + ); + const strokeColor = renderer.getColorValue( + model.strokeColor, + DefaultTheme.shapeStrokeColor, + true + ); + + element.style.width = `${model.w * zoom}px`; + element.style.height = `${model.h * zoom}px`; + + applyShapeSpecificStyles(model, element); + + element.style.backgroundColor = model.filled ? fillColor : 'transparent'; + + applyBorderStyles(model, element, strokeColor); + applyTransformStyles(model, element); + + element.style.boxSizing = 'border-box'; + element.style.zIndex = renderer.layerManager.getZIndex(model).toString(); + + manageClassNames(model, element); + applyShadowStyles(model, element, renderer); +}; diff --git a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/utils.ts b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/utils.ts new file mode 100644 index 000000000..f0df60169 --- /dev/null +++ b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/utils.ts @@ -0,0 +1,59 @@ +import type { ShapeElementModel } from '@blocksuite/affine-model'; + +/** + * Utility to manage a class on an element, tracking the previous class via dataset. + * If the new class is different from the previous one (stored in dataset), + * the previous class is removed. The new class is added if not already present. + * The dataset is then updated with the new class name. + * + * @param element The HTMLElement to update. + * @param newClassName The new class name to apply. + * @param datasetKeyForPreviousClass The key in `element.dataset` used to store the previous class name. + */ +function updateClass( + element: HTMLElement, + newClassName: string, + datasetKeyForPreviousClass: string +): void { + const previousClassName = element.dataset[datasetKeyForPreviousClass]; + + if (previousClassName && previousClassName !== newClassName) { + element.classList.remove(previousClassName); + } + + if (!element.classList.contains(newClassName)) { + element.classList.add(newClassName); + } + + element.dataset[datasetKeyForPreviousClass] = newClassName; +} + +/** + * Utility to set multiple CSS styles on an HTMLElement. + * + * @param element The HTMLElement to apply styles to. + * @param styles An object where keys are camelCased CSS property names and values are their string values. + */ +export function setStyles( + element: HTMLElement, + styles: Record +): void { + for (const property in styles) { + if (Object.prototype.hasOwnProperty.call(styles, property)) { + // Using `any` for `element.style` index is a common practice for dynamic style assignment. + // Assumes `property` is a valid camelCased CSS property. + (element.style as any)[property] = styles[property]; + } + } +} + +export function manageClassNames( + model: ShapeElementModel, + element: HTMLElement +) { + const currentShapeTypeClass = `shape-${model.shapeType}`; + const currentShapeStyleClass = `shape-style-${model.shapeStyle.toLowerCase()}`; + + updateClass(element, currentShapeTypeClass, 'prevShapeTypeClass'); + updateClass(element, currentShapeStyleClass, 'prevShapeStyleClass'); +} diff --git a/blocksuite/affine/gfx/shape/src/view.ts b/blocksuite/affine/gfx/shape/src/view.ts index c9c88da94..37d2da932 100644 --- a/blocksuite/affine/gfx/shape/src/view.ts +++ b/blocksuite/affine/gfx/shape/src/view.ts @@ -8,6 +8,7 @@ import { HighlighterElementRendererExtension, ShapeElementRendererExtension, } from './element-renderer'; +import { ShapeDomRendererExtension } from './element-renderer/shape-dom'; import { ShapeElementView, ShapeViewInteraction } from './element-view'; import { ShapeTool } from './shape-tool'; import { shapeSeniorTool, shapeToolbarExtension } from './toolbar'; @@ -25,6 +26,7 @@ export class ShapeViewExtension extends ViewExtensionProvider { if (this.isEdgeless(context.scope)) { context.register(HighlighterElementRendererExtension); context.register(ShapeElementRendererExtension); + context.register(ShapeDomRendererExtension); context.register(ShapeElementView); context.register(ShapeTool); context.register(shapeSeniorTool); diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index 77fad1c5e..155b762c7 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -23,6 +23,7 @@ export interface BlockSuiteFlags { enable_turbo_renderer: boolean; enable_citation: boolean; enable_link_preview_cache: boolean; + enable_dom_renderer: boolean; } export class FeatureFlagService extends StoreExtension { @@ -50,6 +51,7 @@ export class FeatureFlagService extends StoreExtension { enable_turbo_renderer: false, enable_citation: false, enable_link_preview_cache: false, + enable_dom_renderer: false, }); setFlag(key: keyof BlockSuiteFlags, value: boolean) { diff --git a/blocksuite/framework/std/src/gfx/layer.ts b/blocksuite/framework/std/src/gfx/layer.ts index 51732ad3a..239ed4cbf 100644 --- a/blocksuite/framework/std/src/gfx/layer.ts +++ b/blocksuite/framework/std/src/gfx/layer.ts @@ -165,7 +165,6 @@ export class LayerManager extends GfxExtension { ]; curLayer.zIndex = currentCSSZindex; layers.push(curLayer as LayerManager['layers'][number]); - currentCSSZindex += curLayer.type === 'block' ? curLayer.elements.length : 1; } @@ -772,9 +771,7 @@ export class LayerManager extends GfxExtension { getZIndex(element: GfxModel): number { // @ts-expect-error FIXME: ts error const layer = this.layers.find(layer => layer.set.has(element)); - - if (!layer) return -1; - + if (!layer) return 0; // @ts-expect-error FIXME: ts error return layer.zIndex + layer.elements.indexOf(element); } diff --git a/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts index d8080e1af..984db465b 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts @@ -1,5 +1,8 @@ import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root'; -import type { SurfaceElementModel } from '@blocksuite/affine/blocks/surface'; +import type { + CanvasRenderer, + SurfaceElementModel, +} from '@blocksuite/affine/blocks/surface'; import { ungroupCommand } from '@blocksuite/affine/gfx/group'; import type { GroupElementModel, @@ -789,8 +792,12 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge '.indexable-canvas' )[0] as HTMLCanvasElement; - expect(indexedCanvas.width).toBe(surface.renderer.canvas.width); - expect(indexedCanvas.height).toBe(surface.renderer.canvas.height); + expect(indexedCanvas.width).toBe( + (surface.renderer as CanvasRenderer).canvas.width + ); + expect(indexedCanvas.height).toBe( + (surface.renderer as CanvasRenderer).canvas.height + ); expect(indexedCanvas.width).not.toBe(0); expect(indexedCanvas.height).not.toBe(0); }); diff --git a/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts new file mode 100644 index 000000000..ce6580afd --- /dev/null +++ b/blocksuite/integration-test/src/__tests__/edgeless/shape-dom.spec.ts @@ -0,0 +1,72 @@ +import { DomRenderer } from '@blocksuite/affine-block-surface'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { getSurface } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('Shape rendering with DOM renderer', () => { + beforeEach(async () => { + const cleanup = await setupEditor('edgeless', [], { + enableDomRenderer: true, + }); + return cleanup; + }); + + test('should use DomRenderer when enable_dom_renderer flag is true', async () => { + const surface = getSurface(doc, editor); + expect(surface).not.toBeNull(); + expect(surface?.renderer).toBeInstanceOf(DomRenderer); + }); + + test('should render a shape element as a DOM node', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + const shapeProps = { + type: 'shape', + subType: 'rectangle', + xywh: '[150, 150, 80, 60]', + fill: '#ff0000', + stroke: '#000000', + }; + const shapeId = surfaceModel.addElement(shapeProps as any); + + await new Promise(resolve => setTimeout(resolve, 100)); + const shapeElement = surfaceView?.renderRoot.querySelector( + `[data-element-id="${shapeId}"]` + ); + + expect(shapeElement).not.toBeNull(); + expect(shapeElement).toBeInstanceOf(HTMLElement); + }); + + test('should remove shape DOM node when element is deleted', async () => { + const surfaceView = getSurface(window.doc, window.editor); + const surfaceModel = surfaceView.model; + + expect(surfaceView.renderer).toBeInstanceOf(DomRenderer); + + const shapeProps = { + type: 'shape', + subType: 'ellipse', + xywh: '[200, 200, 50, 50]', + }; + const shapeId = surfaceModel.addElement(shapeProps as any); + + await new Promise(resolve => setTimeout(resolve, 100)); + + let shapeElement = surfaceView.renderRoot.querySelector( + `[data-element-id="${shapeId}"]` + ); + expect(shapeElement).not.toBeNull(); + + surfaceModel.deleteElement(shapeId); + + await new Promise(resolve => setTimeout(resolve, 100)); + + shapeElement = surfaceView.renderRoot.querySelector( + `[data-element-id="${shapeId}"]` + ); + expect(shapeElement).toBeNull(); + }); +}); diff --git a/blocksuite/integration-test/src/__tests__/utils/setup.ts b/blocksuite/integration-test/src/__tests__/utils/setup.ts index 75d648460..ed950931e 100644 --- a/blocksuite/integration-test/src/__tests__/utils/setup.ts +++ b/blocksuite/integration-test/src/__tests__/utils/setup.ts @@ -5,6 +5,7 @@ import type { DocMode } from '@blocksuite/affine/model'; import { AffineSchemas } from '@blocksuite/affine/schemas'; import { CommunityCanvasTextFonts, + FeatureFlagService, FontConfigExtension, } from '@blocksuite/affine/shared/services'; import { @@ -47,11 +48,6 @@ function createCollectionOptions() { id: room, schema, idGenerator, - defaultFlags: { - readonly: { - 'doc:home': false, - }, - }, }; } @@ -115,10 +111,20 @@ export function createPainterWorker() { return worker; } +type SetupEditorOptions = { + extensions?: ExtensionType[]; + enableDomRenderer?: boolean; +}; + export async function setupEditor( mode: DocMode = 'page', - extensions: ExtensionType[] = [] + extensionsInput?: ExtensionType[], + optionsInput?: SetupEditorOptions ) { + const extensions: ExtensionType[] = extensionsInput ?? []; + const options: SetupEditorOptions = optionsInput ?? {}; + const enableDomRenderer = options?.enableDomRenderer ?? false; + const collection = new TestWorkspace(createCollectionOptions()); collection.storeExtensions = storeExtensions; collection.meta.initialize(); @@ -126,6 +132,13 @@ export async function setupEditor( window.collection = collection; initCollection(collection); + + if (enableDomRenderer) { + const docStore = window.collection.docs.get('doc:home')?.getStore(); + const featureFlagService = docStore?.get(FeatureFlagService); + featureFlagService?.setFlag('enable_dom_renderer', true); + } + const appElement = await createEditor(collection, mode, extensions); return () => { diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 547990d40..9cd6e9ac7 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -250,6 +250,14 @@ export const AFFINE_FLAGS = { configurable: isCanaryBuild, defaultState: false, }, + enable_dom_renderer: { + category: 'blocksuite', + bsFlag: 'enable_dom_renderer', + displayName: 'Enable DOM Renderer', + description: 'Enable DOM renderer for graphics elements', + configurable: isCanaryBuild, + defaultState: false, + }, enable_edgeless_scribbled_style: { category: 'blocksuite', bsFlag: 'enable_edgeless_scribbled_style',