refactor(editor): separate the element renders (#11461)

This commit is contained in:
Saul-Mirone
2025-04-04 13:09:46 +00:00
parent 5a1106fb88
commit 2a1306c58c
70 changed files with 390 additions and 330 deletions
@@ -0,0 +1,41 @@
import {
type ElementRenderer,
ElementRendererExtension,
} from '@blocksuite/affine-block-surface';
import {
DefaultTheme,
type HighlighterElementModel,
} from '@blocksuite/affine-model';
export const highlighter: ElementRenderer<HighlighterElementModel> = (
model,
ctx,
matrix,
renderer
) => {
const {
rotate,
deserializedXYWH: [, , w, h],
} = model;
const cx = w / 2;
const cy = h / 2;
ctx.setTransform(
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
);
const color = renderer.getColorValue(
model.color,
DefaultTheme.hightlighterColor,
true
);
ctx.fillStyle = color;
ctx.fill(new Path2D(model.commands));
};
export const HighlighterElementRendererExtension = ElementRendererExtension(
'highlighter',
highlighter
);
@@ -0,0 +1,2 @@
export * from './highlighter';
export * from './shape';
@@ -0,0 +1,66 @@
import type {
CanvasRenderer,
RoughCanvas,
} from '@blocksuite/affine-block-surface';
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { type Colors, drawGeneralShape } from './utils.js';
export function diamond(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
colors: Colors
) {
const {
seed,
strokeWidth,
filled,
strokeStyle,
roughness,
rotate,
shapeStyle,
} = model;
const [, , w, h] = model.deserializedXYWH;
const renderOffset = Math.max(strokeWidth, 0) / 2;
const renderWidth = w - renderOffset * 2;
const renderHeight = h - renderOffset * 2;
const cx = renderWidth / 2;
const cy = renderHeight / 2;
const { fillColor, strokeColor } = colors;
ctx.setTransform(
matrix
.translateSelf(renderOffset, renderOffset)
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy)
);
if (shapeStyle === 'General') {
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
} else {
rc.polygon(
[
[renderWidth / 2, 0],
[renderWidth, renderHeight / 2],
[renderWidth / 2, renderHeight],
[0, renderHeight / 2],
],
{
seed,
roughness: shapeStyle === 'Scribbled' ? roughness : 0,
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
strokeWidth,
fill: filled ? fillColor : undefined,
}
);
}
}
@@ -0,0 +1,59 @@
import type {
CanvasRenderer,
RoughCanvas,
} from '@blocksuite/affine-block-surface';
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { type Colors, drawGeneralShape } from './utils.js';
export function ellipse(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
colors: Colors
) {
const {
seed,
strokeWidth,
filled,
strokeStyle,
roughness,
rotate,
shapeStyle,
} = model;
const [, , w, h] = model.deserializedXYWH;
const renderOffset = Math.max(strokeWidth, 0) / 2;
const renderWidth = Math.max(1, w - renderOffset * 2);
const renderHeight = Math.max(1, h - renderOffset * 2);
const cx = renderWidth / 2;
const cy = renderHeight / 2;
const { fillColor, strokeColor } = colors;
ctx.setTransform(
matrix
.translateSelf(renderOffset, renderOffset)
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy)
);
if (shapeStyle === 'General') {
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
} else {
rc.ellipse(cx, cy, renderWidth, renderHeight, {
seed,
roughness: shapeStyle === 'Scribbled' ? roughness : 0,
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
strokeWidth,
fill: filled ? fillColor : undefined,
curveFitting: 1,
});
}
}
@@ -0,0 +1,183 @@
import {
type CanvasRenderer,
type ElementRenderer,
ElementRendererExtension,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import {
getFontMetrics,
getFontString,
getLineWidth,
isRTL,
measureTextInDOM,
wrapTextDeltas,
} from '@blocksuite/affine-gfx-text';
import type {
LocalShapeElementModel,
ShapeElementModel,
ShapeType,
} from '@blocksuite/affine-model';
import { DefaultTheme, TextAlign } from '@blocksuite/affine-model';
import type { IBound } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
import { diamond } from './diamond.js';
import { ellipse } from './ellipse.js';
import { rect } from './rect.js';
import { triangle } from './triangle.js';
import { type Colors, horizontalOffset, verticalOffset } from './utils.js';
const shapeRenderers: Record<
ShapeType,
(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
colors: Colors
) => void
> = {
diamond,
rect,
triangle,
ellipse,
};
export const shape: ElementRenderer<ShapeElementModel> = (
model,
ctx,
matrix,
renderer,
rc
) => {
const color = renderer.getColorValue(
model.color,
DefaultTheme.shapeTextColor,
true
);
const fillColor = renderer.getColorValue(
model.fillColor,
DefaultTheme.shapeFillColor,
true
);
const strokeColor = renderer.getColorValue(
model.strokeColor,
DefaultTheme.shapeStrokeColor,
true
);
const colors = { color, fillColor, strokeColor };
shapeRenderers[model.shapeType](model, ctx, matrix, renderer, rc, colors);
if (model.textDisplay) {
renderText(model, ctx, colors);
}
};
export const ShapeElementRendererExtension = ElementRendererExtension(
'shape',
shape
);
export * from './utils';
function renderText(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
{ color }: Colors
) {
const {
x,
y,
text,
fontSize,
fontFamily,
fontWeight,
textAlign,
w,
h,
textVerticalAlign,
padding,
} = model;
if (!text) return;
const [verticalPadding, horPadding] = padding;
const font = getFontString(model);
const { lineGap, lineHeight } = measureTextInDOM(
fontFamily,
fontSize,
fontWeight
);
const metrics = getFontMetrics(fontFamily, fontSize, fontWeight);
const lines =
typeof text === 'string'
? [text.split('\n').map(line => ({ insert: line }))]
: deltaInsertsToChunks(wrapTextDeltas(text, font, w - horPadding * 2));
const horOffset = horizontalOffset(model.w, model.textAlign, horPadding);
const vertOffset =
verticalOffset(
lines,
lineHeight + lineGap,
h,
textVerticalAlign,
verticalPadding
) +
metrics.fontBoundingBoxAscent +
lineGap / 2;
let maxLineWidth = 0;
ctx.font = font;
ctx.fillStyle = color;
ctx.textAlign = textAlign;
ctx.textBaseline = 'alphabetic';
for (const [lineIndex, line] of lines.entries()) {
for (const delta of line) {
const str = delta.insert;
const rtl = isRTL(str);
const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected;
if (shouldTemporarilyAttach) {
// to correctly render RTL text mixed with LTR, we have to append it
// to the DOM
document.body.append(ctx.canvas);
}
if (ctx.canvas.dir !== (rtl ? 'rtl' : 'ltr')) {
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
}
ctx.fillText(
str,
// 0.5 is the dom editor padding to make the text align with the DOM text
horOffset + 0.5,
lineIndex * lineHeight + vertOffset
);
maxLineWidth = Math.max(maxLineWidth, getLineWidth(str, font));
if (shouldTemporarilyAttach) {
ctx.canvas.remove();
}
}
}
const offsetX =
model.textAlign === TextAlign.Center
? (w - maxLineWidth) / 2
: model.textAlign === TextAlign.Left
? horOffset
: horOffset - maxLineWidth;
const offsetY = vertOffset - lineHeight + verticalPadding / 2;
const bound = new Bound(
x + offsetX,
y + offsetY,
maxLineWidth,
lineHeight * lines.length
) as IBound;
bound.rotate = model.rotate ?? 0;
model.textBound = bound;
}
@@ -0,0 +1,98 @@
import type {
CanvasRenderer,
RoughCanvas,
} from '@blocksuite/affine-block-surface';
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { type Colors, drawGeneralShape } from './utils.js';
/**
* "magic number" for bezier approximations of arcs (http://itc.ktu.lt/itc354/Riskus354.pdf)
*/
const K_RECT = 1 - 0.5522847498;
export function rect(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
colors: Colors
) {
const {
filled,
radius,
rotate,
roughness,
seed,
shapeStyle,
strokeStyle,
strokeWidth,
} = model;
const [, , w, h] = model.deserializedXYWH;
const renderOffset = Math.max(strokeWidth, 0) / 2;
const renderWidth = w - renderOffset * 2;
const renderHeight = h - renderOffset * 2;
const r =
radius < 1 ? Math.min(renderWidth * radius, renderHeight * radius) : radius;
const cx = renderWidth / 2;
const cy = renderHeight / 2;
const { fillColor, strokeColor } = colors;
ctx.setTransform(
matrix
.translateSelf(renderOffset, renderOffset)
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy)
);
if (shapeStyle === 'General') {
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
} else {
rc.path(
`
M ${r} 0
L ${renderWidth - r} 0
C ${renderWidth - K_RECT * r} 0 ${renderWidth} ${
K_RECT * r
} ${renderWidth} ${r}
L ${renderWidth} ${renderHeight - r}
C ${renderWidth} ${renderHeight - K_RECT * r} ${
renderWidth - K_RECT * r
} ${renderHeight} ${renderWidth - r} ${renderHeight}
L ${r} ${renderHeight}
C ${K_RECT * r} ${renderHeight} 0 ${renderHeight - K_RECT * r} 0 ${
renderHeight - r
}
L 0 ${r}
C 0 ${K_RECT * r} ${K_RECT * r} 0 ${r} 0
Z
`,
{
seed,
roughness,
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
strokeWidth,
fill: filled ? fillColor : undefined,
}
);
}
ctx.setTransform(
ctx
.getTransform()
.translateSelf(cx, cy)
.rotateSelf(-rotate)
.translateSelf(-cx, -cy)
.translateSelf(-renderOffset, -renderOffset)
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy)
);
}
@@ -0,0 +1,65 @@
import type {
CanvasRenderer,
RoughCanvas,
} from '@blocksuite/affine-block-surface';
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import { type Colors, drawGeneralShape } from './utils.js';
export function triangle(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
colors: Colors
) {
const {
seed,
strokeWidth,
filled,
strokeStyle,
roughness,
rotate,
shapeStyle,
} = model;
const [, , w, h] = model.deserializedXYWH;
const renderOffset = Math.max(strokeWidth, 0) / 2;
const renderWidth = w - renderOffset * 2;
const renderHeight = h - renderOffset * 2;
const cx = renderWidth / 2;
const cy = renderHeight / 2;
const { fillColor, strokeColor } = colors;
ctx.setTransform(
matrix
.translateSelf(renderOffset, renderOffset)
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy)
);
if (shapeStyle === 'General') {
drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor);
} else {
rc.polygon(
[
[renderWidth / 2, 0],
[renderWidth, renderHeight],
[0, renderHeight],
],
{
seed,
roughness: shapeStyle === 'Scribbled' ? roughness : 0,
strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined,
stroke: strokeStyle === 'none' ? 'none' : strokeColor,
strokeWidth,
fill: filled ? fillColor : undefined,
}
);
}
}
@@ -0,0 +1,270 @@
import type { CanvasRenderer } from '@blocksuite/affine-block-surface';
import {
getFontString,
getLineHeight,
getLineWidth,
getTextWidth,
measureTextInDOM,
type TextDelta,
wrapText,
wrapTextDeltas,
} from '@blocksuite/affine-gfx-text';
import type {
LocalShapeElementModel,
ShapeElementModel,
TextAlign,
TextVerticalAlign,
} from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { Bound, SerializedXYWH } from '@blocksuite/global/gfx';
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
export type Colors = {
color: string;
fillColor: string;
strokeColor: string;
};
export function drawGeneralShape(
ctx: CanvasRenderingContext2D,
shapeModel: ShapeElementModel | LocalShapeElementModel,
renderer: CanvasRenderer,
filled: boolean,
fillColor: string,
strokeColor: string
) {
const sizeOffset = Math.max(shapeModel.strokeWidth, 0);
const w = Math.max(shapeModel.w - sizeOffset, 0);
const h = Math.max(shapeModel.h - sizeOffset, 0);
switch (shapeModel.shapeType) {
case 'rect':
drawRect(ctx, 0, 0, w, h, shapeModel.radius ?? 0);
break;
case 'diamond':
drawDiamond(ctx, 0, 0, w, h);
break;
case 'ellipse':
drawEllipse(ctx, 0, 0, w, h);
break;
case 'triangle':
drawTriangle(ctx, 0, 0, w, h);
}
ctx.lineWidth = shapeModel.strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.fillStyle = filled ? fillColor : 'transparent';
switch (shapeModel.strokeStyle) {
case 'none':
ctx.strokeStyle = 'transparent';
break;
case 'dash':
ctx.setLineDash([12, 12]);
break;
}
if (shapeModel.shadow) {
const { blur, offsetX, offsetY, color } = shapeModel.shadow;
const scale = ctx.getTransform().a;
const enableShadowBlur = shapeModel.surface.doc
.get(FeatureFlagService)
.getFlag('enable_shape_shadow_blur');
// hard shadow, or soft shadow if `enable_shape_shadow_blur` is true
// see comment of `shape.shadow` in `ShapeElementModel`
if (blur === 0 || enableShadowBlur) {
ctx.shadowBlur = blur * scale;
ctx.shadowOffsetX = offsetX * scale;
ctx.shadowOffsetY = offsetY * scale;
}
ctx.shadowColor = renderer.getColorValue(color, undefined, true);
}
ctx.stroke();
ctx.fill();
if (shapeModel.shadow) {
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
ctx.fill();
ctx.stroke();
}
function drawRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number
) {
const r =
radius < 1
? Math.max(Math.min(width * radius, height * radius), 0)
: radius;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.arcTo(x + width, y, x + width, y + r, r);
ctx.lineTo(x + width, y + height - r);
ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
ctx.lineTo(x + r, y + height);
ctx.arcTo(x, y + height, x, y + height - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function drawDiamond(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number
) {
ctx.beginPath();
ctx.moveTo(width / 2, y);
ctx.lineTo(width, height / 2);
ctx.lineTo(width / 2, height);
ctx.lineTo(x, height / 2);
ctx.closePath();
}
function drawEllipse(
ctx: CanvasRenderingContext2D,
_x: number,
_y: number,
width: number,
height: number
) {
const cx = width / 2;
const cy = height / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, width / 2, height / 2, 0, 0, 2 * Math.PI);
ctx.closePath();
}
function drawTriangle(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number
) {
ctx.beginPath();
ctx.moveTo(width / 2, y);
ctx.lineTo(width, height);
ctx.lineTo(x, height);
ctx.closePath();
}
export function horizontalOffset(
width: number,
textAlign: TextAlign,
horiPadding: number
) {
return textAlign === 'center'
? width / 2
: textAlign === 'right'
? width - horiPadding
: horiPadding;
}
export function verticalOffset(
lines: TextDelta[][],
lineHeight: number,
height: number,
textVerticalAlign: TextVerticalAlign,
verticalPadding: number
) {
return textVerticalAlign === 'center'
? Math.max((height - lineHeight * lines.length) / 2, verticalPadding)
: textVerticalAlign === 'top'
? verticalPadding
: height - lineHeight * lines.length - verticalPadding;
}
export function normalizeShapeBound(
shape: ShapeElementModel,
bound: Bound
): Bound {
if (!shape.text) return bound;
const [verticalPadding, horiPadding] = shape.padding;
const yText = shape.text;
const { fontFamily, fontSize, fontStyle, fontWeight } = shape;
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
const font = getFontString({
fontStyle,
fontWeight,
fontSize,
fontFamily,
});
const widestCharWidth =
[...yText.toString()]
.map(char => getTextWidth(char, font))
.sort((a, b) => a - b)
.pop() ?? getTextWidth('W', font);
if (bound.w < widestCharWidth + horiPadding * 2) {
bound.w = widestCharWidth + horiPadding * 2;
}
const deltas: TextDelta[] = (yText.toDelta() as TextDelta[]).flatMap(
delta => ({
insert: wrapText(delta.insert, font, bound.w - horiPadding * 2),
attributes: delta.attributes,
})
) as TextDelta[];
const lines = deltaInsertsToChunks(deltas);
if (bound.h < lineHeight * lines.length + verticalPadding * 2) {
bound.h = lineHeight * lines.length + verticalPadding * 2;
}
return bound;
}
export function fitContent(shape: ShapeElementModel) {
const font = getFontString(shape);
if (!shape.text) {
return;
}
const [verticalPadding, horiPadding] = shape.padding;
const lines = deltaInsertsToChunks(
wrapTextDeltas(shape.text, font, shape.maxWidth || Number.MAX_SAFE_INTEGER)
);
const { lineHeight, lineGap } = measureTextInDOM(
shape.fontFamily,
shape.fontSize,
shape.fontWeight
);
let maxWidth = 0;
let height = 0;
lines.forEach(line => {
for (const delta of line) {
const str = delta.insert;
maxWidth = Math.max(maxWidth, getLineWidth(str, font));
}
height += lineHeight + lineGap;
});
height = Math.max(lineHeight + lineGap, height);
maxWidth += horiPadding * 2;
height += verticalPadding * 2;
const newXYWH = `[${shape.x},${shape.y},${maxWidth},${height}]`;
if (shape.xywh !== newXYWH) {
shape.xywh = newXYWH as SerializedXYWH;
}
}
+1
View File
@@ -1,5 +1,6 @@
export * from './consts';
export * from './draggable';
export * from './element-renderer';
export * from './overlay';
export * from './shape-tool';
export * from './text';
@@ -1,16 +1,10 @@
import {
EdgelessCRUDIdentifier,
normalizeShapeBound,
} from '@blocksuite/affine-block-surface';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import {
packColor,
type PickColorEvent,
} from '@blocksuite/affine-components/color-picker';
import type { LineDetailType } from '@blocksuite/affine-components/edgeless-line-styles-panel';
import {
createMindmapLayoutActionMenu,
createMindmapStyleActionMenu,
} from '@blocksuite/affine-gfx-mindmap';
import { createTextActions } from '@blocksuite/affine-gfx-text';
import {
type Color,
DefaultTheme,
@@ -35,7 +29,6 @@ import {
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import {
createTextActions,
getRootBlock,
LINE_STYLE_LIST,
renderMenu,
@@ -46,62 +39,13 @@ import { BlockFlavourIdentifier } from '@blocksuite/std';
import { html } from 'lit';
import isEqual from 'lodash-es/isEqual';
import { normalizeShapeBound } from '../element-renderer';
import type { ShapeToolOption } from '../shape-tool';
import { mountShapeTextEditor } from '../text/edgeless-shape-text-editor';
import { ShapeComponentConfig } from './shape-menu-config';
export const shapeToolbarConfig = {
actions: [
{
id: 'a.mindmap-style',
when(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
return models.some(hasGrouped);
},
content(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
if (!models.length) return null;
let mindmaps = models
.map(model => model.group)
.filter(model => ctx.matchModel(model, MindmapElementModel));
if (!mindmaps.length) return null;
// Not displayed when there is both a normal shape and a mindmap shape.
if (models.length !== mindmaps.length) return null;
mindmaps = Array.from(new Set(mindmaps));
return createMindmapStyleActionMenu(ctx, mindmaps);
},
},
{
id: 'b.mindmap-layout',
when(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
return models.some(hasGrouped);
},
content(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
if (!models.length) return null;
let mindmaps = models
.map(model => model.group)
.filter(model => ctx.matchModel(model, MindmapElementModel));
if (!mindmaps.length) return null;
// Not displayed when there is both a normal shape and a mindmap shape.
if (models.length !== mindmaps.length) return null;
mindmaps = Array.from(new Set(mindmaps));
// It's a sub node.
if (models.length === 1 && mindmaps[0].tree.element !== models[0])
return null;
return createMindmapLayoutActionMenu(ctx, mindmaps);
},
},
{
id: 'c.switch-type',
when(ctx) {
@@ -386,7 +330,7 @@ function getTextColor(fillColor: Color, isNotTransparent = false) {
return DefaultTheme.shapeTextColor;
}
function hasGrouped(model: ShapeElementModel) {
export function hasGrouped(model: ShapeElementModel) {
return model.group instanceof MindmapElementModel;
}