Compare commits

..

3 Commits

Author SHA1 Message Date
L-Sun 3d30670987 refactor(editor): use add and delete impl move block 2025-05-29 17:36:29 +08:00
donteatfriedrice bc67766bb9 fix(editor): cleanup transformer middleware slot subscriptions (#12630)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Improved resource management by introducing explicit cleanup for various middleware components, ensuring that resources are properly released when no longer needed.

- **Refactor**
  - Updated middleware logic to support cleanup functions, enhancing the stability and performance of the application by preventing potential memory leaks.

- **Chores**
  - Enhanced lifecycle management in core systems to automatically dispose of resources when appropriate.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-29 08:33:30 +00:00
L-Sun 9a96cfded0 fix(editor): viewportElement is undefined in edgeless root block (#12626)
This PR fixed that `rootComponent.viewportElement` is undefeined in edgeless mode, which leads that toast can not be render in playground.

https://github.com/toeverything/AFFiNE/blob/388641bc89caf37451c2d57ae5e538d432bf1518/blocksuite/affine/components/src/toast/create.ts#L23-L35

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **Refactor**
  - Improved internal code organization for better maintainability. No changes to visible features or functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-05-29 08:10:54 +00:00
30 changed files with 347 additions and 1670 deletions
@@ -129,7 +129,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
) as SurfaceBlockModel;
}
private get _viewportElement(): HTMLElement {
get viewportElement(): HTMLElement {
return this.std.get(ViewportElementProvider).viewportElement;
}
@@ -267,7 +267,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
this.gfx.viewport.onResize();
});
resizeObserver.observe(this._viewportElement);
resizeObserver.observe(this.viewportElement);
this._resizeObserver = resizeObserver;
}
@@ -43,23 +43,6 @@ 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',
@@ -158,18 +141,6 @@ 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>();
@@ -215,7 +186,6 @@ export class DomRenderer {
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this._markViewportDirty();
this.refresh();
})
);
@@ -225,7 +195,6 @@ export class DomRenderer {
if (this._sizeUpdatedRafId) return;
this._sizeUpdatedRafId = requestConnectedFrame(() => {
this._sizeUpdatedRafId = null;
this._markSizeDirty();
this._resetSize();
this._render();
this.refresh();
@@ -239,7 +208,6 @@ export class DomRenderer {
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this._markUsePlaceholderDirty();
this.refresh();
}
})
@@ -339,292 +307,6 @@ 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[] = [];
@@ -705,4 +387,100 @@ 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();
}
}
@@ -1,11 +0,0 @@
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
);
@@ -1,63 +0,0 @@
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,7 +1,6 @@
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,7 +6,6 @@ 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 {
@@ -31,7 +30,6 @@ export class BrushViewExtension extends ViewExtensionProvider {
context.register(HighlighterTool);
context.register(BrushElementRendererExtension);
context.register(BrushDomRendererExtension);
context.register(brushToolbarExtension);
context.register(highlighterToolbarExtension);
@@ -1,11 +0,0 @@
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
);
@@ -1,367 +0,0 @@
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,7 +2,6 @@ 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,7 +7,6 @@ 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';
@@ -25,7 +24,6 @@ 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,7 +1,6 @@
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';
@@ -10,35 +9,18 @@ function applyShapeSpecificStyles(
element: HTMLElement,
zoom: number
) {
// 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();
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 = '';
}
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(
@@ -96,9 +78,6 @@ 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,
@@ -110,77 +89,17 @@ export const shapeDomRenderer = (
true
);
element.style.width = `${unscaledWidth * zoom}px`;
element.style.height = `${unscaledHeight * zoom}px`;
element.style.boxSizing = 'border-box';
element.style.width = `${model.w * zoom}px`;
element.style.height = `${model.h * zoom}px`;
// Apply shape-specific clipping, border-radius, and potentially clear innerHTML
applyShapeSpecificStyles(model, element, zoom);
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
}
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
applyBorderStyles(model, element, strokeColor, zoom);
applyTransformStyles(model, element);
element.style.boxSizing = 'border-box';
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
manageClassNames(model, element);
@@ -37,7 +37,7 @@ const handlePoint = (
};
const sliceText = (slots: TransformerSlots, std: EditorHost['std']) => {
slots.afterExport.subscribe(payload => {
const afterExportSubscription = slots.afterExport.subscribe(payload => {
if (payload.type === 'block') {
const snapshot = payload.snapshot;
@@ -53,10 +53,14 @@ const sliceText = (slots: TransformerSlots, std: EditorHost['std']) => {
}
}
});
return () => {
afterExportSubscription.unsubscribe();
};
};
export const copyMiddleware = (std: BlockStdScope): TransformerMiddleware => {
return ({ slots }) => {
sliceText(slots, std);
return sliceText(slots, std);
};
};
@@ -3,7 +3,7 @@ import type { TransformerMiddleware } from '@blocksuite/store';
export const fileNameMiddleware =
(fileName?: string): TransformerMiddleware =>
({ slots }) => {
slots.beforeImport.subscribe(payload => {
const beforeImportSubscription = slots.beforeImport.subscribe(payload => {
if (payload.type !== 'page') {
return;
}
@@ -20,4 +20,8 @@ export const fileNameMiddleware =
],
};
});
return () => {
beforeImportSubscription.unsubscribe();
};
};
@@ -528,7 +528,7 @@ export const pasteMiddleware = (
): TransformerMiddleware => {
return ({ slots }) => {
let tr: PasteTr | undefined;
slots.beforeImport.subscribe(payload => {
const beforeImportSubscription = slots.beforeImport.subscribe(payload => {
if (payload.type === 'slice') {
const { snapshot } = payload;
flatNote(snapshot);
@@ -543,13 +543,18 @@ export const pasteMiddleware = (
}
}
});
slots.afterImport.subscribe(payload => {
const afterImportSubscription = slots.afterImport.subscribe(payload => {
if (tr && payload.type === 'slice') {
tr.pasted();
tr.focusPasted();
tr.convertToLinkedDoc();
}
});
return () => {
beforeImportSubscription.unsubscribe();
afterImportSubscription.unsubscribe();
};
};
};
@@ -32,7 +32,7 @@ export const replaceIdMiddleware =
map(({ model }) => model)
);
afterImportBlock$
const afterImportBlockSubscription = afterImportBlock$
.pipe(filter(model => matchModels(model, [DatabaseBlockModel])))
.subscribe(model => {
Object.keys(model.props.cells).forEach(cellId => {
@@ -44,7 +44,7 @@ export const replaceIdMiddleware =
});
// replace LinkedPage pageId with new id in paragraph blocks
afterImportBlock$
const replaceLinkedPageIdSubscription = afterImportBlock$
.pipe(
filter(model =>
matchModels(model, [ParagraphBlockModel, ListBlockModel])
@@ -84,7 +84,7 @@ export const replaceIdMiddleware =
}
});
afterImportBlock$
const replaceSurfaceRefIdSubscription = afterImportBlock$
.pipe(filter(model => matchModels(model, [SurfaceRefBlockModel])))
.subscribe(model => {
const original = model.props.reference;
@@ -105,7 +105,7 @@ export const replaceIdMiddleware =
});
// TODO(@fundon): process linked block/element
afterImportBlock$
const replaceLinkedDocIdSubscription = afterImportBlock$
.pipe(
filter(model =>
matchModels(model, [EmbedLinkedDocModel, EmbedSyncedDocModel])
@@ -128,7 +128,7 @@ export const replaceIdMiddleware =
// Before Import
slots.beforeImport
const beforeImportPageSubscription = slots.beforeImport
.pipe(filter(payload => payload.type === 'page'))
.subscribe(payload => {
if (idMap.has(payload.snapshot.meta.id)) {
@@ -140,7 +140,7 @@ export const replaceIdMiddleware =
payload.snapshot.meta.id = newId;
});
slots.beforeImport
const beforeImportBlockSubscription = slots.beforeImport
.pipe(
filter(
(payload): payload is BeforeImportBlockPayload =>
@@ -244,4 +244,13 @@ export const replaceIdMiddleware =
});
}
});
return () => {
afterImportBlockSubscription.unsubscribe();
replaceLinkedPageIdSubscription.unsubscribe();
replaceSurfaceRefIdSubscription.unsubscribe();
replaceLinkedDocIdSubscription.unsubscribe();
beforeImportPageSubscription.unsubscribe();
beforeImportBlockSubscription.unsubscribe();
};
};
@@ -5,33 +5,42 @@ export const surfaceRefToEmbed =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
let pageId: string | null = null;
slots.beforeImport.subscribe(payload => {
if (payload.type === 'slice') {
pageId = payload.snapshot.pageId;
const beforeImportSliceSubscription = slots.beforeImport.subscribe(
payload => {
if (payload.type === 'slice') {
pageId = payload.snapshot.pageId;
}
}
});
slots.beforeImport.subscribe(payload => {
// only handle surface-ref block snapshot
if (
payload.type !== 'block' ||
payload.snapshot.flavour !== 'affine:surface-ref'
)
return;
);
const beforeImportBlockSubscription = slots.beforeImport.subscribe(
payload => {
// only handle surface-ref block snapshot
if (
payload.type !== 'block' ||
payload.snapshot.flavour !== 'affine:surface-ref'
)
return;
// turn into embed-linked-doc if the current doc is different from the pageId of the surface-ref block
const isNotSameDoc = pageId !== std.store.doc.id;
if (pageId && isNotSameDoc) {
// The blockId of the original surface-ref block
const blockId = payload.snapshot.id;
payload.snapshot.id = std.workspace.idGenerator();
payload.snapshot.flavour = 'affine:embed-linked-doc';
payload.snapshot.props = {
pageId,
params: {
mode: 'page',
blockIds: [blockId],
},
};
// turn into embed-linked-doc if the current doc is different from the pageId of the surface-ref block
const isNotSameDoc = pageId !== std.store.doc.id;
if (pageId && isNotSameDoc) {
// The blockId of the original surface-ref block
const blockId = payload.snapshot.id;
payload.snapshot.id = std.workspace.idGenerator();
payload.snapshot.flavour = 'affine:embed-linked-doc';
payload.snapshot.props = {
pageId,
params: {
mode: 'page',
blockIds: [blockId],
},
};
}
}
});
);
return () => {
beforeImportSliceSubscription.unsubscribe();
beforeImportBlockSubscription.unsubscribe();
};
};
@@ -3,9 +3,13 @@ import type { DocMeta, TransformerMiddleware } from '@blocksuite/store';
export const titleMiddleware =
(metas: DocMeta[]): TransformerMiddleware =>
({ slots, adapterConfigs }) => {
slots.beforeExport.subscribe(() => {
const beforeExportSubscription = slots.beforeExport.subscribe(() => {
for (const meta of metas) {
adapterConfigs.set('title:' + meta.id, meta.title);
}
});
return () => {
beforeExportSubscription.unsubscribe();
};
};
@@ -79,7 +79,7 @@ export const uploadMiddleware = (
}
}
blockView$
const blockViewSubscription = blockView$
.pipe(
map(payload => {
if (assetsManager.uploadingAssetsMap.size === 0) return null;
@@ -110,5 +110,9 @@ export const uploadMiddleware = (
)
)
.subscribe();
return () => {
blockViewSubscription.unsubscribe();
};
};
};
@@ -40,7 +40,7 @@ export const gfxBlocksFilter = (
}
return ({ slots, transformerConfigs }) => {
slots.beforeExport.subscribe(payload => {
const beforeExportSubscription = slots.beforeExport.subscribe(payload => {
if (payload.type !== 'block') {
return;
}
@@ -54,7 +54,7 @@ export const gfxBlocksFilter = (
}
});
slots.afterExport.subscribe(payload => {
const afterExportSubscription = slots.afterExport.subscribe(payload => {
if (payload.type !== 'block') {
return;
}
@@ -110,5 +110,10 @@ export const gfxBlocksFilter = (
});
}
});
return () => {
beforeExportSubscription.unsubscribe();
afterExportSubscription.unsubscribe();
};
};
};
@@ -9,36 +9,45 @@ export const newIdCrossDoc =
let samePage = false;
const oldToNewIdMap = new Map<string, string>();
slots.beforeImport.subscribe(payload => {
if (payload.type === 'slice') {
samePage = payload.snapshot.pageId === std.store.id;
const beforeImportSliceSubscription = slots.beforeImport.subscribe(
payload => {
if (payload.type === 'slice') {
samePage = payload.snapshot.pageId === std.store.id;
}
if (payload.type === 'block' && !samePage) {
const newId = std.workspace.idGenerator();
oldToNewIdMap.set(payload.snapshot.id, newId);
payload.snapshot.id = newId;
}
}
if (payload.type === 'block' && !samePage) {
const newId = std.workspace.idGenerator();
);
oldToNewIdMap.set(payload.snapshot.id, newId);
payload.snapshot.id = newId;
const afterImportBlockSubscription = slots.afterImport.subscribe(
payload => {
if (
!samePage &&
payload.type === 'block' &&
matchModels(payload.model, [DatabaseBlockModel])
) {
const originalCells = payload.model.props.cells;
const newCells = {
...originalCells,
};
Object.keys(originalCells).forEach(cellId => {
if (oldToNewIdMap.has(cellId)) {
newCells[oldToNewIdMap.get(cellId)!] = originalCells[cellId];
}
});
payload.model.props.cells$.value = newCells;
}
}
});
);
slots.afterImport.subscribe(payload => {
if (
!samePage &&
payload.type === 'block' &&
matchModels(payload.model, [DatabaseBlockModel])
) {
const originalCells = payload.model.props.cells;
const newCells = {
...originalCells,
};
Object.keys(originalCells).forEach(cellId => {
if (oldToNewIdMap.has(cellId)) {
newCells[oldToNewIdMap.get(cellId)!] = originalCells[cellId];
}
});
payload.model.props.cells$.value = newCells;
}
});
return () => {
beforeImportSliceSubscription.unsubscribe();
afterImportBlockSubscription.unsubscribe();
};
};
@@ -7,19 +7,25 @@ import type { TransformerMiddleware } from '@blocksuite/store';
export const reorderList =
(std: BlockStdScope): TransformerMiddleware =>
({ slots }) => {
slots.afterImport.subscribe(payload => {
if (payload.type === 'block') {
const model = payload.model;
if (
matchModels(model, [ListBlockModel]) &&
model.props.type === 'numbered'
) {
const next = std.store.getNext(model);
correctNumberedListsOrderToPrev(std.store, model);
if (next) {
correctNumberedListsOrderToPrev(std.store, next);
const afterImportBlockSubscription = slots.afterImport.subscribe(
payload => {
if (payload.type === 'block') {
const model = payload.model;
if (
matchModels(model, [ListBlockModel]) &&
model.props.type === 'numbered'
) {
const next = std.store.getNext(model);
correctNumberedListsOrderToPrev(std.store, model);
if (next) {
correctNumberedListsOrderToPrev(std.store, next);
}
}
}
}
});
);
return () => {
afterImportBlockSubscription.unsubscribe();
};
};
@@ -1,73 +0,0 @@
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,5 +4,4 @@ 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';
@@ -1,160 +0,0 @@
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();
}
}
@@ -22,6 +22,7 @@ import {
type BlockModel,
type BlockOptions,
type BlockProps,
toDraftModel,
type YBlock,
} from '../block/index.js';
import { DocCRUD } from './crud.js';
@@ -1143,13 +1144,82 @@ export class Store {
return;
}
this.transact(() => {
this._crud.moveBlocks(
blocksToMove.map(model => model.id),
newParent.id,
targetSibling?.id ?? null,
shouldInsertBeforeSibling
if (
blocksToMove.length > 1 &&
targetSibling &&
blocksToMove.includes(targetSibling)
) {
console.error(
'Cannot move blocks when the target sibling is in the blocks to move'
);
return;
}
if (blocksToMove.length === 1 && targetSibling === blocksToMove[0]) {
return;
}
if (blocksToMove.includes(newParent)) {
console.error(
'Cannot move blocks when the new parent is in the blocks to move'
);
return;
}
for (let i = 0; i < blocksToMove.length - 1; i++) {
const block = blocksToMove[i];
const nextBlock = blocksToMove[i + 1];
if (
block.parent &&
block.parent === nextBlock.parent &&
this.getNext(block) !== nextBlock
) {
console.error(
'The blocks to move are not contiguous under their parent'
);
return;
}
}
this.transact(() => {
let insertIndex = 0;
const updateInsertIndex = () => {
if (targetSibling) {
const targetIndex = newParent.children.indexOf(targetSibling);
if (targetIndex === -1) {
console.warn('Target sibling not found, just insert to the end');
} else {
insertIndex = shouldInsertBeforeSibling
? targetIndex
: targetIndex + 1;
}
}
};
blocksToMove.forEach(block => {
const draftModel = toDraftModel(block);
const props = {
...draftModel.props,
children: block.children,
};
console.log(props);
this._crud.deleteBlock(block.id, {
deleteChildren: false,
});
updateInsertIndex();
this._crud.addBlock(
draftModel.id,
draftModel.flavour,
props,
newParent.id,
insertIndex
);
});
});
}
@@ -113,6 +113,8 @@ type TransformerMiddlewareOptions = {
transformerConfigs: Map<string, unknown>;
};
type TransformerMiddlewareCleanup = () => void;
export type TransformerMiddleware = (
options: TransformerMiddlewareOptions
) => void;
) => void | TransformerMiddlewareCleanup;
@@ -1,3 +1,4 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick } from '@blocksuite/global/utils';
import { Subject } from 'rxjs';
@@ -67,6 +68,8 @@ export class Transformer {
private readonly _docCRUD: DocCRUD;
private readonly _disposables: DisposableGroup = new DisposableGroup();
private readonly _slots: TransformerSlots = {
beforeImport: new Subject<BeforeImportPayload>(),
afterImport: new Subject<AfterImportPayload>(),
@@ -366,13 +369,16 @@ export class Transformer {
this._docCRUD = docCRUD;
middlewares.forEach(middleware => {
middleware({
const cleanup = middleware({
slots: this._slots,
docCRUD: this._docCRUD,
assetsManager: this._assetsManager,
adapterConfigs: this._adapterConfigs,
transformerConfigs: this._transformerConfigs,
});
if (cleanup) {
this._disposables.add(cleanup);
}
});
}
@@ -646,4 +652,9 @@ export class Transformer {
reset() {
this._assetsManager.cleanup();
}
[Symbol.dispose]() {
this._disposables.dispose();
this._assetsManager.cleanup();
}
}
@@ -1,268 +0,0 @@
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();
});
});
@@ -1,158 +0,0 @@
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);
const shapeId = surfaceModel.addElement(shapeProps as any);
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);
const shapeId = surfaceModel.addElement(shapeProps as any);
await new Promise(resolve => setTimeout(resolve, 100));
@@ -91,48 +91,4 @@ 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');
});
});