mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 18:20:39 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c696d278b | |||
| 372fc126b5 | |||
| 6d57c01dd4 |
@@ -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,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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user