Compare commits

..

3 Commits

Author SHA1 Message Date
Yifeng Wang 9c696d278b Add lint-staged script 2025-05-29 16:23:30 +08:00
Yifeng Wang 372fc126b5 test: add test 2025-05-29 16:13:48 +08:00
Yifeng Wang 6d57c01dd4 feat(editor): support brush dom renderer 2025-05-29 16:13:48 +08:00
6 changed files with 663 additions and 96 deletions
@@ -43,6 +43,23 @@ type RendererOptions = {
surfaceModel: SurfaceBlockModel;
};
enum UpdateType {
ELEMENT_ADDED = 'element-added',
ELEMENT_REMOVED = 'element-removed',
ELEMENT_UPDATED = 'element-updated',
VIEWPORT_CHANGED = 'viewport-changed',
SIZE_CHANGED = 'size-changed',
ZOOM_STATE_CHANGED = 'zoom-state-changed',
}
interface IncrementalUpdateState {
dirtyElementIds: Set<string>;
viewportDirty: boolean;
sizeDirty: boolean;
usePlaceholderDirty: boolean;
pendingUpdates: Map<string, UpdateType[]>;
}
const PLACEHOLDER_RESET_STYLES = {
border: 'none',
borderRadius: '0',
@@ -141,6 +158,18 @@ export class DomRenderer {
private _sizeUpdatedRafId: number | null = null;
private readonly _updateState: IncrementalUpdateState = {
dirtyElementIds: new Set(),
viewportDirty: false,
sizeDirty: false,
usePlaceholderDirty: false,
pendingUpdates: new Map(),
};
private _lastViewportBounds: Bound | null = null;
private _lastZoom: number | null = null;
private _lastUsePlaceholder: boolean = false;
rootElement: HTMLElement;
private readonly _elementsMap = new Map<string, HTMLElement>();
@@ -186,6 +215,7 @@ export class DomRenderer {
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this._markViewportDirty();
this.refresh();
})
);
@@ -195,6 +225,7 @@ export class DomRenderer {
if (this._sizeUpdatedRafId) return;
this._sizeUpdatedRafId = requestConnectedFrame(() => {
this._sizeUpdatedRafId = null;
this._markSizeDirty();
this._resetSize();
this._render();
this.refresh();
@@ -208,6 +239,7 @@ export class DomRenderer {
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this._markUsePlaceholderDirty();
this.refresh();
}
})
@@ -307,6 +339,292 @@ export class DomRenderer {
}
private _render() {
this._renderIncremental();
}
private _watchSurface(surfaceModel: SurfaceBlockModel) {
this._disposables.add(
surfaceModel.elementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.elementRemoved.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementDeleted.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(payload => {
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
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._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
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();
}
/**
* Mark a specific element as dirty for incremental updates
* @param elementId - The ID of the element to mark as dirty
* @param updateType - The type of update (optional, defaults to ELEMENT_UPDATED)
*/
markElementDirty(
elementId: string,
updateType: UpdateType = UpdateType.ELEMENT_UPDATED
) {
this._markElementDirty(elementId, updateType);
}
/**
* Force a full re-render of all elements
*/
forceFullRender() {
this._updateState.viewportDirty = true;
this.refresh();
}
private _markElementDirty(elementId: string, updateType: UpdateType) {
this._updateState.dirtyElementIds.add(elementId);
const currentUpdates =
this._updateState.pendingUpdates.get(elementId) || [];
if (!currentUpdates.includes(updateType)) {
currentUpdates.push(updateType);
this._updateState.pendingUpdates.set(elementId, currentUpdates);
}
}
private _markViewportDirty() {
this._updateState.viewportDirty = true;
}
private _markSizeDirty() {
this._updateState.sizeDirty = true;
}
private _markUsePlaceholderDirty() {
this._updateState.usePlaceholderDirty = true;
}
private _clearUpdateState() {
this._updateState.dirtyElementIds.clear();
this._updateState.viewportDirty = false;
this._updateState.sizeDirty = false;
this._updateState.usePlaceholderDirty = false;
this._updateState.pendingUpdates.clear();
}
private _isViewportChanged(): boolean {
const { viewportBounds, zoom } = this.viewport;
if (!this._lastViewportBounds || !this._lastZoom) {
return true;
}
return (
this._lastViewportBounds.x !== viewportBounds.x ||
this._lastViewportBounds.y !== viewportBounds.y ||
this._lastViewportBounds.w !== viewportBounds.w ||
this._lastViewportBounds.h !== viewportBounds.h ||
this._lastZoom !== zoom
);
}
private _isUsePlaceholderChanged(): boolean {
return this._lastUsePlaceholder !== this.usePlaceholder;
}
private _updateLastState() {
const { viewportBounds, zoom } = this.viewport;
this._lastViewportBounds = {
x: viewportBounds.x,
y: viewportBounds.y,
w: viewportBounds.w,
h: viewportBounds.h,
} as Bound;
this._lastZoom = zoom;
this._lastUsePlaceholder = this.usePlaceholder;
}
private _renderIncremental() {
const { viewportBounds, zoom } = this.viewport;
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
const needsFullRender =
this._isViewportChanged() ||
this._isUsePlaceholderChanged() ||
this._updateState.sizeDirty ||
this._updateState.viewportDirty ||
this._updateState.usePlaceholderDirty;
if (needsFullRender) {
this._renderFull();
this._updateLastState();
this._clearUpdateState();
return;
}
// Only update dirty elements
const elementsFromGrid = this.grid.search(viewportBounds, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[];
const visibleElementIds = new Set<string>();
// 1. Update dirty elements
for (const elementModel of elementsFromGrid) {
const display = (elementModel.display ?? true) && !elementModel.hidden;
if (
display &&
intersects(getBoundWithRotation(elementModel), viewportBounds)
) {
visibleElementIds.add(elementModel.id);
// Only update dirty elements
if (this._updateState.dirtyElementIds.has(elementModel.id)) {
if (
this.usePlaceholder &&
!(elementModel as GfxCompatibleInterface).forceFullRender
) {
this._renderOrUpdatePlaceholder(
elementModel,
viewportBounds,
zoom,
addedElements
);
} else {
this._renderOrUpdateFullElement(
elementModel,
viewportBounds,
zoom,
addedElements
);
}
}
}
}
// 2. Remove elements that are no longer in the grid
for (const elementId of this._updateState.dirtyElementIds) {
const updateTypes = this._updateState.pendingUpdates.get(elementId) || [];
if (
updateTypes.includes(UpdateType.ELEMENT_REMOVED) ||
!visibleElementIds.has(elementId)
) {
const domElem = this._elementsMap.get(elementId);
if (domElem) {
domElem.remove();
this._elementsMap.delete(elementId);
elementsToRemove.push(domElem);
}
}
}
// 3. Notify changes
if (addedElements.length > 0 || elementsToRemove.length > 0) {
this.elementsUpdated.next({
elements: Array.from(this._elementsMap.values()),
added: addedElements,
removed: elementsToRemove,
});
}
this._updateLastState();
this._clearUpdateState();
}
private _renderFull() {
const { viewportBounds, zoom } = this.viewport;
const addedElements: HTMLElement[] = [];
const elementsToRemove: HTMLElement[] = [];
@@ -387,100 +705,4 @@ export class DomRenderer {
});
}
}
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();
}
}
@@ -0,0 +1,11 @@
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
import { brushDomRenderer } from './brush-dom/index.js';
/**
* Extension to register the DOM-based renderer for 'brush' elements.
*/
export const BrushDomRendererExtension = DomElementRendererExtension(
'brush',
brushDomRenderer
);
@@ -0,0 +1,63 @@
import type { DomRenderer } from '@blocksuite/affine-block-surface';
import type { BrushElementModel } from '@blocksuite/affine-model';
import { DefaultTheme } from '@blocksuite/affine-model';
/**
* Renders a BrushElementModel to a given HTMLElement using DOM properties.
* This function is intended to be registered via the DomElementRendererExtension.
*
* @param model - The brush element model containing rendering properties.
* @param element - The HTMLElement to apply the brush's styles to.
* @param renderer - The main DOMRenderer instance, providing access to viewport and color utilities.
*/
export const brushDomRenderer = (
model: BrushElementModel,
element: HTMLElement,
renderer: DomRenderer
): void => {
const { zoom } = renderer.viewport;
const unscaledWidth = model.w;
const unscaledHeight = model.h;
const color = renderer.getColorValue(model.color, DefaultTheme.black, true);
element.style.width = `${unscaledWidth * zoom}px`;
element.style.height = `${unscaledHeight * zoom}px`;
element.style.boxSizing = 'border-box';
element.style.overflow = 'hidden';
// Clear any existing content
element.replaceChildren();
// Create SVG element to render the brush stroke
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
svg.setAttribute('preserveAspectRatio', 'none');
// Create path element for the brush stroke
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', model.commands);
path.setAttribute('fill', color);
path.setAttribute('stroke', 'none');
svg.append(path);
element.append(svg);
// Apply rotation if needed
if (model.rotate) {
element.style.transform = `rotate(${model.rotate}deg)`;
element.style.transformOrigin = 'center';
}
// Apply opacity
element.style.opacity = `${model.opacity ?? 1}`;
// Set z-index
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
// Add brush-specific class for styling
element.classList.add('brush-element');
};
+1
View File
@@ -1,6 +1,7 @@
export * from './adapter';
export * from './brush-tool';
export * from './element-renderer';
export * from './element-renderer/brush-dom';
export * from './eraser-tool';
export * from './highlighter-tool';
export * from './toolbar/configs';
+2
View File
@@ -6,6 +6,7 @@ import {
import { BrushTool } from './brush-tool';
import { effects } from './effects';
import { BrushElementRendererExtension } from './element-renderer';
import { BrushDomRendererExtension } from './element-renderer/brush-dom';
import { EraserTool } from './eraser-tool';
import { HighlighterTool } from './highlighter-tool';
import {
@@ -30,6 +31,7 @@ export class BrushViewExtension extends ViewExtensionProvider {
context.register(HighlighterTool);
context.register(BrushElementRendererExtension);
context.register(BrushDomRendererExtension);
context.register(brushToolbarExtension);
context.register(highlighterToolbarExtension);
@@ -0,0 +1,268 @@
import { DomRenderer } from '@blocksuite/affine-block-surface';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
/**
* Tests for brush element rendering with DOM renderer.
* These tests verify that brush elements are correctly rendered as DOM nodes
* when the DOM renderer is enabled, similar to connector element tests.
*/
describe('Brush 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 brush element as a DOM node', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
// Create a brush element with points (commands will be auto-generated)
const brushProps = {
type: 'brush',
points: [
[10, 10],
[50, 50],
[100, 20],
],
color: '#000000',
lineWidth: 2,
};
const brushId = surfaceModel.addElement(brushProps);
await wait(100);
const brushElement = surfaceView?.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
expect(brushElement).toBeInstanceOf(HTMLElement);
// Check if SVG element is present for brush rendering
const svgElement = brushElement?.querySelector('svg');
expect(svgElement).not.toBeNull();
// Check if path element is present
const pathElement = svgElement?.querySelector('path');
expect(pathElement).not.toBeNull();
// Commands are auto-generated from points, so just check it exists
expect(pathElement?.getAttribute('d')).toBeTruthy();
});
test('should render brush with different colors', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
// Create a red brush element
const brushProps = {
type: 'brush',
points: [
[20, 20],
[35, 15],
[50, 25],
[65, 45],
[80, 80],
],
color: '#ff0000',
lineWidth: 3,
};
const brushId = surfaceModel.addElement(brushProps);
await wait(100);
const brushElement = surfaceView?.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
const svgElement = brushElement?.querySelector('svg');
expect(svgElement).not.toBeNull();
const pathElement = svgElement?.querySelector('path');
expect(pathElement).not.toBeNull();
// Check if color is applied (the actual color value might be processed)
const fillColor = pathElement?.getAttribute('fill');
expect(fillColor).toBeTruthy();
});
test('should render brush with opacity', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const brushProps = {
type: 'brush',
points: [
[10, 10],
[50, 50],
[90, 90],
],
color: '#0000ff',
lineWidth: 2,
};
const brushId = surfaceModel.addElement(brushProps);
// Set opacity after creation through model update
const brushModel = surfaceModel.getElementById(brushId);
if (brushModel) {
surfaceModel.updateElement(brushId, { opacity: 0.5 });
}
await wait(100);
const brushElement = surfaceView?.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
// Check opacity style
const opacity = (brushElement as HTMLElement)?.style.opacity;
expect(opacity).toBe('0.5');
});
test('should render brush with rotation', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const brushProps = {
type: 'brush',
points: [
[25, 25],
[50, 50],
[75, 75],
],
color: '#00ff00',
lineWidth: 2,
rotate: 45,
};
const brushId = surfaceModel.addElement(brushProps);
await wait(100);
const brushElement = surfaceView?.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
// Check rotation transform
const transform = (brushElement as HTMLElement)?.style.transform;
expect(transform).toContain('rotate(45deg)');
const transformOrigin = (brushElement as HTMLElement)?.style
.transformOrigin;
expect(transformOrigin).toBe('center center');
});
test('should have proper SVG viewport and sizing', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const brushProps = {
type: 'brush',
points: [
[0, 0],
[60, 40],
[120, 80],
],
color: '#333333',
lineWidth: 2,
};
const brushId = surfaceModel.addElement(brushProps);
await wait(100);
const brushElement = surfaceView?.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
const svgElement = brushElement?.querySelector('svg');
expect(svgElement).not.toBeNull();
// Check SVG attributes
expect(svgElement?.getAttribute('width')).toBe('100%');
expect(svgElement?.getAttribute('height')).toBe('100%');
expect(svgElement?.getAttribute('viewBox')).toBeTruthy();
expect(svgElement?.getAttribute('preserveAspectRatio')).toBe('none');
});
test('should add brush-specific CSS class', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const brushProps = {
type: 'brush',
points: [
[10, 10],
[25, 25],
[40, 40],
],
color: '#666666',
lineWidth: 2,
};
const brushId = surfaceModel.addElement(brushProps);
await wait(100);
const brushElement = surfaceView?.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
expect(brushElement?.classList.contains('brush-element')).toBe(true);
});
test('should remove brush DOM node when element is deleted', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
expect(surfaceView.renderer).toBeInstanceOf(DomRenderer);
const brushProps = {
type: 'brush',
points: [
[25, 25],
[75, 25],
[75, 75],
[25, 75],
[25, 25],
],
color: '#aa00aa',
lineWidth: 2,
};
const brushId = surfaceModel.addElement(brushProps);
await wait(100);
let brushElement = surfaceView.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).not.toBeNull();
surfaceModel.deleteElement(brushId);
await wait(100);
brushElement = surfaceView.renderRoot.querySelector(
`[data-element-id="${brushId}"]`
);
expect(brushElement).toBeNull();
});
});