mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
2
blocksuite/affine/gfx/connector/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/connector/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
62
blocksuite/affine/gfx/group/src/renderer/dom-renderer.ts
Normal file
62
blocksuite/affine/gfx/group/src/renderer/dom-renderer.ts
Normal 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';
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
2
blocksuite/affine/gfx/group/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/group/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
65
blocksuite/affine/gfx/mindmap/src/renderer/dom-renderer.ts
Normal file
65
blocksuite/affine/gfx/mindmap/src/renderer/dom-renderer.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
2
blocksuite/affine/gfx/mindmap/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/mindmap/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user