feat(editor): impl shape text with dom renderer (#13471)

#### PR Dependency Tree


* **PR #13464**
  * **PR #13465**
    * **PR #13471** 👈
      * **PR #13472**
        * **PR #13473**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **New Features**
* DOM rendering added for groups, mind maps and connectors so group
titles/outlines and mindmap connectors are visible on canvas.
* Shapes now support right-to-left text with proper vertical alignment.
* **Improvements**
  * Connector labels scale with viewport zoom for crisper display.
* Group-related selections (including nested groups) now update visuals
consistently.
* **Performance**
* Reduced DOM churn and fewer redraws during rendering and selection
changes.
* **Refactor**
* Renderer import/export surfaces consolidated with no user-facing
behavior changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
L-Sun
2025-08-14 12:29:13 +08:00
committed by GitHub
parent fbf234f9fa
commit 680f3b3006
22 changed files with 251 additions and 35 deletions

View File

@@ -11,7 +11,7 @@ import {
getBoundWithRotation,
intersects,
} from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import { type BlockStdScope, SurfaceSelection } from '@blocksuite/std';
import type {
GfxCompatibleInterface,
GridManager,
@@ -390,6 +390,36 @@ export class DomRenderer {
this.refresh();
})
);
// Workaround for the group rendering reactive update when selection changed
let lastSet = new Set<string>();
this._disposables.add(
this.std.selection.filter$(SurfaceSelection).subscribe(selections => {
const groupRelatedSelection = new Set(
selections.flatMap(s =>
s.elements.flatMap(e => {
const element = surfaceModel.getElementById(e);
if (
element &&
(element.type === 'group' || element.groups.length !== 0)
) {
return [element.id, ...element.groups.map(g => g.id)];
}
return [];
})
)
);
if (lastSet.symmetricDifference(groupRelatedSelection).size !== 0) {
lastSet.union(groupRelatedSelection).forEach(g => {
this._markElementDirty(g, UpdateType.ELEMENT_UPDATED);
});
this.refresh();
}
lastSet = groupRelatedSelection;
})
);
}
addOverlay = (overlay: Overlay) => {

View File

@@ -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
);

View File

@@ -1,9 +1,8 @@
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 './renderer';
export * from './text';
export * from './toolbar/config';
export * from './toolbar/quick-tool';

View File

