mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
Some checks failed
Auto release excalidraw next / Auto-release-excalidraw-next (push) Failing after 2m36s
Build Docker image / build-docker (push) Failing after 6s
Cancel previous runs / cancel (push) Failing after 1s
Publish Docker / publish-docker (push) Failing after 31s
New Sentry production release / sentry (push) Failing after 2m3s
535 lines
13 KiB
TypeScript
535 lines
13 KiB
TypeScript
import {
|
|
DEFAULT_ELEMENT_PROPS,
|
|
DEFAULT_FONT_FAMILY,
|
|
DEFAULT_FONT_SIZE,
|
|
DEFAULT_TEXT_ALIGN,
|
|
DEFAULT_VERTICAL_ALIGN,
|
|
VERTICAL_ALIGN,
|
|
randomInteger,
|
|
randomId,
|
|
getFontString,
|
|
getUpdatedTimestamp,
|
|
getLineHeight,
|
|
} from "@excalidraw/common";
|
|
|
|
import type { Radians } from "@excalidraw/math";
|
|
|
|
import type { MarkOptional, Merge } from "@excalidraw/common/utility-types";
|
|
|
|
import {
|
|
getElementAbsoluteCoords,
|
|
getResizedElementAbsoluteCoords,
|
|
} from "./bounds";
|
|
import { newElementWith } from "./mutateElement";
|
|
import { getBoundTextMaxWidth } from "./textElement";
|
|
import { normalizeText, measureText } from "./textMeasurements";
|
|
import { wrapText } from "./textWrapping";
|
|
|
|
import type {
|
|
ExcalidrawElement,
|
|
ExcalidrawImageElement,
|
|
ExcalidrawTextElement,
|
|
ExcalidrawLinearElement,
|
|
ExcalidrawGenericElement,
|
|
NonDeleted,
|
|
TextAlign,
|
|
VerticalAlign,
|
|
Arrowhead,
|
|
ExcalidrawFreeDrawElement,
|
|
FontFamilyValues,
|
|
ExcalidrawTextContainer,
|
|
ExcalidrawFrameElement,
|
|
ExcalidrawEmbeddableElement,
|
|
ExcalidrawMagicFrameElement,
|
|
ExcalidrawIframeElement,
|
|
ElementsMap,
|
|
ExcalidrawArrowElement,
|
|
FixedSegment,
|
|
ExcalidrawElbowArrowElement,
|
|
} from "./types";
|
|
|
|
export type ElementConstructorOpts = MarkOptional<
|
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
|
| "width"
|
|
| "height"
|
|
| "angle"
|
|
| "groupIds"
|
|
| "frameId"
|
|
| "index"
|
|
| "boundElements"
|
|
| "seed"
|
|
| "version"
|
|
| "versionNonce"
|
|
| "link"
|
|
| "strokeStyle"
|
|
| "fillStyle"
|
|
| "strokeColor"
|
|
| "backgroundColor"
|
|
| "roughness"
|
|
| "strokeWidth"
|
|
| "roundness"
|
|
| "locked"
|
|
| "opacity"
|
|
| "customData"
|
|
>;
|
|
|
|
const _newElementBase = <T extends ExcalidrawElement>(
|
|
type: T["type"],
|
|
{
|
|
x,
|
|
y,
|
|
strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
|
|
backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
|
|
fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
|
|
strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
|
|
strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
|
|
roughness = DEFAULT_ELEMENT_PROPS.roughness,
|
|
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
|
width = 0,
|
|
height = 0,
|
|
angle = 0 as Radians,
|
|
groupIds = [],
|
|
frameId = null,
|
|
index = null,
|
|
roundness = null,
|
|
boundElements = null,
|
|
link = null,
|
|
locked = DEFAULT_ELEMENT_PROPS.locked,
|
|
...rest
|
|
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
|
) => {
|
|
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
// element position or sizing
|
|
if (
|
|
x < -1e6 ||
|
|
x > 1e6 ||
|
|
y < -1e6 ||
|
|
y > 1e6 ||
|
|
width < -1e6 ||
|
|
width > 1e6 ||
|
|
height < -1e6 ||
|
|
height > 1e6
|
|
) {
|
|
console.error("New element size or position is too large", {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
// @ts-ignore
|
|
points: rest.points,
|
|
});
|
|
}
|
|
|
|
// assign type to guard against excess properties
|
|
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
|
id: rest.id || randomId(),
|
|
type,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
angle,
|
|
strokeColor,
|
|
backgroundColor,
|
|
fillStyle,
|
|
strokeWidth,
|
|
strokeStyle,
|
|
roughness,
|
|
opacity,
|
|
groupIds,
|
|
frameId,
|
|
index,
|
|
roundness,
|
|
seed: rest.seed ?? randomInteger(),
|
|
version: rest.version || 1,
|
|
versionNonce: rest.versionNonce ?? 0,
|
|
isDeleted: false as false,
|
|
boundElements,
|
|
updated: getUpdatedTimestamp(),
|
|
link,
|
|
locked,
|
|
customData: rest.customData,
|
|
};
|
|
return element;
|
|
};
|
|
|
|
export const newElement = (
|
|
opts: {
|
|
type: ExcalidrawGenericElement["type"];
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawGenericElement> =>
|
|
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
|
|
|
export const newEmbeddableElement = (
|
|
opts: {
|
|
type: "embeddable";
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
|
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
|
|
};
|
|
|
|
export const newIframeElement = (
|
|
opts: {
|
|
type: "iframe";
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawIframeElement> => {
|
|
return {
|
|
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
|
|
};
|
|
};
|
|
|
|
export const newFrameElement = (
|
|
opts: {
|
|
name?: string;
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawFrameElement> => {
|
|
const frameElement = newElementWith(
|
|
{
|
|
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
|
type: "frame",
|
|
name: opts?.name || null,
|
|
},
|
|
{},
|
|
);
|
|
|
|
return frameElement;
|
|
};
|
|
|
|
export const newMagicFrameElement = (
|
|
opts: {
|
|
name?: string;
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawMagicFrameElement> => {
|
|
const frameElement = newElementWith(
|
|
{
|
|
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
|
|
type: "magicframe",
|
|
name: opts?.name || null,
|
|
},
|
|
{},
|
|
);
|
|
|
|
return frameElement;
|
|
};
|
|
|
|
/** computes element x/y offset based on textAlign/verticalAlign */
|
|
const getTextElementPositionOffsets = (
|
|
opts: {
|
|
textAlign: ExcalidrawTextElement["textAlign"];
|
|
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
|
},
|
|
metrics: {
|
|
width: number;
|
|
height: number;
|
|
},
|
|
) => {
|
|
return {
|
|
x:
|
|
opts.textAlign === "center"
|
|
? metrics.width / 2
|
|
: opts.textAlign === "right"
|
|
? metrics.width
|
|
: 0,
|
|
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
|
};
|
|
};
|
|
|
|
export const newTextElement = (
|
|
opts: {
|
|
text: string;
|
|
originalText?: string;
|
|
fontSize?: number;
|
|
fontFamily?: FontFamilyValues;
|
|
textAlign?: TextAlign;
|
|
verticalAlign?: VerticalAlign;
|
|
containerId?: ExcalidrawTextContainer["id"] | null;
|
|
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
|
autoResize?: ExcalidrawTextElement["autoResize"];
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawTextElement> => {
|
|
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
|
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
|
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
|
const text = normalizeText(opts.text);
|
|
const metrics = measureText(
|
|
text,
|
|
getFontString({ fontFamily, fontSize }),
|
|
lineHeight,
|
|
);
|
|
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
|
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
|
const offsets = getTextElementPositionOffsets(
|
|
{ textAlign, verticalAlign },
|
|
metrics,
|
|
);
|
|
|
|
const textElementProps: ExcalidrawTextElement = {
|
|
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
|
text,
|
|
fontSize,
|
|
fontFamily,
|
|
textAlign,
|
|
verticalAlign,
|
|
x: opts.x - offsets.x,
|
|
y: opts.y - offsets.y,
|
|
width: metrics.width,
|
|
height: metrics.height,
|
|
containerId: opts.containerId || null,
|
|
originalText: opts.originalText ?? text,
|
|
autoResize: opts.autoResize ?? true,
|
|
lineHeight,
|
|
};
|
|
|
|
const textElement: ExcalidrawTextElement = newElementWith(
|
|
textElementProps,
|
|
{},
|
|
);
|
|
|
|
return textElement;
|
|
};
|
|
|
|
const getAdjustedDimensions = (
|
|
element: ExcalidrawTextElement,
|
|
elementsMap: ElementsMap,
|
|
nextText: string,
|
|
): {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
} => {
|
|
let { width: nextWidth, height: nextHeight } = measureText(
|
|
nextText,
|
|
getFontString(element),
|
|
element.lineHeight,
|
|
);
|
|
|
|
// wrapped text
|
|
if (!element.autoResize) {
|
|
nextWidth = element.width;
|
|
}
|
|
|
|
const { textAlign, verticalAlign } = element;
|
|
let x: number;
|
|
let y: number;
|
|
if (
|
|
textAlign === "center" &&
|
|
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
|
!element.containerId &&
|
|
element.autoResize
|
|
) {
|
|
const prevMetrics = measureText(
|
|
element.text,
|
|
getFontString(element),
|
|
element.lineHeight,
|
|
);
|
|
const offsets = getTextElementPositionOffsets(element, {
|
|
width: nextWidth - prevMetrics.width,
|
|
height: nextHeight - prevMetrics.height,
|
|
});
|
|
|
|
x = element.x - offsets.x;
|
|
y = element.y - offsets.y;
|
|
} else {
|
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
|
|
|
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
|
element,
|
|
nextWidth,
|
|
nextHeight,
|
|
false,
|
|
);
|
|
const deltaX1 = (x1 - nextX1) / 2;
|
|
const deltaY1 = (y1 - nextY1) / 2;
|
|
const deltaX2 = (x2 - nextX2) / 2;
|
|
const deltaY2 = (y2 - nextY2) / 2;
|
|
|
|
[x, y] = adjustXYWithRotation(
|
|
{
|
|
s: true,
|
|
e: textAlign === "center" || textAlign === "left",
|
|
w: textAlign === "center" || textAlign === "right",
|
|
},
|
|
element.x,
|
|
element.y,
|
|
element.angle,
|
|
deltaX1,
|
|
deltaY1,
|
|
deltaX2,
|
|
deltaY2,
|
|
);
|
|
}
|
|
|
|
return {
|
|
width: nextWidth,
|
|
height: nextHeight,
|
|
x: Number.isFinite(x) ? x : element.x,
|
|
y: Number.isFinite(y) ? y : element.y,
|
|
};
|
|
};
|
|
|
|
const adjustXYWithRotation = (
|
|
sides: {
|
|
n?: boolean;
|
|
e?: boolean;
|
|
s?: boolean;
|
|
w?: boolean;
|
|
},
|
|
x: number,
|
|
y: number,
|
|
angle: number,
|
|
deltaX1: number,
|
|
deltaY1: number,
|
|
deltaX2: number,
|
|
deltaY2: number,
|
|
): [number, number] => {
|
|
const cos = Math.cos(angle);
|
|
const sin = Math.sin(angle);
|
|
if (sides.e && sides.w) {
|
|
x += deltaX1 + deltaX2;
|
|
} else if (sides.e) {
|
|
x += deltaX1 * (1 + cos);
|
|
y += deltaX1 * sin;
|
|
x += deltaX2 * (1 - cos);
|
|
y += deltaX2 * -sin;
|
|
} else if (sides.w) {
|
|
x += deltaX1 * (1 - cos);
|
|
y += deltaX1 * -sin;
|
|
x += deltaX2 * (1 + cos);
|
|
y += deltaX2 * sin;
|
|
}
|
|
|
|
if (sides.n && sides.s) {
|
|
y += deltaY1 + deltaY2;
|
|
} else if (sides.n) {
|
|
x += deltaY1 * sin;
|
|
y += deltaY1 * (1 - cos);
|
|
x += deltaY2 * -sin;
|
|
y += deltaY2 * (1 + cos);
|
|
} else if (sides.s) {
|
|
x += deltaY1 * -sin;
|
|
y += deltaY1 * (1 + cos);
|
|
x += deltaY2 * sin;
|
|
y += deltaY2 * (1 - cos);
|
|
}
|
|
return [x, y];
|
|
};
|
|
|
|
export const refreshTextDimensions = (
|
|
textElement: ExcalidrawTextElement,
|
|
container: ExcalidrawTextContainer | null,
|
|
elementsMap: ElementsMap,
|
|
text = textElement.text,
|
|
) => {
|
|
if (textElement.isDeleted) {
|
|
return;
|
|
}
|
|
if (container || !textElement.autoResize) {
|
|
text = wrapText(
|
|
text,
|
|
getFontString(textElement),
|
|
container
|
|
? getBoundTextMaxWidth(container, textElement)
|
|
: textElement.width,
|
|
);
|
|
}
|
|
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
|
|
return { text, ...dimensions };
|
|
};
|
|
|
|
export const newFreeDrawElement = (
|
|
opts: {
|
|
type: "freedraw";
|
|
points?: ExcalidrawFreeDrawElement["points"];
|
|
simulatePressure: boolean;
|
|
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
|
return {
|
|
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
|
points: opts.points || [],
|
|
pressures: opts.pressures || [],
|
|
simulatePressure: opts.simulatePressure,
|
|
lastCommittedPoint: null,
|
|
};
|
|
};
|
|
|
|
export const newLinearElement = (
|
|
opts: {
|
|
type: ExcalidrawLinearElement["type"];
|
|
points?: ExcalidrawLinearElement["points"];
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawLinearElement> => {
|
|
return {
|
|
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
|
points: opts.points || [],
|
|
lastCommittedPoint: null,
|
|
startBinding: null,
|
|
endBinding: null,
|
|
startArrowhead: null,
|
|
endArrowhead: null,
|
|
};
|
|
};
|
|
|
|
export const newArrowElement = <T extends boolean>(
|
|
opts: {
|
|
type: ExcalidrawArrowElement["type"];
|
|
startArrowhead?: Arrowhead | null;
|
|
endArrowhead?: Arrowhead | null;
|
|
points?: ExcalidrawArrowElement["points"];
|
|
elbowed?: T;
|
|
fixedSegments?: FixedSegment[] | null;
|
|
} & ElementConstructorOpts,
|
|
): T extends true
|
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
|
: NonDeleted<ExcalidrawArrowElement> => {
|
|
if (opts.elbowed) {
|
|
return {
|
|
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
|
points: opts.points || [],
|
|
lastCommittedPoint: null,
|
|
startBinding: null,
|
|
endBinding: null,
|
|
startArrowhead: opts.startArrowhead || null,
|
|
endArrowhead: opts.endArrowhead || null,
|
|
elbowed: true,
|
|
fixedSegments: opts.fixedSegments || [],
|
|
startIsSpecial: false,
|
|
endIsSpecial: false,
|
|
} as NonDeleted<ExcalidrawElbowArrowElement>;
|
|
}
|
|
|
|
return {
|
|
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
|
points: opts.points || [],
|
|
lastCommittedPoint: null,
|
|
startBinding: null,
|
|
endBinding: null,
|
|
startArrowhead: opts.startArrowhead || null,
|
|
endArrowhead: opts.endArrowhead || null,
|
|
elbowed: false,
|
|
} as T extends true
|
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
|
: NonDeleted<ExcalidrawArrowElement>;
|
|
};
|
|
|
|
export const newImageElement = (
|
|
opts: {
|
|
type: ExcalidrawImageElement["type"];
|
|
status?: ExcalidrawImageElement["status"];
|
|
fileId?: ExcalidrawImageElement["fileId"];
|
|
scale?: ExcalidrawImageElement["scale"];
|
|
crop?: ExcalidrawImageElement["crop"];
|
|
} & ElementConstructorOpts,
|
|
): NonDeleted<ExcalidrawImageElement> => {
|
|
return {
|
|
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
|
// in the future we'll support changing stroke color for some SVG elements,
|
|
// and `transparent` will likely mean "use original colors of the image"
|
|
strokeColor: "transparent",
|
|
status: opts.status ?? "pending",
|
|
fileId: opts.fileId ?? null,
|
|
scale: opts.scale ?? [1, 1],
|
|
crop: opts.crop ?? null,
|
|
};
|
|
};
|