mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
Compare commits
9 Commits
renovate/a
...
0526/brush
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c696d278b | ||
|
|
372fc126b5 | ||
|
|
6d57c01dd4 | ||
|
|
3d2d399796 | ||
|
|
6483f36723 | ||
|
|
df2ecf2bec | ||
|
|
148c718a12 | ||
|
|
c4af1e77d0 | ||
|
|
6a0eb80903 |
@@ -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,11 @@
|
||||
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { connectorDomRenderer } from './connector-dom/index.js';
|
||||
|
||||
/**
|
||||
* Extension to register the DOM-based renderer for 'connector' elements.
|
||||
*/
|
||||
export const ConnectorDomRendererExtension = DomElementRendererExtension(
|
||||
'connector',
|
||||
connectorDomRenderer
|
||||
);
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { DomRenderer } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
DefaultTheme,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
|
||||
|
||||
import { isConnectorWithLabel } from '../../connector-manager.js';
|
||||
import { DEFAULT_ARROW_SIZE } from '../utils.js';
|
||||
|
||||
interface PathBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
function calculatePathBounds(path: PointLocation[]): PathBounds {
|
||||
if (path.length === 0) {
|
||||
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
}
|
||||
|
||||
let minX = path[0][0];
|
||||
let minY = path[0][1];
|
||||
let maxX = path[0][0];
|
||||
let maxY = path[0][1];
|
||||
|
||||
for (const point of path) {
|
||||
minX = Math.min(minX, point[0]);
|
||||
minY = Math.min(minY, point[1]);
|
||||
maxX = Math.max(maxX, point[0]);
|
||||
maxY = Math.max(maxY, point[1]);
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function createConnectorPath(
|
||||
points: PointLocation[],
|
||||
mode: ConnectorMode
|
||||
): string {
|
||||
if (points.length < 2) return '';
|
||||
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
pathBuilder.moveTo(points[0][0], points[0][1]);
|
||||
|
||||
if (mode === ConnectorMode.Curve) {
|
||||
// Use bezier curves
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
pathBuilder.curveTo(
|
||||
prev.absOut[0],
|
||||
prev.absOut[1],
|
||||
curr.absIn[0],
|
||||
curr.absIn[1],
|
||||
curr[0],
|
||||
curr[1]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use straight lines
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
pathBuilder.lineTo(points[i][0], points[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
return pathBuilder.build();
|
||||
}
|
||||
|
||||
function createArrowMarker(
|
||||
id: string,
|
||||
style: PointStyle,
|
||||
color: string,
|
||||
strokeWidth: number,
|
||||
isStart: boolean = false
|
||||
): SVGMarkerElement {
|
||||
const marker = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'marker'
|
||||
);
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
|
||||
marker.id = id;
|
||||
marker.setAttribute('viewBox', '0 0 20 20');
|
||||
marker.setAttribute('refX', isStart ? '20' : '0');
|
||||
marker.setAttribute('refY', '10');
|
||||
marker.setAttribute('markerWidth', String(size));
|
||||
marker.setAttribute('markerHeight', String(size));
|
||||
marker.setAttribute('orient', 'auto');
|
||||
marker.setAttribute('markerUnits', 'strokeWidth');
|
||||
|
||||
switch (style) {
|
||||
case 'Arrow': {
|
||||
const path = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
path.setAttribute(
|
||||
'd',
|
||||
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z'
|
||||
);
|
||||
path.setAttribute('fill', color);
|
||||
path.setAttribute('stroke', color);
|
||||
marker.append(path);
|
||||
break;
|
||||
}
|
||||
case 'Triangle': {
|
||||
const path = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
path.setAttribute(
|
||||
'd',
|
||||
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z'
|
||||
);
|
||||
path.setAttribute('fill', color);
|
||||
path.setAttribute('stroke', color);
|
||||
marker.append(path);
|
||||
break;
|
||||
}
|
||||
case 'Circle': {
|
||||
const circle = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'circle'
|
||||
);
|
||||
circle.setAttribute('cx', '10');
|
||||
circle.setAttribute('cy', '10');
|
||||
circle.setAttribute('r', '4');
|
||||
circle.setAttribute('fill', color);
|
||||
circle.setAttribute('stroke', color);
|
||||
marker.append(circle);
|
||||
break;
|
||||
}
|
||||
case 'Diamond': {
|
||||
const path = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
|
||||
path.setAttribute('fill', color);
|
||||
path.setAttribute('stroke', color);
|
||||
marker.append(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
function renderConnectorLabel(
|
||||
model: ConnectorElementModel,
|
||||
container: HTMLElement,
|
||||
renderer: DomRenderer,
|
||||
zoom: number
|
||||
) {
|
||||
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [lx, ly, lw, lh] = model.labelXYWH;
|
||||
const {
|
||||
labelStyle: {
|
||||
color,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
},
|
||||
} = model;
|
||||
|
||||
// Create label element
|
||||
const labelElement = document.createElement('div');
|
||||
labelElement.style.position = 'absolute';
|
||||
labelElement.style.left = `${lx * zoom}px`;
|
||||
labelElement.style.top = `${ly * zoom}px`;
|
||||
labelElement.style.width = `${lw * zoom}px`;
|
||||
labelElement.style.height = `${lh * zoom}px`;
|
||||
labelElement.style.pointerEvents = 'none';
|
||||
labelElement.style.overflow = 'hidden';
|
||||
labelElement.style.display = 'flex';
|
||||
labelElement.style.alignItems = 'center';
|
||||
labelElement.style.justifyContent =
|
||||
textAlign === 'center'
|
||||
? 'center'
|
||||
: textAlign === 'right'
|
||||
? 'flex-end'
|
||||
: 'flex-start';
|
||||
|
||||
// Style the text
|
||||
labelElement.style.color = renderer.getColorValue(
|
||||
color,
|
||||
DefaultTheme.black,
|
||||
true
|
||||
);
|
||||
labelElement.style.fontSize = `${fontSize * zoom}px`;
|
||||
labelElement.style.fontWeight = fontWeight;
|
||||
labelElement.style.fontStyle = fontStyle;
|
||||
labelElement.style.fontFamily = fontFamily;
|
||||
labelElement.style.textAlign = textAlign;
|
||||
labelElement.style.lineHeight = '1.2';
|
||||
labelElement.style.whiteSpace = 'pre-wrap';
|
||||
labelElement.style.wordWrap = 'break-word';
|
||||
|
||||
// Add text content
|
||||
if (model.text) {
|
||||
labelElement.textContent = model.text.toString();
|
||||
}
|
||||
|
||||
container.append(labelElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a ConnectorElementModel to a given HTMLElement using DOM/SVG.
|
||||
* This function is intended to be registered via the DomElementRendererExtension.
|
||||
*
|
||||
* @param model - The connector element model containing rendering properties.
|
||||
* @param element - The HTMLElement to apply the connector's styles to.
|
||||
* @param renderer - The main DOMRenderer instance, providing access to viewport and color utilities.
|
||||
*/
|
||||
export const connectorDomRenderer = (
|
||||
model: ConnectorElementModel,
|
||||
element: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const {
|
||||
mode,
|
||||
path: points,
|
||||
strokeStyle,
|
||||
frontEndpointStyle,
|
||||
rearEndpointStyle,
|
||||
strokeWidth,
|
||||
stroke,
|
||||
} = model;
|
||||
|
||||
// Clear previous content
|
||||
element.innerHTML = '';
|
||||
|
||||
// Early return if no path points
|
||||
if (!points || points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounds for the SVG viewBox
|
||||
const pathBounds = calculatePathBounds(points);
|
||||
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
|
||||
const svgWidth = (pathBounds.maxX - pathBounds.minX + padding * 2) * zoom;
|
||||
const svgHeight = (pathBounds.maxY - pathBounds.minY + padding * 2) * zoom;
|
||||
const offsetX = pathBounds.minX - padding;
|
||||
const offsetY = pathBounds.minY - padding;
|
||||
|
||||
// Create SVG element
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.left = `${offsetX * zoom}px`;
|
||||
svg.style.top = `${offsetY * zoom}px`;
|
||||
svg.style.width = `${svgWidth}px`;
|
||||
svg.style.height = `${svgHeight}px`;
|
||||
svg.style.overflow = 'visible';
|
||||
svg.style.pointerEvents = 'none';
|
||||
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`);
|
||||
|
||||
// Create defs for markers
|
||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
svg.append(defs);
|
||||
|
||||
const strokeColor = renderer.getColorValue(
|
||||
stroke,
|
||||
DefaultTheme.connectorColor,
|
||||
true
|
||||
);
|
||||
|
||||
// Create markers for endpoints
|
||||
let startMarkerId = '';
|
||||
let endMarkerId = '';
|
||||
|
||||
if (frontEndpointStyle !== 'None') {
|
||||
startMarkerId = `start-marker-${model.id}`;
|
||||
const startMarker = createArrowMarker(
|
||||
startMarkerId,
|
||||
frontEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
true
|
||||
);
|
||||
defs.append(startMarker);
|
||||
}
|
||||
|
||||
if (rearEndpointStyle !== 'None') {
|
||||
endMarkerId = `end-marker-${model.id}`;
|
||||
const endMarker = createArrowMarker(
|
||||
endMarkerId,
|
||||
rearEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
false
|
||||
);
|
||||
defs.append(endMarker);
|
||||
}
|
||||
|
||||
// Create path element
|
||||
const pathElement = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
|
||||
// Adjust points relative to the SVG coordinate system
|
||||
const adjustedPoints = points.map(point => {
|
||||
const adjustedPoint = new PointLocation([
|
||||
point[0] - offsetX,
|
||||
point[1] - offsetY,
|
||||
]);
|
||||
if (point.absIn) {
|
||||
adjustedPoint.in = [
|
||||
point.absIn[0] - offsetX - adjustedPoint[0],
|
||||
point.absIn[1] - offsetY - adjustedPoint[1],
|
||||
];
|
||||
}
|
||||
if (point.absOut) {
|
||||
adjustedPoint.out = [
|
||||
point.absOut[0] - offsetX - adjustedPoint[0],
|
||||
point.absOut[1] - offsetY - adjustedPoint[1],
|
||||
];
|
||||
}
|
||||
return adjustedPoint;
|
||||
});
|
||||
|
||||
const pathData = createConnectorPath(adjustedPoints, mode);
|
||||
pathElement.setAttribute('d', pathData);
|
||||
pathElement.setAttribute('stroke', strokeColor);
|
||||
pathElement.setAttribute('stroke-width', String(strokeWidth));
|
||||
pathElement.setAttribute('fill', 'none');
|
||||
pathElement.setAttribute('stroke-linecap', 'round');
|
||||
pathElement.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
// Apply stroke style
|
||||
if (strokeStyle === 'dash') {
|
||||
pathElement.setAttribute('stroke-dasharray', '12,12');
|
||||
}
|
||||
|
||||
// Apply markers
|
||||
if (startMarkerId) {
|
||||
pathElement.setAttribute('marker-start', `url(#${startMarkerId})`);
|
||||
}
|
||||
if (endMarkerId) {
|
||||
pathElement.setAttribute('marker-end', `url(#${endMarkerId})`);
|
||||
}
|
||||
|
||||
svg.append(pathElement);
|
||||
element.append(svg);
|
||||
|
||||
// Set element size and position
|
||||
element.style.width = `${model.w * zoom}px`;
|
||||
element.style.height = `${model.h * zoom}px`;
|
||||
element.style.overflow = 'visible';
|
||||
element.style.pointerEvents = 'none';
|
||||
|
||||
// Set z-index for layering
|
||||
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
|
||||
|
||||
// Render label if present
|
||||
renderConnectorLabel(model, element, renderer, zoom);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export * from './adapter';
|
||||
export * from './connector-manager';
|
||||
export * from './connector-tool';
|
||||
export * from './element-renderer';
|
||||
export { ConnectorDomRendererExtension } from './element-renderer/connector-dom';
|
||||
export * from './element-transform';
|
||||
export * from './text';
|
||||
export * from './toolbar/config';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConnectionOverlay } from './connector-manager';
|
||||
import { ConnectorTool } from './connector-tool';
|
||||
import { effects } from './effects';
|
||||
import { ConnectorElementRendererExtension } from './element-renderer';
|
||||
import { ConnectorDomRendererExtension } from './element-renderer/connector-dom';
|
||||
import { ConnectorFilter } from './element-transform';
|
||||
import { connectorToolbarExtension } from './toolbar/config';
|
||||
import { connectorQuickTool } from './toolbar/quick-tool';
|
||||
@@ -24,6 +25,7 @@ export class ConnectorViewExtension extends ViewExtensionProvider {
|
||||
super.setup(context);
|
||||
context.register(ConnectorElementView);
|
||||
context.register(ConnectorElementRendererExtension);
|
||||
context.register(ConnectorDomRendererExtension);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(ConnectorTool);
|
||||
context.register(ConnectorFilter);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DomRenderer } from '@blocksuite/affine-block-surface';
|
||||
import type { ShapeElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
import { SVGShapeBuilder } from '@blocksuite/global/gfx';
|
||||
|
||||
import { manageClassNames, setStyles } from './utils';
|
||||
|
||||
@@ -9,18 +10,35 @@ function applyShapeSpecificStyles(
|
||||
element: HTMLElement,
|
||||
zoom: number
|
||||
) {
|
||||
if (model.shapeType === 'rect') {
|
||||
const w = model.w * zoom;
|
||||
const h = model.h * zoom;
|
||||
const r = model.radius ?? 0;
|
||||
const borderRadius =
|
||||
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
|
||||
element.style.borderRadius = borderRadius;
|
||||
} else if (model.shapeType === 'ellipse') {
|
||||
element.style.borderRadius = '50%';
|
||||
} else {
|
||||
element.style.borderRadius = '';
|
||||
// Reset properties that might be set by different shape types
|
||||
element.style.removeProperty('clip-path');
|
||||
element.style.removeProperty('border-radius');
|
||||
// Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based
|
||||
if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') {
|
||||
while (element.firstChild) element.firstChild.remove();
|
||||
}
|
||||
|
||||
switch (model.shapeType) {
|
||||
case 'rect': {
|
||||
const w = model.w * zoom;
|
||||
const h = model.h * zoom;
|
||||
const r = model.radius ?? 0;
|
||||
const borderRadius =
|
||||
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
|
||||
element.style.borderRadius = borderRadius;
|
||||
break;
|
||||
}
|
||||
case 'ellipse':
|
||||
element.style.borderRadius = '50%';
|
||||
break;
|
||||
case 'diamond':
|
||||
element.style.clipPath = 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)';
|
||||
break;
|
||||
case 'triangle':
|
||||
element.style.clipPath = 'polygon(50% 0%, 100% 100%, 0% 100%)';
|
||||
break;
|
||||
}
|
||||
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
|
||||
}
|
||||
|
||||
function applyBorderStyles(
|
||||
@@ -78,6 +96,9 @@ export const shapeDomRenderer = (
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const unscaledWidth = model.w;
|
||||
const unscaledHeight = model.h;
|
||||
|
||||
const fillColor = renderer.getColorValue(
|
||||
model.fillColor,
|
||||
DefaultTheme.shapeFillColor,
|
||||
@@ -89,17 +110,77 @@ export const shapeDomRenderer = (
|
||||
true
|
||||
);
|
||||
|
||||
element.style.width = `${model.w * zoom}px`;
|
||||
element.style.height = `${model.h * zoom}px`;
|
||||
element.style.width = `${unscaledWidth * zoom}px`;
|
||||
element.style.height = `${unscaledHeight * zoom}px`;
|
||||
element.style.boxSizing = 'border-box';
|
||||
|
||||
// Apply shape-specific clipping, border-radius, and potentially clear innerHTML
|
||||
applyShapeSpecificStyles(model, element, zoom);
|
||||
|
||||
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
|
||||
if (model.shapeType === 'diamond' || model.shapeType === 'triangle') {
|
||||
// For diamond and triangle, fill and border are handled by inline SVG
|
||||
element.style.border = 'none'; // Ensure no standard CSS border interferes
|
||||
element.style.backgroundColor = 'transparent'; // Host element is transparent
|
||||
|
||||
const strokeW = model.strokeWidth;
|
||||
|
||||
let svgPoints = '';
|
||||
if (model.shapeType === 'diamond') {
|
||||
// Generate diamond points using shared utility
|
||||
svgPoints = SVGShapeBuilder.diamond(
|
||||
unscaledWidth,
|
||||
unscaledHeight,
|
||||
strokeW
|
||||
);
|
||||
} else {
|
||||
// triangle - generate triangle points using shared utility
|
||||
svgPoints = SVGShapeBuilder.triangle(
|
||||
unscaledWidth,
|
||||
unscaledHeight,
|
||||
strokeW
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if stroke should be visible and its color
|
||||
const finalStrokeColor =
|
||||
model.strokeStyle !== 'none' && strokeW > 0 ? strokeColor : 'transparent';
|
||||
// Determine dash array, only if stroke is visible and style is 'dash'
|
||||
const finalStrokeDasharray =
|
||||
model.strokeStyle === 'dash' && finalStrokeColor !== 'transparent'
|
||||
? '12, 12'
|
||||
: 'none';
|
||||
// Determine fill color
|
||||
const finalFillColor = model.filled ? fillColor : 'transparent';
|
||||
|
||||
// Build SVG safely with DOM-API
|
||||
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');
|
||||
|
||||
const polygon = document.createElementNS(SVG_NS, 'polygon');
|
||||
polygon.setAttribute('points', svgPoints);
|
||||
polygon.setAttribute('fill', finalFillColor);
|
||||
polygon.setAttribute('stroke', finalStrokeColor);
|
||||
polygon.setAttribute('stroke-width', String(strokeW));
|
||||
if (finalStrokeDasharray !== 'none') {
|
||||
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
|
||||
}
|
||||
svg.append(polygon);
|
||||
|
||||
// Replace existing children to avoid memory leaks
|
||||
element.replaceChildren(svg);
|
||||
} else {
|
||||
// Standard rendering for other shapes (e.g., rect, ellipse)
|
||||
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
|
||||
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
|
||||
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
|
||||
}
|
||||
|
||||
applyBorderStyles(model, element, strokeColor, zoom);
|
||||
applyTransformStyles(model, element);
|
||||
|
||||
element.style.boxSizing = 'border-box';
|
||||
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
|
||||
|
||||
manageClassNames(model, element);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { SVGPathBuilder, SVGShapeBuilder } from '../gfx/svg-path.js';
|
||||
|
||||
describe('SVGPathBuilder', () => {
|
||||
test('should build a simple path', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
const result = pathBuilder.moveTo(10, 20).lineTo(30, 40).build();
|
||||
|
||||
expect(result).toBe('M 10 20 L 30 40');
|
||||
});
|
||||
|
||||
test('should build a path with curves', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
const result = pathBuilder
|
||||
.moveTo(0, 0)
|
||||
.curveTo(10, 0, 10, 10, 20, 10)
|
||||
.build();
|
||||
|
||||
expect(result).toBe('M 0 0 C 10 0 10 10 20 10');
|
||||
});
|
||||
|
||||
test('should build a closed path', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
const result = pathBuilder
|
||||
.moveTo(0, 0)
|
||||
.lineTo(10, 0)
|
||||
.lineTo(5, 10)
|
||||
.closePath()
|
||||
.build();
|
||||
|
||||
expect(result).toBe('M 0 0 L 10 0 L 5 10 Z');
|
||||
});
|
||||
|
||||
test('should clear commands', () => {
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
pathBuilder.moveTo(10, 20).lineTo(30, 40);
|
||||
pathBuilder.clear();
|
||||
const result = pathBuilder.moveTo(0, 0).build();
|
||||
|
||||
expect(result).toBe('M 0 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVGShapeBuilder', () => {
|
||||
test('should generate diamond polygon points', () => {
|
||||
const result = SVGShapeBuilder.diamond(100, 80, 2);
|
||||
expect(result).toBe('50,1 99,40 50,79 1,40');
|
||||
});
|
||||
|
||||
test('should generate triangle polygon points', () => {
|
||||
const result = SVGShapeBuilder.triangle(100, 80, 2);
|
||||
expect(result).toBe('50,1 99,79 1,79');
|
||||
});
|
||||
|
||||
test('should generate diamond path', () => {
|
||||
const result = SVGShapeBuilder.diamondPath(100, 80, 2);
|
||||
expect(result).toBe('M 50 1 L 99 40 L 50 79 L 1 40 Z');
|
||||
});
|
||||
|
||||
test('should generate triangle path', () => {
|
||||
const result = SVGShapeBuilder.trianglePath(100, 80, 2);
|
||||
expect(result).toBe('M 50 1 L 99 79 L 1 79 Z');
|
||||
});
|
||||
|
||||
test('should handle zero stroke width', () => {
|
||||
const diamondResult = SVGShapeBuilder.diamond(100, 80, 0);
|
||||
expect(diamondResult).toBe('50,0 100,40 50,80 0,40');
|
||||
|
||||
const triangleResult = SVGShapeBuilder.triangle(100, 80, 0);
|
||||
expect(triangleResult).toBe('50,0 100,80 0,80');
|
||||
});
|
||||
});
|
||||
@@ -4,4 +4,5 @@ export * from './math.js';
|
||||
export * from './model/index.js';
|
||||
export * from './perfect-freehand/index.js';
|
||||
export * from './polyline.js';
|
||||
export * from './svg-path.js';
|
||||
export * from './xywh.js';
|
||||
|
||||
160
blocksuite/framework/global/src/gfx/svg-path.ts
Normal file
160
blocksuite/framework/global/src/gfx/svg-path.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
interface PathCommand {
|
||||
command: string;
|
||||
coordinates: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility class for building SVG path strings using command-based API.
|
||||
* Supports moveTo, lineTo, curveTo operations and can build complete path strings.
|
||||
*/
|
||||
export class SVGPathBuilder {
|
||||
private commands: PathCommand[] = [];
|
||||
|
||||
/**
|
||||
* Move to a specific point without drawing
|
||||
*/
|
||||
moveTo(x: number, y: number): this {
|
||||
this.commands.push({
|
||||
command: 'M',
|
||||
coordinates: [x, y],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line to a specific point
|
||||
*/
|
||||
lineTo(x: number, y: number): this {
|
||||
this.commands.push({
|
||||
command: 'L',
|
||||
coordinates: [x, y],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a cubic Bézier curve
|
||||
*/
|
||||
curveTo(
|
||||
cp1x: number,
|
||||
cp1y: number,
|
||||
cp2x: number,
|
||||
cp2y: number,
|
||||
x: number,
|
||||
y: number
|
||||
): this {
|
||||
this.commands.push({
|
||||
command: 'C',
|
||||
coordinates: [cp1x, cp1y, cp2x, cp2y, x, y],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current path
|
||||
*/
|
||||
closePath(): this {
|
||||
this.commands.push({
|
||||
command: 'Z',
|
||||
coordinates: [],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete SVG path string
|
||||
*/
|
||||
build(): string {
|
||||
const pathSegments = this.commands.map(cmd => {
|
||||
const coords = cmd.coordinates.join(' ');
|
||||
return coords ? `${cmd.command} ${coords}` : cmd.command;
|
||||
});
|
||||
|
||||
return pathSegments.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all commands and reset the builder
|
||||
*/
|
||||
clear(): this {
|
||||
this.commands = [];
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SVG polygon points string for common shapes
|
||||
*/
|
||||
export class SVGShapeBuilder {
|
||||
/**
|
||||
* Generate diamond (rhombus) polygon points
|
||||
*/
|
||||
static diamond(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
return [
|
||||
`${width / 2},${halfStroke}`,
|
||||
`${width - halfStroke},${height / 2}`,
|
||||
`${width / 2},${height - halfStroke}`,
|
||||
`${halfStroke},${height / 2}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate triangle polygon points
|
||||
*/
|
||||
static triangle(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
return [
|
||||
`${width / 2},${halfStroke}`,
|
||||
`${width - halfStroke},${height - halfStroke}`,
|
||||
`${halfStroke},${height - halfStroke}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diamond path using SVGPathBuilder
|
||||
*/
|
||||
static diamondPath(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
|
||||
return pathBuilder
|
||||
.moveTo(width / 2, halfStroke)
|
||||
.lineTo(width - halfStroke, height / 2)
|
||||
.lineTo(width / 2, height - halfStroke)
|
||||
.lineTo(halfStroke, height / 2)
|
||||
.closePath()
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate triangle path using SVGPathBuilder
|
||||
*/
|
||||
static trianglePath(
|
||||
width: number,
|
||||
height: number,
|
||||
strokeWidth: number = 0
|
||||
): string {
|
||||
const halfStroke = strokeWidth / 2;
|
||||
const pathBuilder = new SVGPathBuilder();
|
||||
|
||||
return pathBuilder
|
||||
.moveTo(width / 2, halfStroke)
|
||||
.lineTo(width - halfStroke, height - halfStroke)
|
||||
.lineTo(halfStroke, height - halfStroke)
|
||||
.closePath()
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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';
|
||||
|
||||
describe('Connector 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 connector element as a DOM node', async () => {
|
||||
const surfaceView = getSurface(window.doc, window.editor);
|
||||
const surfaceModel = surfaceView.model;
|
||||
|
||||
// Create two shapes to connect
|
||||
const shape1Id = surfaceModel.addElement({
|
||||
type: 'shape',
|
||||
xywh: '[100, 100, 80, 60]',
|
||||
});
|
||||
|
||||
const shape2Id = surfaceModel.addElement({
|
||||
type: 'shape',
|
||||
xywh: '[300, 200, 80, 60]',
|
||||
});
|
||||
|
||||
// Create a connector between the shapes
|
||||
const connectorProps = {
|
||||
type: 'connector',
|
||||
source: { id: shape1Id },
|
||||
target: { id: shape2Id },
|
||||
stroke: '#000000',
|
||||
strokeWidth: 2,
|
||||
};
|
||||
const connectorId = surfaceModel.addElement(connectorProps);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const connectorElement = surfaceView?.renderRoot.querySelector(
|
||||
`[data-element-id="${connectorId}"]`
|
||||
);
|
||||
|
||||
expect(connectorElement).not.toBeNull();
|
||||
expect(connectorElement).toBeInstanceOf(HTMLElement);
|
||||
|
||||
// Check if SVG element is present for connector rendering
|
||||
const svgElement = connectorElement?.querySelector('svg');
|
||||
expect(svgElement).not.toBeNull();
|
||||
});
|
||||
|
||||
test('should render connector with different stroke styles', async () => {
|
||||
const surfaceView = getSurface(window.doc, window.editor);
|
||||
const surfaceModel = surfaceView.model;
|
||||
|
||||
// Create a dashed connector
|
||||
const connectorProps = {
|
||||
type: 'connector',
|
||||
source: { position: [100, 100] },
|
||||
target: { position: [200, 200] },
|
||||
strokeStyle: 'dash',
|
||||
stroke: '#ff0000',
|
||||
strokeWidth: 4,
|
||||
};
|
||||
const connectorId = surfaceModel.addElement(connectorProps);
|
||||
|
||||
// Wait for path generation and rendering
|
||||
await wait(500);
|
||||
|
||||
const connectorElement = surfaceView?.renderRoot.querySelector(
|
||||
`[data-element-id="${connectorId}"]`
|
||||
);
|
||||
|
||||
expect(connectorElement).not.toBeNull();
|
||||
|
||||
const svgElement = connectorElement?.querySelector('svg');
|
||||
expect(svgElement).not.toBeNull();
|
||||
|
||||
// Find the main path element (not the ones inside markers)
|
||||
const pathElements = svgElement?.querySelectorAll('path');
|
||||
// The main connector path should be the last one (after marker paths)
|
||||
const pathElement = pathElements?.[pathElements.length - 1];
|
||||
|
||||
expect(pathElement).not.toBeNull();
|
||||
|
||||
// Check stroke-dasharray attribute
|
||||
const strokeDasharray = pathElement!.getAttribute('stroke-dasharray');
|
||||
expect(strokeDasharray).toBe('12,12');
|
||||
});
|
||||
|
||||
test('should render connector with arrow endpoints', async () => {
|
||||
const surfaceView = getSurface(window.doc, window.editor);
|
||||
const surfaceModel = surfaceView.model;
|
||||
|
||||
const connectorProps = {
|
||||
type: 'connector',
|
||||
source: { position: [100, 100] },
|
||||
target: { position: [200, 200] },
|
||||
frontEndpointStyle: 'Triangle',
|
||||
rearEndpointStyle: 'Arrow',
|
||||
};
|
||||
const connectorId = surfaceModel.addElement(connectorProps);
|
||||
|
||||
await wait(100);
|
||||
|
||||
const connectorElement = surfaceView?.renderRoot.querySelector(
|
||||
`[data-element-id="${connectorId}"]`
|
||||
);
|
||||
|
||||
expect(connectorElement).not.toBeNull();
|
||||
|
||||
// Check for markers in defs
|
||||
const defsElement = connectorElement?.querySelector('defs');
|
||||
expect(defsElement).not.toBeNull();
|
||||
|
||||
const markers = defsElement?.querySelectorAll('marker');
|
||||
expect(markers?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should remove connector DOM node when element is deleted', async () => {
|
||||
const surfaceView = getSurface(window.doc, window.editor);
|
||||
const surfaceModel = surfaceView.model;
|
||||
|
||||
expect(surfaceView.renderer).toBeInstanceOf(DomRenderer);
|
||||
|
||||
const connectorProps = {
|
||||
type: 'connector',
|
||||
source: { position: [50, 50] },
|
||||
target: { position: [150, 150] },
|
||||
};
|
||||
const connectorId = surfaceModel.addElement(connectorProps);
|
||||
|
||||
await wait(100);
|
||||
|
||||
let connectorElement = surfaceView.renderRoot.querySelector(
|
||||
`[data-element-id="${connectorId}"]`
|
||||
);
|
||||
expect(connectorElement).not.toBeNull();
|
||||
|
||||
surfaceModel.deleteElement(connectorId);
|
||||
|
||||
await wait(100);
|
||||
|
||||
connectorElement = surfaceView.renderRoot.querySelector(
|
||||
`[data-element-id="${connectorId}"]`
|
||||
);
|
||||
expect(connectorElement).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe('Shape rendering with DOM renderer', () => {
|
||||
fill: '#ff0000',
|
||||
stroke: '#000000',
|
||||
};
|
||||
const shapeId = surfaceModel.addElement(shapeProps as any);
|
||||
const shapeId = surfaceModel.addElement(shapeProps);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const shapeElement = surfaceView?.renderRoot.querySelector(
|
||||
@@ -73,7 +73,7 @@ describe('Shape rendering with DOM renderer', () => {
|
||||
subType: 'ellipse',
|
||||
xywh: '[200, 200, 50, 50]',
|
||||
};
|
||||
const shapeId = surfaceModel.addElement(shapeProps as any);
|
||||
const shapeId = surfaceModel.addElement(shapeProps);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
@@ -91,4 +91,48 @@ describe('Shape rendering with DOM renderer', () => {
|
||||
);
|
||||
expect(shapeElement).toBeNull();
|
||||
});
|
||||
|
||||
test('should correctly render diamond shape', async () => {
|
||||
const surfaceView = getSurface(window.doc, window.editor);
|
||||
const surfaceModel = surfaceView.model;
|
||||
const shapeProps = {
|
||||
type: 'shape',
|
||||
subType: 'diamond',
|
||||
xywh: '[150, 150, 80, 60]',
|
||||
fillColor: '#ff0000',
|
||||
strokeColor: '#000000',
|
||||
filled: true,
|
||||
};
|
||||
const shapeId = surfaceModel.addElement(shapeProps);
|
||||
await wait(100);
|
||||
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
|
||||
`[data-element-id="${shapeId}"]`
|
||||
);
|
||||
|
||||
expect(shapeElement).not.toBeNull();
|
||||
expect(shapeElement?.style.width).toBe('80px');
|
||||
expect(shapeElement?.style.height).toBe('60px');
|
||||
});
|
||||
|
||||
test('should correctly render triangle shape', async () => {
|
||||
const surfaceView = getSurface(window.doc, window.editor);
|
||||
const surfaceModel = surfaceView.model;
|
||||
const shapeProps = {
|
||||
type: 'shape',
|
||||
subType: 'triangle',
|
||||
xywh: '[150, 150, 80, 60]',
|
||||
fillColor: '#ff0000',
|
||||
strokeColor: '#000000',
|
||||
filled: true,
|
||||
};
|
||||
const shapeId = surfaceModel.addElement(shapeProps);
|
||||
await wait(100);
|
||||
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
|
||||
`[data-element-id="${shapeId}"]`
|
||||
);
|
||||
|
||||
expect(shapeElement).not.toBeNull();
|
||||
expect(shapeElement?.style.width).toBe('80px');
|
||||
expect(shapeElement?.style.height).toBe('60px');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user