@@ -1,14 +1,18 @@
import type { DomRenderer } from '@blocksuite/affine-block-surface';
import {
DomElementRendererExtension,
type DomRenderer,
} from '@blocksuite/affine-block-surface';
import {
type ConnectorElementModel,
ConnectorMode,
DefaultTheme,
type LocalConnectorElementModel,
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';
import { isConnectorWithLabel } from '../connector-manager';
import { DEFAULT_ARROW_SIZE } from './utils';
interface PathBounds {
minX: number;
@@ -221,8 +225,8 @@ function renderConnectorLabel(
* @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,
export const connectorBaseDomRenderer = (
model: ConnectorElementModel | LocalConnectorElementModel,
element: HTMLElement,
renderer: DomRenderer
): void => {
@@ -358,7 +362,21 @@ export const connectorDomRenderer = (
element.style.height = `${model.h * zoom}px`;
element.style.overflow = 'visible';
element.style.pointerEvents = 'none';
// Render label if present
renderConnectorLabel(model, element, renderer, zoom);
};
export const connectorDomRenderer = (
model: ConnectorElementModel,
element: HTMLElement,
renderer: DomRenderer
): void => {
connectorBaseDomRenderer(model, element, renderer);
renderConnectorLabel(model, element, renderer, renderer.viewport.zoom);
};
/**
* Extension to register the DOM-based renderer for 'connector' elements.
*/
export const ConnectorDomRendererExtension = DomElementRendererExtension(
'connector',
connectorDomRenderer
);

View File

@@ -25,7 +25,7 @@ import {
} from '@blocksuite/global/gfx';
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
import { isConnectorWithLabel } from '../connector-manager.js';
import { isConnectorWithLabel } from '../connector-manager';
import {
DEFAULT_ARROW_SIZE,
getArrowOptions,
@@ -33,7 +33,7 @@ import {
renderCircle,
renderDiamond,
renderTriangle,
} from './utils.js';
} from './utils';
export const connector: ElementRenderer<
ConnectorElementModel | LocalConnectorElementModel

View File

@@ -0,0 +1,2 @@
export * from './dom-renderer';
export * from './element-renderer';

View File

@@ -6,9 +6,11 @@ import {
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 {
ConnectorDomRendererExtension,
ConnectorElementRendererExtension,
} from './renderer';
import { connectorToolbarExtension } from './toolbar/config';
import { connectorQuickTool } from './toolbar/quick-tool';
import { ConnectorElementView, ConnectorInteraction } from './view/view';

View File

@@ -1,6 +1,6 @@
export * from './adapter';
export * from './command';
export * from './element-renderer';
export * from './element-view';
export * from './renderer';
export * from './text/text';
export * from './toolbar/config';

View File

@@ -0,0 +1,62 @@
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
import { FontWeight, type GroupElementModel } from '@blocksuite/affine-model';
import {
GROUP_TITLE_FONT,
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_PADDING,
} from './consts';
import { titleRenderParams } from './utils';
export const GroupDomRendererExtension = DomElementRendererExtension(
'group',
(model: GroupElementModel, domElement, renderer) => {
const { zoom } = renderer.viewport;
const [, , w, h] = model.deserializedXYWH;
const renderParams = titleRenderParams(model, zoom);
model.externalXYWH = renderParams.titleBound.serialize();
domElement.innerHTML = '';
domElement.style.outlineColor = '';
domElement.style.outlineWidth = '';
domElement.style.outlineStyle = '';
const elements = renderer.provider.selectedElements?.() || [];
const renderTitle = () => {
const { text } = renderParams;
const titleElement = document.createElement('div');
titleElement.style.transform = `translate(0, -100%)`;
titleElement.style.fontFamily = GROUP_TITLE_FONT;
titleElement.style.fontWeight = `${FontWeight.Regular}`;
titleElement.style.fontStyle = 'normal';
titleElement.style.fontSize = `${GROUP_TITLE_FONT_SIZE}px`;
titleElement.style.color = renderer.getPropertyValue('--affine-blue');
titleElement.style.textAlign = 'left';
titleElement.style.padding = `${GROUP_TITLE_PADDING[0]}px ${GROUP_TITLE_PADDING[1]}px`;
titleElement.textContent = text;
domElement.replaceChildren(titleElement);
};
if (elements.includes(model.id)) {
if (model.showTitle) {
renderTitle();
} else {
domElement.style.outlineColor =
renderer.getPropertyValue('--affine-blue');
domElement.style.outlineWidth = '2px';
domElement.style.outlineStyle = 'solid';
}
} else if (model.childElements.some(child => elements.includes(child.id))) {
domElement.style.outlineColor = '#8FD1FF';
domElement.style.outlineWidth = '2px';
domElement.style.outlineStyle = 'solid';
}
domElement.style.width = `${w * zoom}px`;
domElement.style.height = `${h * zoom}px`;
domElement.style.overflow = 'visible';
domElement.style.pointerEvents = 'none';
}
);

View File

@@ -6,7 +6,7 @@ import {
import type { GroupElementModel } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import { titleRenderParams } from './utils.js';
import { titleRenderParams } from './utils';
export const group: ElementRenderer<GroupElementModel> = (
model,

View File

@@ -0,0 +1,2 @@
export * from './dom-renderer';
export * from './element-renderer';

View File

@@ -13,7 +13,7 @@ import {
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_OFFSET,
GROUP_TITLE_PADDING,
} from './consts.js';
} from './consts';
export function titleRenderParams(group: GroupElementModel, zoom: number) {
let text = group.title.toString().trim();

View File

@@ -21,7 +21,7 @@ import {
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_OFFSET,
GROUP_TITLE_PADDING,
} from '../element-renderer/consts';
} from '../renderer/consts';
export function mountGroupTitleEditor(
group: GroupElementModel,

View File

@@ -4,9 +4,12 @@ import {
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { GroupElementRendererExtension } from './element-renderer';
import { GroupElementView, GroupInteraction } from './element-view';
import { GroupInteractionExtension } from './interaction-ext';
import {
GroupDomRendererExtension,
GroupElementRendererExtension,
} from './renderer';
import { groupToolbarExtension } from './toolbar/config';
export class GroupViewExtension extends ViewExtensionProvider {
@@ -20,6 +23,7 @@ export class GroupViewExtension extends ViewExtensionProvider {
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(GroupElementRendererExtension);
context.register(GroupDomRendererExtension);
context.register(GroupElementView);
if (this.isEdgeless(context.scope)) {
context.register(groupToolbarExtension);

View File

@@ -1,7 +1,7 @@
export * from './adapter';
export * from './element-renderer';
export * from './indicator-overlay';
export * from './interactivity';
export * from './renderer';
export * from './toolbar/config';
export * from './toolbar/senior-tool';
export * from './utils';

View File

@@ -0,0 +1,65 @@
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
import {
connectorBaseDomRenderer,
ConnectorPathGenerator,
} from '@blocksuite/affine-gfx-connector';
import type {
MindmapElementModel,
MindmapNode,
} from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/std/gfx';
export const MindmapDomRendererExtension = DomElementRendererExtension(
'mindmap',
(model: MindmapElementModel, domElement, renderer) => {
const bound = model.elementBound;
const { zoom } = renderer.viewport;
// Set element size and position
domElement.style.width = `${bound.w * zoom}px`;
domElement.style.height = `${bound.h * zoom}px`;
domElement.style.overflow = 'visible';
domElement.style.pointerEvents = 'none';
const newChildren: HTMLDivElement[] = [];
const traverse = (node: MindmapNode) => {
const connectors = model.getConnectors(node);
if (!connectors) return;
connectors.reverse().forEach(result => {
const { connector, outdated } = result;
const elementGetter = (id: string) =>
model.surface.getElementById(id) ??
(model.surface.store.getModelById(id) as GfxModel);
if (outdated) {
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
}
const connectorContainer = document.createElement('div');
connectorContainer.style.position = 'absolute';
connectorContainer.style.transformOrigin = 'top left';
const geometricStyles = {
left: `${(connector.x - bound.x) * zoom}px`,
top: `${(connector.y - bound.y) * zoom}px`,
};
const opacityStyle = { opacity: node.element.opacity };
Object.assign(connectorContainer.style, geometricStyles, opacityStyle);
connectorBaseDomRenderer(connector, connectorContainer, renderer);
newChildren.push(connectorContainer);
});
if (node.detail.collapsed) {
return;
} else {
node.children.forEach(traverse);
}
};
model.tree && traverse(model.tree);
domElement.replaceChildren(...newChildren);
}
);

View File

@@ -0,0 +1,2 @@
export * from './dom-renderer';
export * from './element-renderer';

View File

@@ -4,9 +4,12 @@ import {
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { MindmapElementRendererExtension } from './element-renderer';
import { MindMapIndicatorOverlay } from './indicator-overlay';
import { MindMapDragExtension } from './interactivity';
import {
MindmapDomRendererExtension,
MindmapElementRendererExtension,
} from './renderer';
import {
mindmapToolbarExtension,
shapeMindmapToolbarExtension,
@@ -25,6 +28,7 @@ export class MindmapViewExtension extends ViewExtensionProvider {
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(MindmapElementRendererExtension);
context.register(MindmapDomRendererExtension);
context.register(mindMapSeniorTool);
context.register(mindmapToolbarExtension);
context.register(shapeMindmapToolbarExtension);

View File

@@ -1,4 +1,5 @@
import type { DomRenderer } from '@blocksuite/affine-block-surface';
import { isRTL } from '@blocksuite/affine-gfx-text';
import type { ShapeElementModel } from '@blocksuite/affine-model';
import { DefaultTheme } from '@blocksuite/affine-model';
import { SVGShapeBuilder } from '@blocksuite/global/gfx';
@@ -99,6 +100,8 @@ export const shapeDomRenderer = (
const unscaledWidth = model.w;
const unscaledHeight = model.h;
const newChildren: Element[] = [];
const fillColor = renderer.getColorValue(
model.fillColor,
DefaultTheme.shapeFillColor,
@@ -170,8 +173,7 @@ export const shapeDomRenderer = (
}
svg.append(polygon);
// Replace existing children to avoid memory leaks
element.replaceChildren(svg);
newChildren.push(svg);
} else {
// Standard rendering for other shapes (e.g., rect, ellipse)
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
@@ -179,6 +181,41 @@ export const shapeDomRenderer = (
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
}
if (model.textDisplay && model.text) {
const str = model.text.toString();
const textElement = document.createElement('div');
if (isRTL(str)) {
textElement.dir = 'rtl';
}
textElement.style.position = 'absolute';
textElement.style.inset = '0';
textElement.style.display = 'flex';
textElement.style.flexDirection = 'column';
textElement.style.justifyContent =
model.textVerticalAlign === 'center'
? 'center'
: model.textVerticalAlign === 'top'
? 'flex-start'
: 'flex-end';
textElement.style.whiteSpace = 'pre-wrap';
textElement.style.wordBreak = 'break-word';
textElement.style.textAlign = model.textAlign;
textElement.style.alignmentBaseline = 'alphabetic';
textElement.style.fontFamily = model.fontFamily;
textElement.style.fontSize = `${model.fontSize * zoom}px`;
textElement.style.fontWeight = model.fontWeight;
textElement.style.color = renderer.getColorValue(
model.color,
DefaultTheme.shapeTextColor,
true
);
textElement.textContent = str;
newChildren.push(textElement);
}
// Replace existing children to avoid memory leaks
element.replaceChildren(...newChildren);
applyTransformStyles(model, element);
manageClassNames(model, element);