refactor(editor): separate the element renders (#11461)
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
|
||||
308
blocksuite/affine/gfx/connector/src/element-renderer/index.ts
Normal file
308
blocksuite/affine/gfx/connector/src/element-renderer/index.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
type CanvasRenderer,
|
||||
ConnectorUtils,
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
type RoughCanvas,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getTextWidth,
|
||||
isRTL,
|
||||
type TextDelta,
|
||||
wrapTextDeltas,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
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/std/inline';
|
||||
|
||||
import {
|
||||
DEFAULT_ARROW_SIZE,
|
||||
getArrowOptions,
|
||||
renderArrow,
|
||||
renderCircle,
|
||||
renderDiamond,
|
||||
renderTriangle,
|
||||
} from './utils.js';
|
||||
|
||||
export const connector: ElementRenderer<
|
||||
ConnectorElementModel | LocalConnectorElementModel
|
||||
> = (model, ctx, matrix, renderer, rc) => {
|
||||
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 = ConnectorUtils.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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectorElementRendererExtension = ElementRendererExtension(
|
||||
'connector',
|
||||
connector
|
||||
);
|
||||
|
||||
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))
|
||||
)
|
||||
);
|
||||
}
|
||||
312
blocksuite/affine/gfx/connector/src/element-renderer/utils.ts
Normal file
312
blocksuite/affine/gfx/connector/src/element-renderer/utils.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import type { RoughCanvas } from '@blocksuite/affine-block-surface';
|
||||
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';
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './connector-tool';
|
||||
export * from './element-renderer';
|
||||
export * from './element-transform';
|
||||
export * from './text';
|
||||
export * from './toolbar/config';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { getLineHeight } from '@blocksuite/affine-gfx-text';
|
||||
import type { ConnectorElementModel } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
@@ -319,11 +317,7 @@ export class EdgelessConnectorLabelEditor extends WithDisposable(
|
||||
labelConstraints: { hasMaxWidth, maxWidth },
|
||||
} = connector;
|
||||
|
||||
const lineHeight = TextUtils.getLineHeight(
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight
|
||||
);
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const { translateX, translateY, zoom } = this.gfx.viewport;
|
||||
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
|
||||
const transformOperation = [
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
ConnectorUtils,
|
||||
EdgelessCRUDIdentifier,
|
||||
TextUtils,
|
||||
} 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 {
|
||||
createTextActions,
|
||||
normalizeTextBound,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
ConnectorElementModel,
|
||||
type ConnectorElementProps,
|
||||
@@ -33,7 +36,6 @@ import {
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { MenuItem } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import {
|
||||
createTextActions,
|
||||
getRootBlock,
|
||||
LINE_STYLE_LIST,
|
||||
renderMenu,
|
||||
@@ -368,7 +370,7 @@ export const connectorToolbarConfig = {
|
||||
} = model;
|
||||
const prevBounds = Bound.fromXYWH(labelXYWH || [0, 0, 16, 16]);
|
||||
const center = prevBounds.center;
|
||||
const bounds = TextUtils.normalizeTextBound(
|
||||
const bounds = normalizeTextBound(
|
||||
{
|
||||
yText: text!,
|
||||
fontFamily,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"references": [
|
||||
{ "path": "../../blocks/block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../text" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
|
||||
@@ -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;
|
||||
67
blocksuite/affine/gfx/group/src/element-renderer/index.ts
Normal file
67
blocksuite/affine/gfx/group/src/element-renderer/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type CanvasRenderer,
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import { titleRenderParams } from './utils.js';
|
||||
|
||||
export const group: ElementRenderer<GroupElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const GroupElementRendererExtension = ElementRendererExtension(
|
||||
'group',
|
||||
group
|
||||
);
|
||||
|
||||
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]);
|
||||
}
|
||||
77
blocksuite/affine/gfx/group/src/element-renderer/utils.ts
Normal file
77
blocksuite/affine/gfx/group/src/element-renderer/utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getLineWidth,
|
||||
truncateTextByWidth,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { FontWeight } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './command';
|
||||
export * from './element-renderer';
|
||||
export * from './text/text';
|
||||
export * from './toolbar/config';
|
||||
export * from './view';
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
@@ -21,6 +16,12 @@ import { html, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from '../element-renderer/consts';
|
||||
|
||||
export function mountGroupTitleEditor(
|
||||
group: GroupElementModel,
|
||||
edgeless: BlockComponent
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"references": [
|
||||
{ "path": "../../blocks/block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../text" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"@blocksuite/affine-block-image": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-gfx-connector": "workspace:*",
|
||||
"@blocksuite/affine-gfx-shape": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
|
||||
79
blocksuite/affine/gfx/mindmap/src/element-renderer.ts
Normal file
79
blocksuite/affine/gfx/mindmap/src/element-renderer.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ConnectorPathGenerator,
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { connector as renderConnector } from '@blocksuite/affine-gfx-connector';
|
||||
import type {
|
||||
MindmapElementModel,
|
||||
MindmapNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
export const mindmap: ElementRenderer<MindmapElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer,
|
||||
rc,
|
||||
bound
|
||||
) => {
|
||||
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.getModelById(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,
|
||||
// NOTE: should we add this?
|
||||
bound
|
||||
);
|
||||
|
||||
if (shouldSetGlobalAlpha) {
|
||||
ctx.globalAlpha = origin;
|
||||
}
|
||||
});
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
return;
|
||||
} else {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
model.tree && traverse(model.tree);
|
||||
};
|
||||
|
||||
export const MindmapElementRendererExtension = ElementRendererExtension(
|
||||
'mindmap',
|
||||
mindmap
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './element-renderer';
|
||||
export * from './element-transform';
|
||||
export * from './indicator-overlay';
|
||||
export * from './toolbar/config';
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { addAttachments } from '@blocksuite/affine-block-attachment';
|
||||
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
|
||||
import { addImages } from '@blocksuite/affine-block-image';
|
||||
import {
|
||||
CanvasElementType,
|
||||
EdgelessCRUDIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { mountTextElementEditor } from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
insertEdgelessTextCommand,
|
||||
mountTextElementEditor,
|
||||
} from '@blocksuite/affine-gfx-text';
|
||||
import {
|
||||
MAX_IMAGE_WIDTH,
|
||||
type MindmapStyle,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { hasGrouped } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
LayoutType,
|
||||
MindmapElementModel,
|
||||
MindmapStyle,
|
||||
ShapeElementModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarContext,
|
||||
@@ -130,7 +132,68 @@ export const mindmapToolbarConfig = {
|
||||
when: ctx => ctx.getSurfaceModelsByType(MindmapElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const shapeMindmapToolbarConfig = {
|
||||
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);
|
||||
},
|
||||
},
|
||||
],
|
||||
when: ctx => ctx.getSurfaceModelsByType(ShapeElementModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const mindmapToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('affine:surface:mindmap'),
|
||||
config: mindmapToolbarConfig,
|
||||
});
|
||||
|
||||
export const shapeMindmapToolbarExtension = ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:surface:shape'),
|
||||
config: shapeMindmapToolbarConfig,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fitContent } from '@blocksuite/affine-block-surface';
|
||||
import { fitContent } from '@blocksuite/affine-gfx-shape';
|
||||
import {
|
||||
applyNodeStyle,
|
||||
LayoutType,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
{ "path": "../../blocks/block-image" },
|
||||
{ "path": "../../blocks/block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../connector" },
|
||||
{ "path": "../shape" },
|
||||
{ "path": "../text" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-gfx-mindmap": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
183
blocksuite/affine/gfx/shape/src/element-renderer/shape/index.ts
Normal file
183
blocksuite/affine/gfx/shape/src/element-renderer/shape/index.ts
Normal file
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
270
blocksuite/affine/gfx/shape/src/element-renderer/shape/utils.ts
Normal file
270
blocksuite/affine/gfx/shape/src/element-renderer/shape/utils.ts
Normal file
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"references": [
|
||||
{ "path": "../../blocks/block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../mindmap" },
|
||||
{ "path": "../text" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-edgeless-text": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
|
||||
1
blocksuite/affine/gfx/text/src/commands/index.ts
Normal file
1
blocksuite/affine/gfx/text/src/commands/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { insertEdgelessTextCommand } from './insert-edgeless-text.js';
|
||||
114
blocksuite/affine/gfx/text/src/commands/insert-edgeless-text.ts
Normal file
114
blocksuite/affine/gfx/text/src/commands/insert-edgeless-text.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
getSurfaceBlock,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
EDGELESS_TEXT_BLOCK_MIN_HEIGHT,
|
||||
EDGELESS_TEXT_BLOCK_MIN_WIDTH,
|
||||
EdgelessTextBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
export const insertEdgelessTextCommand: Command<
|
||||
{
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
{
|
||||
textId: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { std, x, y } = ctx;
|
||||
const host = std.host;
|
||||
const doc = host.doc;
|
||||
const surface = getSurfaceBlock(doc);
|
||||
if (!surface) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const zoom = gfx.viewport.zoom;
|
||||
const selection = gfx.selection;
|
||||
|
||||
const textId = std.get(EdgelessCRUDIdentifier).addBlock(
|
||||
'affine:edgeless-text',
|
||||
{
|
||||
xywh: new Bound(
|
||||
x - (EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom) / 2,
|
||||
y - (EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom) / 2,
|
||||
EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom,
|
||||
EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom
|
||||
).serialize(),
|
||||
},
|
||||
surface.id
|
||||
);
|
||||
|
||||
const blockId = doc.addBlock('affine:paragraph', { type: 'text' }, textId);
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
selection.set({
|
||||
elements: [textId],
|
||||
editing: true,
|
||||
});
|
||||
const disposable = selection.slots.updated.subscribe(() => {
|
||||
const editing = selection.editing;
|
||||
const id = selection.selectedIds[0];
|
||||
if (!editing || id !== textId) {
|
||||
const textBlock = host.view.getBlock(textId);
|
||||
const model = textBlock?.model;
|
||||
if (matchModels(model, [EdgelessTextBlockModel])) {
|
||||
model.props.hasMaxWidth = true;
|
||||
}
|
||||
|
||||
disposable.unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
focusTextModel(std, blockId);
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
const edgelessText = host.view.getBlock(textId);
|
||||
const paragraph = host.view.getBlock(blockId);
|
||||
if (!edgelessText || !paragraph) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
edgelessText.addEventListener(
|
||||
'focusout',
|
||||
e => {
|
||||
if (edgelessText.model.children.length > 1) return;
|
||||
if (
|
||||
!paragraph.model.text ||
|
||||
(paragraph.model.text.length === 0 && e.relatedTarget !== null)
|
||||
) {
|
||||
doc.deleteBlock(edgelessText.model);
|
||||
}
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
const subscription = paragraph.model.deleted.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
abortController.abort();
|
||||
});
|
||||
edgelessText.addEventListener(
|
||||
'beforeinput',
|
||||
() => {
|
||||
abortController.abort();
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
next({ textId });
|
||||
};
|
||||
@@ -26,6 +26,8 @@ import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { getCursorByCoord, getLineHeight } from './element-renderer/utils';
|
||||
|
||||
export function mountTextElementEditor(
|
||||
textElement: TextElementModel,
|
||||
edgeless: BlockComponent,
|
||||
@@ -34,7 +36,7 @@ export function mountTextElementEditor(
|
||||
let cursorIndex = textElement.text.length;
|
||||
if (focusCoord) {
|
||||
cursorIndex = Math.min(
|
||||
TextUtils.getCursorByCoord(textElement, focusCoord),
|
||||
getCursorByCoord(textElement, focusCoord),
|
||||
cursorIndex
|
||||
);
|
||||
}
|
||||
@@ -413,11 +415,7 @@ export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
|
||||
hasMaxWidth,
|
||||
w,
|
||||
} = this.element;
|
||||
const lineHeight = TextUtils.getLineHeight(
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight
|
||||
);
|
||||
const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight);
|
||||
const rect = getSelectedRect([this.element]);
|
||||
|
||||
const { translateX, translateY, zoom } = this.gfx.viewport;
|
||||
|
||||
94
blocksuite/affine/gfx/text/src/element-renderer/index.ts
Normal file
94
blocksuite/affine/gfx/text/src/element-renderer/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
type ElementRenderer,
|
||||
ElementRendererExtension,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { DefaultTheme, type TextElementModel } from '@blocksuite/affine-model';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
|
||||
|
||||
import {
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
getTextWidth,
|
||||
isRTL,
|
||||
wrapTextDeltas,
|
||||
} from './utils.js';
|
||||
|
||||
export const text: ElementRenderer<TextElementModel> = (
|
||||
model,
|
||||
ctx,
|
||||
matrix,
|
||||
renderer
|
||||
) => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const TextElementRendererExtension = ElementRendererExtension(
|
||||
'text',
|
||||
text
|
||||
);
|
||||
|
||||
export * from './utils';
|
||||
555
blocksuite/affine/gfx/text/src/element-renderer/utils.ts
Normal file
555
blocksuite/affine/gfx/text/src/element-renderer/utils.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { TextUtils } from '@blocksuite/affine-block-surface';
|
||||
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/std/inline';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
const { getFontFacesByFontFamily, wrapFontFamily } = TextUtils;
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './commands';
|
||||
export * from './edgeless-text-editor';
|
||||
export * from './element-renderer';
|
||||
export * from './tool';
|
||||
export * from './toolbar';
|
||||
export * from './view';
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
CanvasElementType,
|
||||
EdgelessCRUDIdentifier,
|
||||
type IModelCoord,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { TextElementModel } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
@@ -11,6 +10,7 @@ import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { EdgelessTextEditor } from './edgeless-text-editor';
|
||||
import { getCursorByCoord } from './element-renderer/utils';
|
||||
|
||||
export function mountTextElementEditor(
|
||||
textElement: TextElementModel,
|
||||
@@ -20,7 +20,7 @@ export function mountTextElementEditor(
|
||||
let cursorIndex = textElement.text.length;
|
||||
if (focusCoord) {
|
||||
cursorIndex = Math.min(
|
||||
TextUtils.getCursorByCoord(textElement, focusCoord),
|
||||
getCursorByCoord(textElement, focusCoord),
|
||||
cursorIndex
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
|
||||
import type { TextElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
@@ -9,6 +8,7 @@ import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, type GfxController } from '@blocksuite/std/gfx';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { insertEdgelessTextCommand } from './commands/insert-edgeless-text';
|
||||
import { mountTextElementEditor } from './mount-text-editor';
|
||||
|
||||
function addText(gfx: GfxController, event: PointerEventState) {
|
||||
|
||||
382
blocksuite/affine/gfx/text/src/toolbar/actions.ts
Normal file
382
blocksuite/affine/gfx/text/src/toolbar/actions.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
packColor,
|
||||
type PickColorEvent,
|
||||
} from '@blocksuite/affine-components/color-picker';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
DefaultTheme,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
resolveColor,
|
||||
type SurfaceTextModelMap,
|
||||
TextAlign,
|
||||
type TextStyleProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type {
|
||||
ToolbarActions,
|
||||
ToolbarContext,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getMostCommonResolvedValue,
|
||||
getMostCommonValue,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type MenuItem,
|
||||
renderCurrentMenuItemWith,
|
||||
renderMenu,
|
||||
} from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import {
|
||||
isFontStyleSupported,
|
||||
isFontWeightSupported,
|
||||
} from '../element-renderer/utils';
|
||||
|
||||
const FONT_WEIGHT_LIST = [
|
||||
{
|
||||
key: 'Light',
|
||||
value: FontWeight.Light,
|
||||
},
|
||||
{
|
||||
key: 'Regular',
|
||||
value: FontWeight.Regular,
|
||||
},
|
||||
{
|
||||
key: 'Semibold',
|
||||
value: FontWeight.SemiBold,
|
||||
},
|
||||
] as const satisfies MenuItem<FontWeight>[];
|
||||
|
||||
const FONT_STYLE_LIST = [
|
||||
{
|
||||
value: FontStyle.Normal,
|
||||
},
|
||||
{
|
||||
key: 'Italic',
|
||||
value: FontStyle.Italic,
|
||||
},
|
||||
] as const satisfies MenuItem<FontStyle>[];
|
||||
|
||||
const FONT_SIZE_LIST = [
|
||||
{ value: 16 },
|
||||
{ value: 24 },
|
||||
{ value: 32 },
|
||||
{ value: 40 },
|
||||
{ value: 64 },
|
||||
{ value: 128 },
|
||||
] as const satisfies MenuItem<number>[];
|
||||
|
||||
const TEXT_ALIGN_LIST = [
|
||||
{
|
||||
key: 'Left',
|
||||
value: TextAlign.Left,
|
||||
icon: TextAlignLeftIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Center',
|
||||
value: TextAlign.Center,
|
||||
icon: TextAlignCenterIcon(),
|
||||
},
|
||||
{
|
||||
key: 'Right',
|
||||
value: TextAlign.Right,
|
||||
icon: TextAlignRightIcon(),
|
||||
},
|
||||
] as const satisfies MenuItem<TextAlign>[];
|
||||
|
||||
export function createTextActions<
|
||||
K extends abstract new (...args: any) => any,
|
||||
T extends keyof SurfaceTextModelMap,
|
||||
>(
|
||||
klass: K,
|
||||
type: T,
|
||||
update: (
|
||||
ctx: ToolbarContext,
|
||||
model: InstanceType<K>,
|
||||
props: Partial<TextStyleProps>
|
||||
) => void = (ctx, model, props) =>
|
||||
ctx.std.get(EdgelessCRUDIdentifier).updateElement(model.id, props),
|
||||
mapInto: (model: InstanceType<K>) => TextStyleProps = model => model,
|
||||
stash: <P extends keyof TextStyleProps>(
|
||||
model: InstanceType<K>,
|
||||
type: 'stash' | 'pop',
|
||||
field: P
|
||||
) => void = (model, type, field) => model[type](field)
|
||||
) {
|
||||
return [
|
||||
{
|
||||
id: 'a.font',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const fontFamily =
|
||||
getMostCommonValue(models.map(mapInto), 'fontFamily') ??
|
||||
FontFamily.Inter;
|
||||
const styleInfo = { fontFamily: TextUtils.wrapFontFamily(fontFamily) };
|
||||
|
||||
const onPick = (fontFamily: FontFamily) => {
|
||||
let fontWeight =
|
||||
getMostCommonValue(models.map(mapInto), 'fontWeight') ??
|
||||
FontWeight.Regular;
|
||||
let fontStyle =
|
||||
getMostCommonValue(models.map(mapInto), 'fontStyle') ??
|
||||
FontStyle.Normal;
|
||||
|
||||
if (!isFontWeightSupported(fontFamily, fontWeight)) {
|
||||
fontWeight = FontWeight.Regular;
|
||||
}
|
||||
if (!isFontStyleSupported(fontFamily, fontStyle)) {
|
||||
fontStyle = FontStyle.Normal;
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
update(ctx, model, { fontFamily, fontWeight, fontStyle });
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font"
|
||||
.tooltip="${'Font'}"
|
||||
.justify="${'space-between'}"
|
||||
.iconContainerWidth="${'40px'}"
|
||||
>
|
||||
<span class="label padding0" style=${styleMap(styleInfo)}
|
||||
>Aa</span
|
||||
>
|
||||
${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-font-family-panel
|
||||
.value=${fontFamily}
|
||||
.onSelect=${onPick}
|
||||
></edgeless-font-family-panel>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.text-color',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
|
||||
const theme = ctx.theme.edgeless$.value;
|
||||
|
||||
const palettes =
|
||||
type === 'shape'
|
||||
? DefaultTheme.ShapeTextColorPalettes
|
||||
: DefaultTheme.Palettes;
|
||||
const defaultColor =
|
||||
type === 'shape'
|
||||
? DefaultTheme.shapeTextColor
|
||||
: DefaultTheme.textColor;
|
||||
|
||||
const field = 'color';
|
||||
const firstModel = models[0];
|
||||
const originalColor = mapInto(firstModel)[field];
|
||||
const color =
|
||||
getMostCommonResolvedValue(models, field, color =>
|
||||
resolveColor(color, theme)
|
||||
) ?? resolveColor(defaultColor, theme);
|
||||
|
||||
const onPick = (e: PickColorEvent) => {
|
||||
if (e.type === 'pick') {
|
||||
const color = e.detail.value;
|
||||
for (const model of models) {
|
||||
const props = packColor(field, color);
|
||||
update(ctx, model, props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
stash(model, e.type === 'start' ? 'stash' : 'pop', field);
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<edgeless-color-picker-button
|
||||
class="text-color"
|
||||
.label="${'Text color'}"
|
||||
.pick=${onPick}
|
||||
.color=${color}
|
||||
.theme=${theme}
|
||||
.isText=${true}
|
||||
.hollowCircle=${true}
|
||||
.originalColor=${originalColor}
|
||||
.palettes=${palettes}
|
||||
.enableCustomColor=${enableCustomColor}
|
||||
>
|
||||
</edgeless-color-picker-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.font-style',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const fontFamily =
|
||||
getMostCommonValue(models.map(mapInto), 'fontFamily') ??
|
||||
FontFamily.Inter;
|
||||
const fontWeight =
|
||||
getMostCommonValue(models.map(mapInto), 'fontWeight') ??
|
||||
FontWeight.Regular;
|
||||
const fontStyle =
|
||||
getMostCommonValue(models.map(mapInto), 'fontStyle') ??
|
||||
FontStyle.Normal;
|
||||
const matchFontFaces = TextUtils.getFontFacesByFontFamily(fontFamily);
|
||||
const disabled =
|
||||
matchFontFaces.length === 1 &&
|
||||
matchFontFaces[0].style === fontStyle &&
|
||||
matchFontFaces[0].weight === fontWeight;
|
||||
|
||||
const onPick = (fontWeight: FontWeight, fontStyle: FontStyle) => {
|
||||
for (const model of models) {
|
||||
update(ctx, model, { fontWeight, fontStyle });
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Font style"
|
||||
.tooltip="${'Font style'}"
|
||||
.justify="${'space-between'}"
|
||||
.iconContainerWidth="${'90px'}"
|
||||
.disabled=${disabled}
|
||||
>
|
||||
<span class="label ellipsis">
|
||||
${renderCurrentMenuItemWith(
|
||||
FONT_WEIGHT_LIST,
|
||||
fontWeight,
|
||||
'key'
|
||||
)}
|
||||
${renderCurrentMenuItemWith(
|
||||
FONT_STYLE_LIST,
|
||||
fontStyle,
|
||||
'key'
|
||||
)}
|
||||
</span>
|
||||
${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<edgeless-font-weight-and-style-panel
|
||||
.fontFamily=${fontFamily}
|
||||
.fontWeight=${fontWeight}
|
||||
.fontStyle=${fontStyle}
|
||||
.onSelect=${onPick}
|
||||
></edgeless-font-weight-and-style-panel>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'd.font-size',
|
||||
when: type !== 'edgeless-text',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const fontSize$ = signal(
|
||||
Math.trunc(
|
||||
getMostCommonValue(models.map(mapInto), 'fontSize') ??
|
||||
FONT_SIZE_LIST[0].value
|
||||
)
|
||||
);
|
||||
|
||||
const onPick = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const fontSize = e.detail;
|
||||
|
||||
for (const model of models) {
|
||||
update(ctx, model, { fontSize });
|
||||
}
|
||||
};
|
||||
|
||||
return html`<affine-size-dropdown-menu
|
||||
@select=${onPick}
|
||||
.label="${'Font size'}"
|
||||
.sizes=${FONT_SIZE_LIST}
|
||||
.size$=${fontSize$}
|
||||
></affine-size-dropdown-menu>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'e.alignment',
|
||||
content(ctx) {
|
||||
const models = ctx.getSurfaceModelsByType(klass);
|
||||
if (!models.length) return null;
|
||||
const allowed = models.every(model =>
|
||||
isSurfaceTextModel(model, klass, type)
|
||||
);
|
||||
if (!allowed) return null;
|
||||
|
||||
const textAlign =
|
||||
getMostCommonValue(models.map(mapInto), 'textAlign') ??
|
||||
TextAlign.Left;
|
||||
|
||||
const onPick = (textAlign: TextAlign) => {
|
||||
for (const model of models) {
|
||||
update(ctx, model, { textAlign });
|
||||
}
|
||||
};
|
||||
|
||||
return renderMenu({
|
||||
label: 'Alignment',
|
||||
items: TEXT_ALIGN_LIST,
|
||||
currentValue: textAlign,
|
||||
onPick,
|
||||
});
|
||||
},
|
||||
},
|
||||
] as const satisfies ToolbarActions;
|
||||
}
|
||||
|
||||
function isSurfaceTextModel<
|
||||
K extends abstract new (...args: any) => any,
|
||||
T extends keyof SurfaceTextModelMap,
|
||||
>(model: GfxModel, klass: K, type: T): model is InstanceType<K> {
|
||||
return model instanceof klass || ('type' in model && model.type === type);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
TextUtils,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { TextElementModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { createTextActions } from '@blocksuite/affine-widget-edgeless-toolbar';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
|
||||
import { normalizeTextBound } from '../element-renderer/utils';
|
||||
import { createTextActions } from './actions';
|
||||
|
||||
export const textToolbarConfig = {
|
||||
actions: createTextActions(TextElementModel, 'text', (ctx, model, props) => {
|
||||
// No need to adjust element bounds
|
||||
@@ -30,7 +29,7 @@ export const textToolbarConfig = {
|
||||
|
||||
const { fontFamily, fontStyle, fontSize, fontWeight } = textStyle;
|
||||
|
||||
const bounds = TextUtils.normalizeTextBound(
|
||||
const bounds = normalizeTextBound(
|
||||
{
|
||||
yText,
|
||||
fontFamily,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './actions';
|
||||
export * from './config';
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../blocks/block-edgeless-text" },
|
||||
{ "path": "../../blocks/block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../model" },
|
||||
|
||||
Reference in New Issue
Block a user