mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
refactor(editor): add dom renderer entry for canvas element (#12149)
This commit is contained in:
@@ -2,6 +2,7 @@ import { addAttachments } from '@blocksuite/affine-block-attachment';
|
|||||||
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
|
||||||
import { addImages } from '@blocksuite/affine-block-image';
|
import { addImages } from '@blocksuite/affine-block-image';
|
||||||
import {
|
import {
|
||||||
|
CanvasRenderer,
|
||||||
DefaultTool,
|
DefaultTool,
|
||||||
EdgelessCRUDIdentifier,
|
EdgelessCRUDIdentifier,
|
||||||
ExportManager,
|
ExportManager,
|
||||||
@@ -509,6 +510,14 @@ export class EdgelessClipboardController extends PageClipboard {
|
|||||||
this._checkCanContinueToCanvas(host, pathname, editorMode);
|
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(
|
const surfaceCanvas = this.surface.renderer.getCanvasByBound(
|
||||||
bound,
|
bound,
|
||||||
canvasElements
|
canvasElements
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
import type { ExtensionType, Store } from '@blocksuite/store';
|
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 type { SurfaceBlockComponent } from '../../surface-block.js';
|
||||||
import { getBgGridGap } from '../../utils/get-bg-grip-gap.js';
|
import { getBgGridGap } from '../../utils/get-bg-grip-gap.js';
|
||||||
import { FileExporter } from './file-exporter.js';
|
import { FileExporter } from './file-exporter.js';
|
||||||
@@ -358,6 +358,9 @@ export class ExportManager {
|
|||||||
const surfaceBlock = gfx.surfaceComponent as SurfaceBlockComponent | null;
|
const surfaceBlock = gfx.surfaceComponent as SurfaceBlockComponent | null;
|
||||||
if (!surfaceBlock) return;
|
if (!surfaceBlock) return;
|
||||||
const bound = gfx.elementsBound;
|
const bound = gfx.elementsBound;
|
||||||
|
if (!(surfaceBlock.renderer instanceof CanvasRenderer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return this.edgelessToCanvas(surfaceBlock.renderer, bound, gfx);
|
return this.edgelessToCanvas(surfaceBlock.renderer, bound, gfx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,6 +376,13 @@ export class ExportManager {
|
|||||||
zoom: number;
|
zoom: number;
|
||||||
}
|
}
|
||||||
): Promise<HTMLCanvasElement | undefined> {
|
): 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;
|
const rootModel = this.doc.root;
|
||||||
if (!rootModel) return;
|
if (!rootModel) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './clipboard-config';
|
export * from './clipboard-config';
|
||||||
export * from './crud-extension';
|
export * from './crud-extension';
|
||||||
|
export * from './dom-element-renderer';
|
||||||
export * from './edit-props-middleware-builder';
|
export * from './edit-props-middleware-builder';
|
||||||
export * from './element-renderer';
|
export * from './element-renderer';
|
||||||
export * from './export-manager';
|
export * from './export-manager';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
} from './element-model/base.js';
|
} from './element-model/base.js';
|
||||||
export { CanvasElementType } from './element-model/index.js';
|
export { CanvasElementType } from './element-model/index.js';
|
||||||
export { CanvasRenderer } from './renderer/canvas-renderer.js';
|
export { CanvasRenderer } from './renderer/canvas-renderer.js';
|
||||||
|
export { DomRenderer } from './renderer/dom-renderer.js';
|
||||||
export type { ElementRenderer } from './renderer/elements/index.js';
|
export type { ElementRenderer } from './renderer/elements/index.js';
|
||||||
export * from './renderer/elements/type.js';
|
export * from './renderer/elements/type.js';
|
||||||
export { Overlay, OverlayIdentifier } from './renderer/overlay.js';
|
export { Overlay, OverlayIdentifier } from './renderer/overlay.js';
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export class CanvasRenderer {
|
|||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
this.canvas.style.zIndex = maximumZIndex.toString();
|
this.canvas.style.zIndex = maximumZIndex.toString();
|
||||||
|
|
||||||
for (let i = 0; i < canvasLayers.length; ++i) {
|
for (let i = 0; i < canvasLayers.length; ++i) {
|
||||||
const layer = canvasLayers[i];
|
const layer = canvasLayers[i];
|
||||||
const created = i < currentCanvases.length;
|
const created = i < currentCanvases.length;
|
||||||
|
|||||||
@@ -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;
|
||||||
487
blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts
Normal file
487
blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Color } from '@blocksuite/affine-model';
|
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 { Bound } from '@blocksuite/global/gfx';
|
||||||
import type { EditorHost, SurfaceSelection } from '@blocksuite/std';
|
import type { EditorHost, SurfaceSelection } from '@blocksuite/std';
|
||||||
import { BlockComponent } 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 { ConnectorElementModel } from './element-model/index.js';
|
||||||
import { CanvasRenderer } from './renderer/canvas-renderer.js';
|
import { CanvasRenderer } from './renderer/canvas-renderer.js';
|
||||||
|
import { DomRenderer } from './renderer/dom-renderer.js';
|
||||||
import { OverlayIdentifier } from './renderer/overlay.js';
|
import { OverlayIdentifier } from './renderer/overlay.js';
|
||||||
import type { SurfaceBlockModel } from './surface-model.js';
|
import type { SurfaceBlockModel } from './surface-model.js';
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
|||||||
.affine-edgeless-surface-block-container {
|
.affine-edgeless-surface-block-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.affine-edgeless-surface-block-container canvas {
|
.affine-edgeless-surface-block-container canvas {
|
||||||
@@ -99,7 +104,7 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
|||||||
|
|
||||||
private _lastTime = 0;
|
private _lastTime = 0;
|
||||||
|
|
||||||
private _renderer!: CanvasRenderer;
|
private _renderer!: CanvasRenderer | DomRenderer;
|
||||||
|
|
||||||
fitToViewport = (bound: Bound) => {
|
fitToViewport = (bound: Bound) => {
|
||||||
const { viewport } = this._gfx;
|
const { viewport } = this._gfx;
|
||||||
@@ -141,13 +146,14 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
|||||||
private _initRenderer() {
|
private _initRenderer() {
|
||||||
const gfx = this._gfx;
|
const gfx = this._gfx;
|
||||||
const themeService = this.std.get(ThemeProvider);
|
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,
|
std: this.std,
|
||||||
viewport: gfx.viewport,
|
viewport: gfx.viewport,
|
||||||
layerManager: gfx.layer,
|
layerManager: gfx.layer,
|
||||||
gridManager: gfx.grid,
|
gridManager: gfx.grid,
|
||||||
enableStackingCanvas: true,
|
|
||||||
provider: {
|
provider: {
|
||||||
generateColorProperty: (color: Color, fallback?: Color) =>
|
generateColorProperty: (color: Color, fallback?: Color) =>
|
||||||
themeService.generateColorProperty(
|
themeService.generateColorProperty(
|
||||||
@@ -170,28 +176,40 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
|||||||
),
|
),
|
||||||
selectedElements: () => gfx.selection.selectedIds,
|
selectedElements: () => gfx.selection.selectedIds,
|
||||||
},
|
},
|
||||||
onStackingCanvasCreated(canvas) {
|
|
||||||
canvas.className = 'indexable-canvas';
|
|
||||||
},
|
|
||||||
surfaceModel: this.model,
|
surfaceModel: this.model,
|
||||||
});
|
};
|
||||||
|
|
||||||
this._disposables.add(() => {
|
if (useDOMRenderer) {
|
||||||
this._renderer.dispose();
|
this._renderer = new DomRenderer(rendererOptions);
|
||||||
});
|
this._disposables.add(() => this._renderer.dispose());
|
||||||
this._disposables.add(
|
} else {
|
||||||
this._renderer.stackingCanvasUpdated.subscribe(payload => {
|
this._renderer = new CanvasRenderer({
|
||||||
if (payload.added.length) {
|
...rendererOptions,
|
||||||
this._surfaceContainer.append(...payload.added);
|
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(
|
this._disposables.add(
|
||||||
gfx.selection.slots.updated.subscribe(() => {
|
gfx.selection.slots.updated.subscribe(() => {
|
||||||
this._renderer.refresh();
|
this._renderer.refresh();
|
||||||
@@ -211,8 +229,11 @@ export class SurfaceBlockComponent extends BlockComponent<SurfaceBlockModel> {
|
|||||||
|
|
||||||
override firstUpdated() {
|
override firstUpdated() {
|
||||||
this._renderer.attach(this._surfaceContainer);
|
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() {
|
override render() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CanvasRenderer,
|
||||||
OverlayIdentifier,
|
OverlayIdentifier,
|
||||||
type SurfaceBlockComponent,
|
type SurfaceBlockComponent,
|
||||||
} from '@blocksuite/affine-block-surface';
|
} from '@blocksuite/affine-block-surface';
|
||||||
@@ -315,8 +316,16 @@ export class MindMapDragExtension extends InteractivityExtension {
|
|||||||
const renderer = surfaceBlock?.renderer;
|
const renderer = surfaceBlock?.renderer;
|
||||||
const indicatorOverlay = this._indicatorOverlay;
|
const indicatorOverlay = this._indicatorOverlay;
|
||||||
|
|
||||||
if (!renderer || !indicatorOverlay) {
|
// TODO: handle DOM renderer case for mindmap drag image
|
||||||
return;
|
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;
|
const nodeBound = mindmapNode.element.elementBound;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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<string, string>
|
||||||
|
): 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');
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HighlighterElementRendererExtension,
|
HighlighterElementRendererExtension,
|
||||||
ShapeElementRendererExtension,
|
ShapeElementRendererExtension,
|
||||||
} from './element-renderer';
|
} from './element-renderer';
|
||||||
|
import { ShapeDomRendererExtension } from './element-renderer/shape-dom';
|
||||||
import { ShapeElementView, ShapeViewInteraction } from './element-view';
|
import { ShapeElementView, ShapeViewInteraction } from './element-view';
|
||||||
import { ShapeTool } from './shape-tool';
|
import { ShapeTool } from './shape-tool';
|
||||||
import { shapeSeniorTool, shapeToolbarExtension } from './toolbar';
|
import { shapeSeniorTool, shapeToolbarExtension } from './toolbar';
|
||||||
@@ -25,6 +26,7 @@ export class ShapeViewExtension extends ViewExtensionProvider {
|
|||||||
if (this.isEdgeless(context.scope)) {
|
if (this.isEdgeless(context.scope)) {
|
||||||
context.register(HighlighterElementRendererExtension);
|
context.register(HighlighterElementRendererExtension);
|
||||||
context.register(ShapeElementRendererExtension);
|
context.register(ShapeElementRendererExtension);
|
||||||
|
context.register(ShapeDomRendererExtension);
|
||||||
context.register(ShapeElementView);
|
context.register(ShapeElementView);
|
||||||
context.register(ShapeTool);
|
context.register(ShapeTool);
|
||||||
context.register(shapeSeniorTool);
|
context.register(shapeSeniorTool);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface BlockSuiteFlags {
|
|||||||
enable_turbo_renderer: boolean;
|
enable_turbo_renderer: boolean;
|
||||||
enable_citation: boolean;
|
enable_citation: boolean;
|
||||||
enable_link_preview_cache: boolean;
|
enable_link_preview_cache: boolean;
|
||||||
|
enable_dom_renderer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeatureFlagService extends StoreExtension {
|
export class FeatureFlagService extends StoreExtension {
|
||||||
@@ -50,6 +51,7 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
enable_turbo_renderer: false,
|
enable_turbo_renderer: false,
|
||||||
enable_citation: false,
|
enable_citation: false,
|
||||||
enable_link_preview_cache: false,
|
enable_link_preview_cache: false,
|
||||||
|
enable_dom_renderer: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ export class LayerManager extends GfxExtension {
|
|||||||
];
|
];
|
||||||
curLayer.zIndex = currentCSSZindex;
|
curLayer.zIndex = currentCSSZindex;
|
||||||
layers.push(curLayer as LayerManager['layers'][number]);
|
layers.push(curLayer as LayerManager['layers'][number]);
|
||||||
|
|
||||||
currentCSSZindex +=
|
currentCSSZindex +=
|
||||||
curLayer.type === 'block' ? curLayer.elements.length : 1;
|
curLayer.type === 'block' ? curLayer.elements.length : 1;
|
||||||
}
|
}
|
||||||
@@ -772,9 +771,7 @@ export class LayerManager extends GfxExtension {
|
|||||||
getZIndex(element: GfxModel): number {
|
getZIndex(element: GfxModel): number {
|
||||||
// @ts-expect-error FIXME: ts error
|
// @ts-expect-error FIXME: ts error
|
||||||
const layer = this.layers.find(layer => layer.set.has(element));
|
const layer = this.layers.find(layer => layer.set.has(element));
|
||||||
|
if (!layer) return 0;
|
||||||
if (!layer) return -1;
|
|
||||||
|
|
||||||
// @ts-expect-error FIXME: ts error
|
// @ts-expect-error FIXME: ts error
|
||||||
return layer.zIndex + layer.elements.indexOf(element);
|
return layer.zIndex + layer.elements.indexOf(element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
|
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 { ungroupCommand } from '@blocksuite/affine/gfx/group';
|
||||||
import type {
|
import type {
|
||||||
GroupElementModel,
|
GroupElementModel,
|
||||||
@@ -789,8 +792,12 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
|
|||||||
'.indexable-canvas'
|
'.indexable-canvas'
|
||||||
)[0] as HTMLCanvasElement;
|
)[0] as HTMLCanvasElement;
|
||||||
|
|
||||||
expect(indexedCanvas.width).toBe(surface.renderer.canvas.width);
|
expect(indexedCanvas.width).toBe(
|
||||||
expect(indexedCanvas.height).toBe(surface.renderer.canvas.height);
|
(surface.renderer as CanvasRenderer).canvas.width
|
||||||
|
);
|
||||||
|
expect(indexedCanvas.height).toBe(
|
||||||
|
(surface.renderer as CanvasRenderer).canvas.height
|
||||||
|
);
|
||||||
expect(indexedCanvas.width).not.toBe(0);
|
expect(indexedCanvas.width).not.toBe(0);
|
||||||
expect(indexedCanvas.height).not.toBe(0);
|
expect(indexedCanvas.height).not.toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import type { DocMode } from '@blocksuite/affine/model';
|
|||||||
import { AffineSchemas } from '@blocksuite/affine/schemas';
|
import { AffineSchemas } from '@blocksuite/affine/schemas';
|
||||||
import {
|
import {
|
||||||
CommunityCanvasTextFonts,
|
CommunityCanvasTextFonts,
|
||||||
|
FeatureFlagService,
|
||||||
FontConfigExtension,
|
FontConfigExtension,
|
||||||
} from '@blocksuite/affine/shared/services';
|
} from '@blocksuite/affine/shared/services';
|
||||||
import {
|
import {
|
||||||
@@ -47,11 +48,6 @@ function createCollectionOptions() {
|
|||||||
id: room,
|
id: room,
|
||||||
schema,
|
schema,
|
||||||
idGenerator,
|
idGenerator,
|
||||||
defaultFlags: {
|
|
||||||
readonly: {
|
|
||||||
'doc:home': false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,10 +111,20 @@ export function createPainterWorker() {
|
|||||||
return worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetupEditorOptions = {
|
||||||
|
extensions?: ExtensionType[];
|
||||||
|
enableDomRenderer?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export async function setupEditor(
|
export async function setupEditor(
|
||||||
mode: DocMode = 'page',
|
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());
|
const collection = new TestWorkspace(createCollectionOptions());
|
||||||
collection.storeExtensions = storeExtensions;
|
collection.storeExtensions = storeExtensions;
|
||||||
collection.meta.initialize();
|
collection.meta.initialize();
|
||||||
@@ -126,6 +132,13 @@ export async function setupEditor(
|
|||||||
window.collection = collection;
|
window.collection = collection;
|
||||||
|
|
||||||
initCollection(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);
|
const appElement = await createEditor(collection, mode, extensions);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: isCanaryBuild,
|
configurable: isCanaryBuild,
|
||||||
defaultState: false,
|
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: {
|
enable_edgeless_scribbled_style: {
|
||||||
category: 'blocksuite',
|
category: 'blocksuite',
|
||||||
bsFlag: 'enable_edgeless_scribbled_style',
|
bsFlag: 'enable_edgeless_scribbled_style',
|
||||||
|
|||||||
Reference in New Issue
Block a user