chore(editor): reorg packages (#10702)

This commit is contained in:
Saul-Mirone
2025-03-08 03:57:04 +00:00
parent 334912e85b
commit 8aedef0a36
961 changed files with 837 additions and 927 deletions
@@ -0,0 +1,435 @@
import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import type {
GridManager,
LayerManager,
SurfaceBlockModel,
Viewport,
} from '@blocksuite/block-std/gfx';
import type { IBound } from '@blocksuite/global/gfx';
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
import { DisposableGroup, Slot } from '@blocksuite/global/slot';
import last from 'lodash-es/last';
import type { SurfaceElementModel } from '../element-model/base.js';
import { RoughCanvas } from '../utils/rough/canvas.js';
import type { ElementRenderer } from './elements/index.js';
import type { Overlay } from './overlay.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
getColorScheme: () => ColorScheme;
getColorValue: (color: Color, fallback?: Color, real?: boolean) => string;
getPropertyValue: (property: string) => string;
selectedElements?: () => string[];
};
type RendererOptions = {
viewport: Viewport;
layerManager: LayerManager;
provider?: Partial<EnvProvider>;
enableStackingCanvas?: boolean;
onStackingCanvasCreated?: (canvas: HTMLCanvasElement) => void;
elementRenderers: Record<string, ElementRenderer>;
gridManager: GridManager;
surfaceModel: SurfaceBlockModel;
};
export class CanvasRenderer {
private _container!: HTMLElement;
private readonly _disposables = new DisposableGroup();
private readonly _overlays = new Set<Overlay>();
private _refreshRafId: number | null = null;
private _stackingCanvas: HTMLCanvasElement[] = [];
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
elementRenderers: Record<string, ElementRenderer>;
grid: GridManager;
layerManager: LayerManager;
provider: Partial<EnvProvider>;
stackingCanvasUpdated = new Slot<{
canvases: HTMLCanvasElement[];
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
}>();
viewport: Viewport;
get stackingCanvas() {
return this._stackingCanvas;
}
constructor(options: RendererOptions) {
const canvas = document.createElement('canvas');
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
this.viewport = options.viewport;
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
this.elementRenderers = options.elementRenderers;
this._initViewport();
options.enableStackingCanvas = options.enableStackingCanvas ?? false;
if (options.enableStackingCanvas) {
this._initStackingCanvas(options.onStackingCanvasCreated);
}
this._watchSurface(options.surfaceModel);
}
/**
* Specifying the actual size gives better results and more consistent behavior across browsers.
*
* Make sure the main canvas and the offscreen canvas or layer canvas are the same size.
*
* It is not recommended to set width and height to 100%.
*/
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
const { width, height } = this.viewport;
const actualWidth = Math.ceil(width * dpr);
const actualHeight = Math.ceil(height * dpr);
return {
filter({ width, height }: HTMLCanvasElement) {
return width !== actualWidth || height !== actualHeight;
},
update(canvas: HTMLCanvasElement) {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = actualWidth;
canvas.height = actualHeight;
},
};
}
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
const layer = this.layerManager;
const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => {
this._stackingCanvas = canvases;
const sizeUpdater = this._canvasSizeUpdater();
canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update);
};
const updateStackingCanvas = () => {
/**
* we already have a main canvas, so the last layer should be skipped
*/
const canvasLayers = layer.getCanvasLayers().slice(0, -1);
const canvases = [];
const currentCanvases = this._stackingCanvas;
const lastLayer = last(this.layerManager.layers);
const maximumZIndex = lastLayer
? lastLayer.zIndex + lastLayer.elements.length + 1
: 1;
this.canvas.style.zIndex = maximumZIndex.toString();
for (let i = 0; i < canvasLayers.length; ++i) {
const layer = canvasLayers[i];
const created = i < currentCanvases.length;
const canvas = created
? currentCanvases[i]
: document.createElement('canvas');
if (!created) {
onCreated?.(canvas);
}
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
canvas.style.zIndex = layer.zIndex.toString();
canvases.push(canvas);
}
this._stackingCanvas = canvases;
updateStackingCanvasSize(canvases);
if (currentCanvases.length !== canvases.length) {
const diff = canvases.length - currentCanvases.length;
const payload: {
canvases: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
added: HTMLCanvasElement[];
} = {
canvases,
removed: [],
added: [],
};
if (diff > 0) {
payload.added = canvases.slice(-diff);
} else {
payload.removed = currentCanvases.slice(diff);
}
this.stackingCanvasUpdated.emit(payload);
}
this.refresh();
};
this._disposables.add(
this.layerManager.slots.layerUpdated.on(() => {
updateStackingCanvas();
})
);
updateStackingCanvas();
}
private _initViewport() {
let sizeUpdatedRafId: number | null = null;
this._disposables.add(
this.viewport.viewportUpdated.on(() => {
this.refresh();
})
);
this._disposables.add(
this.viewport.sizeUpdated.on(() => {
if (sizeUpdatedRafId) return;
sizeUpdatedRafId = requestConnectedFrame(() => {
sizeUpdatedRafId = null;
this._resetSize();
this._render();
this.refresh();
}, this._container);
})
);
}
private _render() {
const { viewportBounds, zoom } = this.viewport;
const { ctx } = this;
const dpr = window.devicePixelRatio;
const scale = zoom * dpr;
const matrix = new DOMMatrix().scaleSelf(scale);
/**
* if a layer does not have a corresponding canvas
* its element will be add to this array and drawing on the
* main canvas
*/
let fallbackElement: SurfaceElementModel[] = [];
this.layerManager.getCanvasLayers().forEach((layer, idx) => {
if (!this._stackingCanvas[idx]) {
fallbackElement = fallbackElement.concat(layer.elements);
return;
}
const canvas = this._stackingCanvas[idx];
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const rc = new RoughCanvas(ctx.canvas);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.setTransform(matrix);
this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements);
});
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.setTransform(matrix);
this._renderByBound(
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
fallbackElement,
true
);
}
private _renderByBound(
ctx: CanvasRenderingContext2D | null,
matrix: DOMMatrix,
rc: RoughCanvas,
bound: IBound,
surfaceElements?: SurfaceElementModel[],
overLay: boolean = false
) {
if (!ctx) return;
const elements =
surfaceElements ??
(this.grid.search(bound, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[]);
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) {
const renderFn =
this.elementRenderers[
element.type as keyof typeof this.elementRenderers
];
if (!renderFn) {
console.warn(`Cannot find renderer for ${element.type}`);
continue;
}
ctx.save();
ctx.globalAlpha = element.opacity ?? 1;
const dx = element.x - bound.x;
const dy = element.y - bound.y;
renderFn(element, ctx, matrix.translate(dx, dy), this, rc, bound);
ctx.restore();
}
}
if (overLay) {
for (const overlay of this._overlays) {
ctx.save();
ctx.translate(-bound.x, -bound.y);
overlay.render(ctx, rc);
ctx.restore();
}
}
ctx.restore();
}
private _resetSize() {
const sizeUpdater = this._canvasSizeUpdater();
sizeUpdater.update(this.canvas);
this._stackingCanvas.forEach(sizeUpdater.update);
this.refresh();
}
private _watchSurface(surfaceModel: SurfaceBlockModel) {
const slots = [
'elementAdded',
'elementRemoved',
'localElementAdded',
'localElementDeleted',
'localElementUpdated',
] as const;
slots.forEach(slotName => {
this._disposables.add(surfaceModel[slotName].on(() => this.refresh()));
});
this._disposables.add(
surfaceModel.elementUpdated.on(payload => {
// ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return;
this.refresh();
})
);
}
addOverlay(overlay: Overlay) {
overlay.setRenderer(this);
this._overlays.add(overlay);
this.refresh();
}
/**
* Used to attach main canvas, main canvas will always exist
* @param container
*/
attach(container: HTMLElement) {
this._container = container;
container.append(this.canvas);
this._resetSize();
this.refresh();
}
dispose(): void {
this._overlays.forEach(overlay => overlay.dispose());
this._overlays.clear();
this._disposables.dispose();
}
generateColorProperty(color: Color, fallback?: Color) {
return (
this.provider.generateColorProperty?.(color, fallback) ?? 'transparent'
);
}
getCanvasByBound(
bound: IBound = this.viewport.viewportBounds,
surfaceElements?: SurfaceElementModel[],
canvas?: HTMLCanvasElement,
clearBeforeDrawing?: boolean,
withZoom?: boolean
): HTMLCanvasElement {
canvas = canvas || document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
canvas.style.width = `${bound.w}px`;
canvas.style.height = `${bound.h}px`;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const matrix = new DOMMatrix().scaleSelf(
withZoom ? dpr * this.viewport.zoom : dpr
);
const rc = new RoughCanvas(canvas);
if (clearBeforeDrawing) ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(matrix);
this._renderByBound(ctx, matrix, rc, bound, surfaceElements);
return canvas;
}
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;
}
overlay.setRenderer(null);
this._overlays.delete(overlay);
this.refresh();
}
}
@@ -0,0 +1,25 @@
import { type BrushElementModel, DefaultTheme } from '@blocksuite/affine-model';
import type { CanvasRenderer } from '../../canvas-renderer.js';
export function brush(
model: BrushElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer
) {
const { rotate } = model;
const [, , w, h] = model.deserializedXYWH;
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.black, true);
ctx.fillStyle = color;
ctx.fill(new Path2D(model.commands));
}
@@ -0,0 +1,303 @@
import {
type ConnectorElementModel,
ConnectorMode,
DefaultTheme,
type LocalConnectorElementModel,
type PointStyle,
} from '@blocksuite/affine-model';
import {
getBezierParameters,
type PointLocation,
} from '@blocksuite/global/gfx';
import { deltaInsertsToChunks } from '@blocksuite/inline';
import { isConnectorWithLabel } from '../../../managers/connector-manager.js';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../../canvas-renderer.js';
import {
getFontString,
getLineHeight,
getTextWidth,
isRTL,
type TextDelta,
wrapTextDeltas,
} from '../text/utils.js';
import {
DEFAULT_ARROW_SIZE,
getArrowOptions,
renderArrow,
renderCircle,
renderDiamond,
renderTriangle,
} from './utils.js';
export function connector(
model: ConnectorElementModel | LocalConnectorElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas
) {
const {
mode,
path: points,
strokeStyle,
frontEndpointStyle,
rearEndpointStyle,
strokeWidth,
} = model;
// points might not be build yet in some senarios
// eg. undo/redo, copy/paste
if (!points.length || points.length < 2) {
return;
}
ctx.setTransform(matrix);
const hasLabel = isConnectorWithLabel(model);
let dx = 0;
let dy = 0;
if (hasLabel) {
ctx.save();
const { deserializedXYWH, labelXYWH } = model as ConnectorElementModel;
const [x, y, w, h] = deserializedXYWH;
const [lx, ly, lw, lh] = labelXYWH!;
const offset = DEFAULT_ARROW_SIZE * strokeWidth;
dx = lx - x;
dy = ly - y;
const path = new Path2D();
path.rect(-offset / 2, -offset / 2, w + offset, h + offset);
path.rect(dx - 3 - 0.5, dy - 3 - 0.5, lw + 6 + 1, lh + 6 + 1);
ctx.clip(path, 'evenodd');
}
const strokeColor = renderer.getColorValue(
model.stroke,
DefaultTheme.connectorColor,
true
);
renderPoints(
model,
ctx,
rc,
points,
strokeStyle === 'dash',
mode === ConnectorMode.Curve,
strokeColor
);
renderEndpoint(
model,
points,
ctx,
rc,
'Front',
frontEndpointStyle,
strokeColor
);
renderEndpoint(
model,
points,
ctx,
rc,
'Rear',
rearEndpointStyle,
strokeColor
);
if (hasLabel) {
ctx.restore();
renderLabel(
model as ConnectorElementModel,
ctx,
matrix.translate(dx, dy),
renderer
);
}
}
function renderPoints(
model: ConnectorElementModel | LocalConnectorElementModel,
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
points: PointLocation[],
dash: boolean,
curve: boolean,
stroke: string
) {
const { seed, strokeWidth, roughness, rough } = model;
if (rough) {
const options = {
seed,
roughness,
stroke,
strokeLineDash: dash ? [12, 12] : undefined,
strokeWidth,
};
if (curve) {
const b = getBezierParameters(points);
rc.path(
`M${b[0][0]},${b[0][1]} C${b[1][0]},${b[1][1]} ${b[2][0]},${b[2][1]} ${b[3][0]},${b[3][1]}`,
options
);
} else {
rc.linearPath(points as unknown as [number, number][], options);
}
} else {
ctx.save();
ctx.strokeStyle = stroke;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
dash && ctx.setLineDash([12, 12]);
ctx.beginPath();
if (curve) {
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point[0], point[1]);
} else {
const last = points[index - 1];
ctx.bezierCurveTo(
last.absOut[0],
last.absOut[1],
point.absIn[0],
point.absIn[1],
point[0],
point[1]
);
}
});
} else {
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point[0], point[1]);
} else {
ctx.lineTo(point[0], point[1]);
}
});
}
ctx.stroke();
ctx.closePath();
ctx.restore();
}
}
function renderEndpoint(
model: ConnectorElementModel | LocalConnectorElementModel,
location: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
end: 'Front' | 'Rear',
style: PointStyle,
stroke: string
) {
const arrowOptions = getArrowOptions(end, model, stroke);
switch (style) {
case 'Arrow':
renderArrow(location, ctx, rc, arrowOptions);
break;
case 'Triangle':
renderTriangle(location, ctx, rc, arrowOptions);
break;
case 'Circle':
renderCircle(location, ctx, rc, arrowOptions);
break;
case 'Diamond':
renderDiamond(location, ctx, rc, arrowOptions);
break;
}
}
function renderLabel(
model: ConnectorElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer
) {
const {
text,
labelXYWH,
labelStyle: {
color,
fontSize,
fontWeight,
fontStyle,
fontFamily,
textAlign,
},
labelConstraints: { hasMaxWidth, maxWidth },
} = model;
const font = getFontString({
fontStyle,
fontWeight,
fontSize,
fontFamily,
});
const [, , w, h] = labelXYWH!;
const cx = w / 2;
const cy = h / 2;
const deltas = wrapTextDeltas(text!, font, w);
const lines = deltaInsertsToChunks(deltas);
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
const textHeight = (lines.length - 1) * lineHeight * 0.5;
ctx.setTransform(matrix);
ctx.font = font;
ctx.textAlign = textAlign;
ctx.textBaseline = 'middle';
ctx.fillStyle = renderer.getColorValue(color, DefaultTheme.black, true);
let textMaxWidth = textAlign === 'center' ? 0 : getMaxTextWidth(lines, font);
if (hasMaxWidth && maxWidth > 0) {
textMaxWidth = Math.min(textMaxWidth, textMaxWidth);
}
for (const [index, 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);
}
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
const x =
textMaxWidth *
(textAlign === 'center'
? 1
: textAlign === 'right'
? rtl
? -0.5
: 0.5
: rtl
? 0.5
: -0.5);
ctx.fillText(str, x + cx, index * lineHeight - textHeight + cy);
if (shouldTemporarilyAttach) {
ctx.canvas.remove();
}
}
}
}
function getMaxTextWidth(lines: TextDelta[][], font: string) {
return Math.max(
...lines.flatMap(line =>
line.map(delta => getTextWidth(delta.insert, font))
)
);
}
@@ -0,0 +1,313 @@
import {
type ConnectorElementModel,
ConnectorMode,
type LocalConnectorElementModel,
} from '@blocksuite/affine-model';
import type {
BezierCurveParameters,
IVec,
PointLocation,
} from '@blocksuite/global/gfx';
import {
getBezierParameters,
getBezierTangent,
Vec,
} from '@blocksuite/global/gfx';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
type ConnectorEnd = 'Front' | 'Rear';
export const DEFAULT_ARROW_SIZE = 15;
export function getArrowPoints(
points: PointLocation[],
size = 10,
mode: ConnectorMode,
bezierParameters: BezierCurveParameters,
endPoint: ConnectorEnd = 'Rear',
radians: number = Math.PI / 4
) {
const anchorPoint = getPointWithTangent(
points,
mode,
endPoint,
bezierParameters
);
const unit = Vec.mul(anchorPoint.tangent, -1);
const angle = endPoint === 'Front' ? Math.PI : 0;
return {
points: [
Vec.add(Vec.mul(Vec.rot(unit, angle + radians), size), anchorPoint),
anchorPoint,
Vec.add(Vec.mul(Vec.rot(unit, angle - radians), size), anchorPoint),
],
};
}
export function getCircleCenterPoint(
points: PointLocation[],
radius = 5,
mode: ConnectorMode,
bezierParameters: BezierCurveParameters,
endPoint: ConnectorEnd = 'Rear'
) {
const anchorPoint = getPointWithTangent(
points,
mode,
endPoint,
bezierParameters
);
const unit = Vec.mul(anchorPoint.tangent, -1);
const angle = endPoint === 'Front' ? Math.PI : 0;
return Vec.add(Vec.mul(Vec.rot(unit, angle), radius), anchorPoint);
}
export function getPointWithTangent(
points: PointLocation[],
mode: ConnectorMode,
endPoint: ConnectorEnd,
bezierParameters: BezierCurveParameters
) {
const anchorIndex = endPoint === 'Rear' ? points.length - 1 : 0;
const pointToAnchorIndex =
endPoint === 'Rear' ? anchorIndex - 1 : anchorIndex + 1;
const anchorPoint = points[anchorIndex];
const pointToAnchor = points[pointToAnchorIndex];
const clone = anchorPoint.clone();
let tangent;
if (mode !== ConnectorMode.Curve) {
tangent =
endPoint === 'Rear'
? Vec.tangent(anchorPoint, pointToAnchor)
: Vec.tangent(pointToAnchor, anchorPoint);
} else {
tangent =
endPoint === 'Rear'
? getBezierTangent(bezierParameters, 1)
: getBezierTangent(bezierParameters, 0);
}
clone.tangent = tangent ?? [0, 0];
return clone;
}
export function getDiamondPoints(
point: PointLocation,
size = 10,
endPoint: ConnectorEnd = 'Rear'
) {
const unit = Vec.mul(point.tangent, -1);
const angle = endPoint === 'Front' ? Math.PI : 0;
const diamondPoints = [
Vec.add(Vec.mul(Vec.rot(unit, angle + Math.PI * 0.25), size), point),
point,
Vec.add(Vec.mul(Vec.rot(unit, angle - Math.PI * 0.25), size), point),
Vec.add(Vec.mul(Vec.rot(unit, angle), size * Math.sqrt(2)), point),
];
return {
points: diamondPoints,
};
}
export type ArrowOptions = ReturnType<typeof getArrowOptions>;
export function getArrowOptions(
end: ConnectorEnd,
model: ConnectorElementModel | LocalConnectorElementModel,
strokeColor: string
) {
const { seed, mode, rough, roughness, strokeWidth, path } = model;
return {
end,
seed,
mode,
rough,
roughness,
strokeWidth,
strokeColor,
fillColor: strokeColor,
fillStyle: 'solid',
bezierParameters: getBezierParameters(path),
};
}
export function getRcOptions(options: ArrowOptions) {
const { seed, roughness, strokeWidth, strokeColor, fillColor } = options;
return {
seed,
roughness,
stroke: strokeColor,
strokeWidth,
fill: fillColor,
fillStyle: 'solid',
};
}
export function renderRoundedPolygon(
ctx: CanvasRenderingContext2D,
points: IVec[],
color: string,
strokeWidth: number,
fill: boolean = true
) {
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.save();
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.moveTo(points[i][0], points[i][1]);
} else {
ctx.lineTo(points[i][0], points[i][1]);
}
}
if (fill) {
ctx.closePath();
ctx.fill();
}
ctx.stroke();
ctx.restore();
}
export function renderArrow(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
options;
const radians = Math.PI / 4;
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
const { points: arrowPoints } = getArrowPoints(
points,
size,
mode,
bezierParameters,
end,
radians
);
if (rough) {
rc.linearPath(arrowPoints as [number, number][], getRcOptions(options));
} else {
renderRoundedPolygon(ctx, arrowPoints, strokeColor, strokeWidth, false);
}
}
export function renderTriangle(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } =
options;
const radians = Math.PI / 6;
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
const { points: trianglePoints } = getArrowPoints(
points,
size,
mode,
bezierParameters,
end,
radians
);
if (rough) {
rc.polygon(
[
[trianglePoints[0][0], trianglePoints[0][1]],
[trianglePoints[1][0], trianglePoints[1][1]],
[trianglePoints[2][0], trianglePoints[2][1]],
],
getRcOptions(options)
);
} else {
renderRoundedPolygon(ctx, trianglePoints, strokeColor, strokeWidth);
}
}
export function renderDiamond(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const { mode, end, rough, bezierParameters, strokeColor, strokeWidth } =
options;
const anchorPoint = getPointWithTangent(points, mode, end, bezierParameters);
const size = 10 * (strokeWidth / 2);
const { points: diamondPoints } = getDiamondPoints(anchorPoint, size, end);
if (rough) {
rc.polygon(
[
[diamondPoints[0][0], diamondPoints[0][1]],
[diamondPoints[1][0], diamondPoints[1][1]],
[diamondPoints[2][0], diamondPoints[2][1]],
[diamondPoints[3][0], diamondPoints[3][1]],
],
getRcOptions(options)
);
} else {
renderRoundedPolygon(ctx, diamondPoints, strokeColor, strokeWidth);
}
}
export function renderCircle(
points: PointLocation[],
ctx: CanvasRenderingContext2D,
rc: RoughCanvas,
options: ArrowOptions
) {
const {
bezierParameters,
mode,
end,
fillColor,
strokeColor,
strokeWidth,
rough,
} = options;
const radius = 5 * (strokeWidth / 2);
const centerPoint = getCircleCenterPoint(
points,
radius,
mode,
bezierParameters,
end
);
const cx = centerPoint[0];
const cy = centerPoint[1];
if (rough) {
// radius + 2 when render rough circle to avoid connector line cross the circle and make it looks bad
rc.circle(cx, cy, radius + 2, getRcOptions(options));
} else {
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.save();
ctx.beginPath();
ctx.ellipse(cx, cy, radius, radius, 0, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
@@ -0,0 +1,6 @@
import { FontFamily } from '@blocksuite/affine-model';
export const GROUP_TITLE_FONT = FontFamily.Inter;
export const GROUP_TITLE_FONT_SIZE = 12;
export const GROUP_TITLE_PADDING = [2, 0];
export const GROUP_TITLE_OFFSET = 4;
@@ -0,0 +1,58 @@
import type { GroupElementModel } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import type { CanvasRenderer } from '../../canvas-renderer.js';
import { titleRenderParams } from './utils.js';
export function group(
model: GroupElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer
) {
const { xywh } = model;
const bound = Bound.deserialize(xywh);
const elements = renderer.provider.selectedElements?.() || [];
const renderParams = titleRenderParams(model, renderer.viewport.zoom);
model.externalXYWH = renderParams.titleBound.serialize();
ctx.setTransform(matrix);
if (elements.includes(model.id)) {
if (model.showTitle) {
renderTitle(model, ctx, renderer, renderParams);
} else {
ctx.lineWidth = 2 / renderer.viewport.zoom;
ctx.strokeStyle = renderer.getPropertyValue('--affine-blue');
ctx.strokeRect(0, 0, bound.w, bound.h);
}
} else if (model.childElements.some(child => elements.includes(child.id))) {
ctx.lineWidth = 2 / renderer.viewport.zoom;
ctx.strokeStyle = '#8FD1FF';
ctx.strokeRect(0, 0, bound.w, bound.h);
if (model.showTitle) renderTitle(model, ctx, renderer, renderParams);
}
}
function renderTitle(
model: GroupElementModel,
ctx: CanvasRenderingContext2D,
renderer: CanvasRenderer,
renderParams: ReturnType<typeof titleRenderParams>
) {
const { text, lineHeight, font, padding, offset, titleBound } = renderParams;
model.externalXYWH = titleBound.serialize();
ctx.translate(0, -offset);
ctx.beginPath();
ctx.font = font;
ctx.fillStyle = renderer.getPropertyValue('--affine-blue');
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, padding[0], -lineHeight / 2 - padding[1]);
}
@@ -0,0 +1,77 @@
import type { GroupElementModel } from '@blocksuite/affine-model';
import { FontWeight } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import {
getFontString,
getLineHeight,
getLineWidth,
truncateTextByWidth,
} from '../text/utils.js';
import {
GROUP_TITLE_FONT,
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_OFFSET,
GROUP_TITLE_PADDING,
} from './consts.js';
export function titleRenderParams(group: GroupElementModel, zoom: number) {
let text = group.title.toString().trim();
const font = getGroupTitleFont(zoom);
const lineWidth = getLineWidth(text, font);
const lineHeight = getLineHeight(
GROUP_TITLE_FONT,
GROUP_TITLE_FONT_SIZE / zoom,
'normal'
);
const bound = group.elementBound;
const padding = [
GROUP_TITLE_PADDING[0] / zoom,
GROUP_TITLE_PADDING[1] / zoom,
];
const offset = GROUP_TITLE_OFFSET / zoom;
let titleWidth = lineWidth + padding[0] * 2;
const titleHeight = lineHeight + padding[1] * 2;
if (titleWidth > bound.w) {
text = truncateTextByWidth(text, font, bound.w - 10);
text = text.slice(0, text.length - 1) + '..';
titleWidth = bound.w;
}
return {
font,
bound,
text,
titleWidth,
titleHeight,
offset,
lineHeight,
padding,
titleBound: new Bound(
bound.x,
bound.y - titleHeight - offset,
titleWidth,
titleHeight
),
};
}
export function titleBound(group: GroupElementModel, zoom: number) {
const { titleWidth, titleHeight, bound } = titleRenderParams(group, zoom);
return new Bound(bound.x, bound.y - titleHeight, titleWidth, titleHeight);
}
function getGroupTitleFont(zoom: number) {
const fontSize = GROUP_TITLE_FONT_SIZE / zoom;
const font = getFontString({
fontSize,
fontFamily: GROUP_TITLE_FONT,
fontWeight: FontWeight.Regular,
fontStyle: 'normal',
});
return font;
}
@@ -0,0 +1,32 @@
import type { GfxPrimitiveElementModel } from '@blocksuite/block-std/gfx';
import type { IBound } from '@blocksuite/global/gfx';
import type { RoughCanvas } from '../../index.js';
import type { CanvasRenderer } from '../canvas-renderer.js';
import { brush } from './brush/index.js';
import { connector } from './connector/index.js';
import { group } from './group/index.js';
import { mindmap } from './mindmap.js';
import { shape } from './shape/index.js';
import { text } from './text/index.js';
export { normalizeShapeBound } from './shape/utils.js';
export type ElementRenderer<
T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel,
> = (
model: T,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
viewportBound: IBound
) => void;
export const elementRenderers = {
brush,
connector,
group,
shape,
text,
mindmap,
} as Record<string, ElementRenderer<any>>;
@@ -0,0 +1,66 @@
import type {
MindmapElementModel,
MindmapNode,
} from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/block-std/gfx';
import type { IBound } from '@blocksuite/global/gfx';
import { ConnectorPathGenerator } from '../../managers/connector-manager.js';
import type { RoughCanvas } from '../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../canvas-renderer.js';
import { connector as renderConnector } from './connector/index.js';
export function mindmap(
model: MindmapElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas,
bound: IBound
) {
const dx = model.x - bound.x;
const dy = model.y - bound.y;
matrix = matrix.translate(-dx, -dy);
const mindmapOpacity = model.opacity;
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.doc.getBlockById(id) as GfxModel);
if (outdated) {
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
}
const dx = connector.x - bound.x;
const dy = connector.y - bound.y;
const origin = ctx.globalAlpha;
const shouldSetGlobalAlpha =
origin !== connector.opacity * mindmapOpacity;
if (shouldSetGlobalAlpha) {
ctx.globalAlpha = connector.opacity * mindmapOpacity;
}
renderConnector(connector, ctx, matrix.translate(dx, dy), renderer, rc);
if (shouldSetGlobalAlpha) {
ctx.globalAlpha = origin;
}
});
if (node.detail.collapsed) {
return;
} else {
node.children.forEach(traverse);
}
};
model.tree && traverse(model.tree);
}
@@ -0,0 +1,64 @@
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../../canvas-renderer.js';
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,57 @@
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../../canvas-renderer.js';
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,172 @@
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/inline';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../../canvas-renderer.js';
import {
getFontMetrics,
getFontString,
getLineWidth,
isRTL,
measureTextInDOM,
wrapTextDeltas,
} from '../text/utils.js';
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 function shape(
model: ShapeElementModel | LocalShapeElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer,
rc: RoughCanvas
) {
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);
}
}
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,96 @@
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../../canvas-renderer.js';
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,63 @@
import type {
LocalShapeElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import type { RoughCanvas } from '../../../utils/rough/canvas.js';
import type { CanvasRenderer } from '../../canvas-renderer.js';
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,271 @@
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/inline';
import type { CanvasRenderer } from '../../canvas-renderer.js';
import {
getFontString,
getLineHeight,
getLineWidth,
getTextWidth,
measureTextInDOM,
type TextDelta,
wrapText,
wrapTextDeltas,
} from '../text/utils.js';
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;
}
}
@@ -0,0 +1,84 @@
import { DefaultTheme, type TextElementModel } from '@blocksuite/affine-model';
import { deltaInsertsToChunks } from '@blocksuite/inline';
import type { CanvasRenderer } from '../../canvas-renderer.js';
import {
getFontString,
getLineHeight,
getTextWidth,
isRTL,
wrapTextDeltas,
} from './utils.js';
export function text(
model: TextElementModel,
ctx: CanvasRenderingContext2D,
matrix: DOMMatrix,
renderer: CanvasRenderer
) {
const { fontSize, fontWeight, fontStyle, fontFamily, textAlign, rotate } =
model;
const [, , w, h] = model.deserializedXYWH;
const cx = w / 2;
const cy = h / 2;
ctx.setTransform(
matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy)
);
// const deltas: ITextDelta[] = yText.toDelta() as ITextDelta[];
const font = getFontString({
fontStyle,
fontWeight,
fontSize,
fontFamily,
});
const deltas = wrapTextDeltas(model.text, font, w);
const lines = deltaInsertsToChunks(deltas);
const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight);
const horizontalOffset =
textAlign === 'center' ? w / 2 : textAlign === 'right' ? w : 0;
const color = renderer.getColorValue(
model.color,
DefaultTheme.textColor,
true
);
ctx.font = font;
ctx.fillStyle = color;
ctx.textAlign = textAlign;
ctx.textBaseline = 'ideographic';
for (const [lineIndex, line] of lines.entries()) {
let beforeTextWidth = 0;
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);
}
ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr');
// 0.5 comes from v-line padding
const offset =
textAlign === 'center' ? 0 : textAlign === 'right' ? -0.5 : 0.5;
ctx.fillText(
str,
horizontalOffset + beforeTextWidth + offset,
(lineIndex + 1) * lineHeightPx
);
beforeTextWidth += getTextWidth(str, font);
if (shouldTemporarilyAttach) {
ctx.canvas.remove();
}
}
}
}
@@ -0,0 +1,557 @@
import type {
FontFamily,
FontStyle,
FontWeight,
TextElementModel,
} from '@blocksuite/affine-model';
import type { Bound } from '@blocksuite/global/gfx';
import {
getPointsFromBoundWithRotation,
rotatePoints,
} from '@blocksuite/global/gfx';
import { deltaInsertsToChunks } from '@blocksuite/inline';
import type * as Y from 'yjs';
import {
getFontFacesByFontFamily,
wrapFontFamily,
} from '../../../utils/font.js';
export type TextDelta = {
insert: string;
attributes?: Record<string, unknown>;
};
const getMeasureCtx = (function initMeasureContext() {
let ctx: CanvasRenderingContext2D | null = null;
let canvas: HTMLCanvasElement | null = null;
return () => {
if (!canvas) {
canvas = document.createElement('canvas');
ctx = canvas.getContext('2d')!;
}
return ctx!;
};
})();
const textMeasureCache = new Map<
string,
{
lineHeight: number;
lineGap: number;
fontSize: number;
}
>();
export function measureTextInDOM(
fontFamily: string,
fontSize: number,
fontWeight: string
) {
const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`;
if (textMeasureCache.has(cacheKey)) {
const {
fontSize: cacheFontSize,
lineGap,
lineHeight,
} = textMeasureCache.get(cacheKey)!;
return {
lineHeight: lineHeight * (fontSize / cacheFontSize),
lineGap: lineGap * (fontSize / cacheFontSize),
};
}
const div = document.createElement('div');
const span = document.createElement('span');
div.append(span);
span.innerText = 'x';
div.style.position = 'absolute';
div.style.top = '0px';
div.style.left = '0px';
div.style.visibility = 'hidden';
div.style.fontFamily = wrapFontFamily(fontFamily);
div.style.fontWeight = fontWeight;
div.style.fontSize = `${fontSize}px`;
div.style.pointerEvents = 'none';
document.body.append(div);
const lineHeight = span.getBoundingClientRect().height;
const height = div.getBoundingClientRect().height;
const result = {
lineHeight,
lineGap: height - lineHeight,
};
div.remove();
textMeasureCache.set(cacheKey, {
...result,
fontSize,
});
return result;
}
export function getFontString({
fontStyle,
fontWeight,
fontSize,
fontFamily,
}: {
fontStyle: string;
fontWeight: string;
fontSize: number;
fontFamily: string;
}): string {
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
return `${fontStyle} ${fontWeight} ${fontSize}px/${lineHeight}px ${wrapFontFamily(
fontFamily
)}, sans-serif`.trim();
}
export function getLineHeight(
fontFamily: string,
fontSize: number,
fontWeight: string
): number {
const { lineHeight } = measureTextInDOM(fontFamily, fontSize, fontWeight);
return lineHeight;
}
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
type TextMetricsLike = Writeable<TextMetrics>;
const metricsCache = new Map<
string,
{
fontSize: number;
metrics: TextMetrics;
}
>();
export function getFontMetrics(
fontFamily: string,
fontSize: number,
fontWeight: string
) {
const ctx = getMeasureCtx();
const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`;
if (metricsCache.has(cacheKey)) {
const { fontSize: cacheFontSize, metrics } = metricsCache.get(cacheKey)!;
return Object.keys(Object.getPrototypeOf(metrics)).reduce((acc, key) => {
acc[key as keyof TextMetrics] =
metrics[key as keyof TextMetrics] * (fontSize / cacheFontSize);
return acc;
}, {} as TextMetricsLike);
}
const font = `${fontWeight} ${fontSize}px ${wrapFontFamily(fontFamily)}`;
ctx.font = font;
const metrics = ctx.measureText('x');
// check if font does not fallback
if (ctx.font === font) {
metricsCache.set(cacheKey, {
fontSize,
metrics,
});
}
return metrics;
}
const RS_LTR_CHARS =
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF' +
'\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF';
const RS_RTL_CHARS = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
// eslint-disable-next-line no-misleading-character-class
const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
export function isRTL(text: string) {
return RE_RTL_CHECK.test(text);
}
export function splitIntoLines(text: string): string[] {
return normalizeText(text).split('\n');
}
export function getLineWidth(text: string, font: string): number {
const ctx = getMeasureCtx();
if (font !== ctx.font) ctx.font = font;
const width = ctx.measureText(text).width;
return width;
}
export function getTextWidth(text: string, font: string): number {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach(line => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
}
export function wrapTextDeltas(text: Y.Text, font: string, w: number) {
if (!text) return [];
const deltas: TextDelta[] = (text.toDelta() as TextDelta[]).flatMap(
delta => ({
insert: wrapText(delta.insert, font, w),
attributes: delta.attributes,
})
) as TextDelta[];
return deltas;
}
export const truncateTextByWidth = (
text: string,
font: string,
width: number
) => {
let totalWidth = 0;
let i = 0;
for (; i < text.length; i++) {
const char = text[i];
totalWidth += charWidth.calculate(char, font);
if (totalWidth > width) {
break;
}
}
return text.slice(0, i);
};
export function getTextCursorPosition(
model: TextElementModel,
coord: { x: number; y: number }
) {
const leftTop = getPointsFromBoundWithRotation(model)[0];
const mousePos = rotatePoints(
[[coord.x, coord.y]],
leftTop,
-model.rotate
)[0];
return [
Math.floor(
(mousePos[1] - leftTop[1]) /
getLineHeight(model.fontFamily, model.fontSize, model.fontWeight)
),
mousePos[0] - leftTop[0],
];
}
export function getCursorByCoord(
model: TextElementModel,
coord: { x: number; y: number }
) {
const [lineIndex, offsetX] = getTextCursorPosition(model, coord);
const font = getFontString(model);
const deltas = wrapTextDeltas(model.text, font, model.w);
const lines = deltaInsertsToChunks(deltas).map(line =>
line.map(iTextDelta => iTextDelta.insert).join('')
);
if (lineIndex < 0 || lineIndex >= lines.length) {
return model.text.length;
}
const string = lines[lineIndex];
let index = lines.slice(0, lineIndex).join('').length - 1;
let currentStringWidth = 0;
let charIndex = 0;
while (currentStringWidth < offsetX) {
index += 1;
if (charIndex === string.length) {
break;
}
currentStringWidth += charWidth.calculate(string[charIndex], font);
charIndex += 1;
}
return index;
}
export function normalizeTextBound(
{
yText,
fontStyle,
fontWeight,
fontSize,
fontFamily,
hasMaxWidth,
maxWidth,
}: {
yText: Y.Text;
fontStyle: FontStyle;
fontWeight: FontWeight;
fontSize: number;
fontFamily: FontFamily;
hasMaxWidth?: boolean;
maxWidth?: number;
},
bound: Bound,
dragging: boolean = false
): Bound {
if (!yText) return bound;
const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight);
const font = getFontString({
fontStyle,
fontWeight,
fontSize,
fontFamily,
});
let lines: TextDelta[][] = [];
const deltas: TextDelta[] = yText.toDelta() as TextDelta[];
const text = yText.toString();
const widestCharWidth =
[...text]
.map(char => getTextWidth(char, font))
.sort((a, b) => a - b)
.pop() ?? getTextWidth('W', font);
if (bound.w < widestCharWidth) {
bound.w = widestCharWidth;
}
const width = bound.w;
const insertDeltas = deltas.flatMap(delta => ({
insert: wrapText(delta.insert, font, width),
attributes: delta.attributes,
})) as TextDelta[];
lines = deltaInsertsToChunks(insertDeltas);
if (!dragging) {
lines = deltaInsertsToChunks(deltas);
const widestLineWidth = Math.max(
...text.split('\n').map(line => getTextWidth(line, font))
);
bound.w = widestLineWidth;
if (hasMaxWidth && maxWidth && maxWidth > 0) {
bound.w = Math.min(bound.w, maxWidth);
}
}
bound.h = lineHeightPx * lines.length;
return bound;
}
export function isFontWeightSupported(
fontFamily: FontFamily | string,
weight: FontWeight
) {
const fontFaces = getFontFacesByFontFamily(fontFamily);
const fontFace = fontFaces.find(fontFace => fontFace.weight === weight);
return !!fontFace;
}
export function isFontStyleSupported(
fontFamily: FontFamily | string,
style: FontStyle
) {
const fontFaces = getFontFacesByFontFamily(fontFamily);
const fontFace = fontFaces.find(fontFace => fontFace.style === style);
return !!fontFace;
}
export function normalizeText(text: string): string {
return (
text
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, ' ')
// normalize newlines
.replace(/\r?\n|\r/g, '\n')
);
}
export const getTextHeight = (text: string, lineHeight: number) => {
const lineCount = splitIntoLines(text).length;
return lineHeight * lineCount;
};
export function parseTokens(text: string): string[] {
// Splitting words containing "-" as those are treated as separate words
// by css wrapping algorithm eg non-profit => non-, profit
const words = text.split('-');
if (words.length > 1) {
// non-proft org => ['non-', 'profit org']
words.forEach((word, index) => {
if (index !== words.length - 1) {
words[index] = word += '-';
}
});
}
// Joining the words with space and splitting them again with space to get the
// final list of tokens
// ['non-', 'profit org'] =>,'non- profit org' => ['non-','profit','org']
return words.join(' ').split(' ');
}
export const charWidth = (() => {
const cachedCharWidth: Record<string, Array<number>> = {};
const calculate = (char: string, font: string) => {
const ascii = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][ascii]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][ascii] = width;
}
return cachedCharWidth[font][ascii];
};
const getCache = (font: string) => {
return cachedCharWidth[font];
};
return {
calculate,
getCache,
};
})();
export function wrapText(text: string, font: string, maxWidth: number): string {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = [];
const originalLines = text.split('\n');
const spaceWidth = getLineWidth(' ', font);
let currentLine = '';
let currentLineWidthTillNow = 0;
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const resetParams = () => {
currentLine = '';
currentLineWidthTillNow = 0;
};
originalLines.forEach(originalLine => {
const currentLineWidth = getTextWidth(originalLine, font);
// Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
return; // continue
}
const words = parseTokens(originalLine);
resetParams();
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font);
// This will only happen when single word takes entire width
if (currentWordWidth === maxWidth) {
push(words[index]);
index++;
}
// Start breaking longer words exceeding max width
else if (currentWordWidth > maxWidth) {
// push current line since the current word exceeds the max width
// so will be appended in next line
push(currentLine);
resetParams();
while (words[index].length > 0) {
const currentChar = String.fromCodePoint(
words[index].codePointAt(0)!
);
const width = charWidth.calculate(currentChar, font);
currentLineWidthTillNow += width;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = width;
} else {
currentLine += currentChar;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
push(currentLine);
resetParams();
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line unless the line ends with hyphen to sync
// with css word-wrap
} else if (!currentLine.endsWith('-')) {
currentLine += ' ';
currentLineWidthTillNow += spaceWidth;
}
index++;
} else {
// Start appending words in a line till max width reached
while (currentLineWidthTillNow < maxWidth && index < words.length) {
const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
resetParams();
break;
}
index++;
// if word ends with "-" then we don't need to add space
// to sync with css word-wrap
const shouldAppendSpace = !word.endsWith('-');
currentLine += word;
if (shouldAppendSpace) {
currentLine += ' ';
}
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
lines.push(currentLine);
}
resetParams();
break;
}
}
}
}
if (currentLine.slice(-1) === ' ') {
// only remove last trailing space which we have added when joining words
currentLine = currentLine.slice(0, -1);
push(currentLine);
}
});
return lines.join('\n');
}
@@ -0,0 +1,6 @@
import type {
ShapeElementModel,
TextElementModel,
} from '@blocksuite/affine-model';
export type CanvasElementWithText = ShapeElementModel | TextElementModel;
@@ -0,0 +1,55 @@
import {
type GfxController,
GfxControllerIdentifier,
} from '@blocksuite/block-std/gfx';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Extension } from '@blocksuite/store';
import type { RoughCanvas } from '../utils/rough/canvas.js';
import type { CanvasRenderer } from './canvas-renderer.js';
/**
* An overlay is a layer covered on top of elements,
* can be used for rendering non-CRDT state indicators.
*/
export abstract class Overlay extends Extension {
static overlayName: string = '';
protected _renderer: CanvasRenderer | null = null;
constructor(protected gfx: GfxController) {
super();
}
static override setup(di: Container): void {
if (!this.overlayName) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
`The overlay constructor '${this.name}' should have a static 'overlayName' property.`
);
}
di.addImpl(OverlayIdentifier(this.overlayName), this, [
GfxControllerIdentifier,
]);
}
clear() {}
dispose() {}
refresh() {
if (this._renderer) {
this._renderer.refresh();
}
}
abstract render(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void;
setRenderer(renderer: CanvasRenderer | null) {
this._renderer = renderer;
}
}
export const OverlayIdentifier = createIdentifier<Overlay>('Overlay');