mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax
This commit is contained in:
commit
795176b256
221 changed files with 15664 additions and 8165 deletions
|
@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
|
|||
if (
|
||||
!isMobile &&
|
||||
appState.viewModeEnabled &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
@ -440,7 +440,9 @@ export const shouldHideLinkPopup = (
|
|||
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
|
||||
if (
|
||||
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
||||
|
|
|
@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
|
|||
];
|
||||
|
||||
export const shouldEnableBindingForPointerEvent = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
return !event[KEYS.CTRL_OR_CMD];
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import { distance2d, rotate, rotatePoint } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
|
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
|
||||
|
||||
export class ElementBounds {
|
||||
private static boundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(element: ExcalidrawElement) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (cachedBounds?.version && cachedBounds.version === element.version) {
|
||||
return cachedBounds.bounds;
|
||||
}
|
||||
|
||||
const bounds = ElementBounds.calculateBounds(element);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private static calculateBounds(element: ExcalidrawElement): Bounds {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
//
|
||||
// If the element is created from right to left, the width is going to be negative
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export const getElementAbsoluteCoords = (
|
||||
|
@ -69,6 +160,111 @@ export const getElementAbsoluteCoords = (
|
|||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* for a given element, `getElementLineSegments` returns line segments
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
*/
|
||||
export const getElementLineSegments = (
|
||||
element: ExcalidrawElement,
|
||||
): [Point, Point][] => {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
const center: Point = [cx, cy];
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: [Point, Point][] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < element.points.length - 1) {
|
||||
segments.push([
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
const [nw, ne, sw, se, n, s, w, e] = (
|
||||
[
|
||||
[x1, y1],
|
||||
[x2, y1],
|
||||
[x1, y2],
|
||||
[x2, y2],
|
||||
[cx, y1],
|
||||
[cx, y2],
|
||||
[x1, cy],
|
||||
[x2, cy],
|
||||
] as Point[]
|
||||
).map((point) => rotatePoint(point, center, element.angle));
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
[nw, ne],
|
||||
[sw, se],
|
||||
[nw, sw],
|
||||
[ne, se],
|
||||
[nw, e],
|
||||
[sw, e],
|
||||
[ne, w],
|
||||
[se, w],
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
*
|
||||
* Rectangle here means any rectangular frame, not an excalidraw element.
|
||||
*/
|
||||
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
|
||||
return [
|
||||
boxSceneCoords.x,
|
||||
boxSceneCoords.y,
|
||||
boxSceneCoords.x + boxSceneCoords.width,
|
||||
boxSceneCoords.y + boxSceneCoords.height,
|
||||
boxSceneCoords.x + boxSceneCoords.width / 2,
|
||||
boxSceneCoords.y + boxSceneCoords.height / 2,
|
||||
];
|
||||
};
|
||||
|
||||
export const pointRelativeTo = (
|
||||
element: ExcalidrawElement,
|
||||
absoluteCoords: Point,
|
||||
|
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
|
|||
return coords;
|
||||
};
|
||||
|
||||
// We could cache this stuff
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
): [number, number, number, number] => {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
return bounds;
|
||||
export const getElementBounds = (element: ExcalidrawElement): Bounds => {
|
||||
return ElementBounds.getBounds(element);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
|
|||
export const getClosestElementBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
from: { x: number; y: number },
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
|
|||
return getElementBounds(closestElement);
|
||||
};
|
||||
|
||||
export interface Box {
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
|
@ -642,7 +786,7 @@ export interface Box {
|
|||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): Box => {
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
minX,
|
||||
|
|
|
@ -26,10 +26,16 @@ import {
|
|||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
StrokeRoundness,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCurvePathOps,
|
||||
getRectangleBoxAbsoluteCoords,
|
||||
RectangleBox,
|
||||
} from "./bounds";
|
||||
import { FrameNameBoundsCache, Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
|
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
|
|||
export const hitTest = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
|
@ -72,22 +79,39 @@ export const hitTest = (
|
|||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
return isPointHittingElementBoundingBox(
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
|
||||
const isHittingBoundTextElement = hitTest(
|
||||
boundTextElement,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
if (isHittingBoundTextElement) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
||||
return isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
point,
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
|
@ -96,19 +120,33 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
|||
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
|
||||
// eg for linear elements text can be outside the element bounding box
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
!isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
[x, y],
|
||||
) &&
|
||||
isPointHittingElementBoundingBox(
|
||||
element,
|
||||
[x, y],
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementNotConsideringBoundingBox = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
point: Point,
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
|
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
|
|||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
};
|
||||
|
||||
const isElementSelected = (
|
||||
|
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
|
|||
element: NonDeleted<ExcalidrawElement>,
|
||||
[x, y]: Point,
|
||||
threshold: number,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
) => {
|
||||
// frames needs be checked differently so as to be able to drag it
|
||||
// by its frame, whether it has been selected or not
|
||||
// this logic here is not ideal
|
||||
// TODO: refactor it later...
|
||||
if (element.type === "frame") {
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point: [x, y],
|
||||
threshold,
|
||||
check: isInsideCheck,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCenterX = (x1 + x2) / 2;
|
||||
const elementCenterY = (y1 + y2) / 2;
|
||||
|
@ -157,7 +216,13 @@ export const bindingBorderTest = (
|
|||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const check = isOutsideCheck;
|
||||
const point: Point = [x, y];
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
|
@ -177,6 +242,7 @@ type HitTestArgs = {
|
|||
point: Point;
|
||||
threshold: number;
|
||||
check: (distance: number, threshold: number) => boolean;
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null;
|
||||
};
|
||||
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
|
@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
|||
"This should not happen, we need to investigate why it does.",
|
||||
);
|
||||
return false;
|
||||
case "frame": {
|
||||
// check distance to frame element first
|
||||
if (
|
||||
args.check(
|
||||
distanceToBindableElement(args.element, args.point),
|
||||
args.threshold,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
|
||||
|
||||
if (frameNameBounds) {
|
||||
return args.check(
|
||||
distanceToRectangleBox(frameNameBounds, args.point),
|
||||
args.threshold,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -219,6 +306,7 @@ export const distanceToBindableElement = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
|
@ -248,7 +336,8 @@ const distanceToRectangle = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
|
@ -258,6 +347,14 @@ const distanceToRectangle = (
|
|||
);
|
||||
};
|
||||
|
||||
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
|
||||
return Math.max(
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceToDiamond = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: Point,
|
||||
|
@ -457,8 +554,7 @@ const pointRelativeToElement = (
|
|||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
|
@ -466,9 +562,26 @@ const pointRelativeToElement = (
|
|||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(element.x, element.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const [ax, ay, bx, by] = elementCoords;
|
||||
const halfWidth = (bx - ax) / 2;
|
||||
const halfHeight = (by - ay) / 2;
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
const pointRelativeToDivElement = (
|
||||
pointTuple: Point,
|
||||
rectangle: RectangleBox,
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
const rotate = GATransform.rotation(center, rectangle.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(rectangle.x, rectangle.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
|
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
|
|||
element: ExcalidrawElement,
|
||||
): GA.Transform => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const translate = GA.reverse(
|
||||
|
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
|
|||
return GATransform.compose(rotate, translate);
|
||||
};
|
||||
|
||||
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
||||
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
||||
const coordsCenter = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): GA.Point => {
|
||||
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
};
|
||||
|
||||
// The focus distance is the oriented ratio between the size of
|
||||
|
@ -531,6 +649,7 @@ export const determineFocusDistance = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
|
@ -548,7 +667,7 @@ export const determineFocusPoint = (
|
|||
): Point => {
|
||||
if (focus === 0) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
return GAPoint.toTuple(center);
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
|
@ -563,6 +682,7 @@ export const determineFocusPoint = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
|
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
|
@ -646,7 +767,8 @@ const getCorners = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
|
@ -655,6 +777,7 @@ const getCorners = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
|
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
|
|
@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
|
|||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
|
@ -16,10 +18,31 @@ export const dragSelectedElements = (
|
|||
distanceX: number = 0,
|
||||
distanceY: number = 0,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
selectedElements.forEach((element) => {
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
|
||||
selectedElements,
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
const elementsInFrames = scene
|
||||
.getNonDeletedElements()
|
||||
.filter((e) => e.frameId !== null)
|
||||
.filter((e) => frames.includes(e.frameId!));
|
||||
|
||||
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
|
||||
}
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
|
@ -38,7 +61,13 @@ export const dragSelectedElements = (
|
|||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||
) {
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (textElement) {
|
||||
if (
|
||||
textElement &&
|
||||
// when container is added to a frame, so will its bound text
|
||||
// so the text is already in `elementsToUpdate` and we should avoid
|
||||
// updating its coords again
|
||||
(!textElement.frameId || !frames.includes(textElement.frameId))
|
||||
) {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
|
@ -50,7 +79,7 @@ export const dragSelectedElements = (
|
|||
}
|
||||
}
|
||||
updateBoundElements(element, {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
@ -49,7 +50,11 @@ export {
|
|||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
isFrameElement,
|
||||
} from "./typeChecks";
|
||||
export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
|
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
|||
(element) => !element.isDeleted,
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedFrames = (
|
||||
frames: readonly ExcalidrawFrameElement[],
|
||||
) =>
|
||||
frames.filter(
|
||||
(frame) => !frame.isDeleted,
|
||||
) as readonly NonDeleted<ExcalidrawFrameElement>[];
|
||||
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
|
|
@ -594,7 +594,7 @@ export class LinearElementEditor {
|
|||
}
|
||||
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
appState: AppState,
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
|
|
|
@ -12,18 +12,17 @@ import {
|
|||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../element/types";
|
||||
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureTextElement,
|
||||
normalizeText,
|
||||
|
@ -39,7 +38,6 @@ import {
|
|||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getSubtypeMethods, isValidSubtype } from "../subtypes";
|
||||
|
||||
|
@ -72,6 +70,7 @@ type ElementConstructorOpts = MarkOptional<
|
|||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
|
@ -106,6 +105,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
height = 0,
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
|
@ -132,6 +132,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
roughness,
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
|
@ -155,6 +156,21 @@ export const newElement = (
|
|||
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
|
@ -187,6 +203,7 @@ export const newTextElement = (
|
|||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
isFrameName?: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
|
@ -223,6 +240,7 @@ export const newTextElement = (
|
|||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
isFrameName: opts.isFrameName || false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
@ -239,8 +257,6 @@ const getAdjustedDimensions = (
|
|||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
@ -294,27 +310,6 @@ const getAdjustedDimensions = (
|
|||
);
|
||||
}
|
||||
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const boundTextElementPadding = getBoundTextElementOffset(element);
|
||||
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||
height = nextHeight + boundTextElementPadding * 2;
|
||||
}
|
||||
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||
width = nextWidth + boundTextElementPadding * 2;
|
||||
}
|
||||
if (
|
||||
!isArrowElement(container) &&
|
||||
(height !== containerDims.height || width !== containerDims.width)
|
||||
) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
@ -668,6 +663,10 @@ export const duplicateElements = (
|
|||
: null;
|
||||
}
|
||||
|
||||
if (clonedElement.frameId) {
|
||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
|
|
|
@ -14,17 +14,22 @@ import {
|
|||
NonDeleted,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawImageElement,
|
||||
} from "./types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
getCommonBoundingBox,
|
||||
getElementPointsCoords,
|
||||
} from "./bounds";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
@ -49,8 +54,12 @@ import {
|
|||
measureTextElement,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle < 0) {
|
||||
return angle + 2 * Math.PI;
|
||||
}
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return angle - 2 * Math.PI;
|
||||
}
|
||||
|
@ -152,12 +161,17 @@ const rotateSingleElement = (
|
|||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
let angle: number;
|
||||
if (isFrameElement(element)) {
|
||||
angle = 0;
|
||||
} else {
|
||||
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
mutateElement(element, { angle });
|
||||
|
@ -592,7 +606,7 @@ export const resizeSingleElement = (
|
|||
}
|
||||
};
|
||||
|
||||
const resizeMultipleElements = (
|
||||
export const resizeMultipleElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
|
@ -623,8 +637,28 @@ const resizeMultipleElements = (
|
|||
[],
|
||||
);
|
||||
|
||||
// getCommonBoundingBox() uses getBoundTextElement() which returns null for
|
||||
// original elements from pointerDownState, so we have to find and add these
|
||||
// bound text elements manually. Additionally, the coordinates of bound text
|
||||
// elements aren't always up to date.
|
||||
const boundTextElements = targetElements.reduce((acc, { orig }) => {
|
||||
if (!isLinearElement(orig)) {
|
||||
return acc;
|
||||
}
|
||||
const textId = getBoundTextElementId(orig);
|
||||
if (!textId) {
|
||||
return acc;
|
||||
}
|
||||
const text = pointerDownState.originalElements.get(textId) ?? null;
|
||||
if (!isBoundToContainer(text)) {
|
||||
return acc;
|
||||
}
|
||||
const xy = LinearElementEditor.getBoundTextElementPosition(orig, text);
|
||||
return [...acc, { ...text, ...xy }];
|
||||
}, [] as ExcalidrawTextElementWithContainer[]);
|
||||
|
||||
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||
targetElements.map(({ orig }) => orig),
|
||||
targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
||||
);
|
||||
const direction = transformHandleType;
|
||||
|
||||
|
@ -636,12 +670,22 @@ const resizeMultipleElements = (
|
|||
};
|
||||
|
||||
// anchor point must be on the opposite side of the dragged selection handle
|
||||
// or be the center of the selection if alt is pressed
|
||||
// or be the center of the selection if shouldResizeFromCenter
|
||||
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||
? [midX, midY]
|
||||
: mapDirectionsToAnchors[direction];
|
||||
|
||||
const mapDirectionsToPointerSides: Record<
|
||||
const scale =
|
||||
Math.max(
|
||||
Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
|
||||
Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
|
||||
) * (shouldResizeFromCenter ? 2 : 1);
|
||||
|
||||
if (scale === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapDirectionsToPointerPositions: Record<
|
||||
typeof direction,
|
||||
[x: boolean, y: boolean]
|
||||
> = {
|
||||
|
@ -651,68 +695,117 @@ const resizeMultipleElements = (
|
|||
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
||||
};
|
||||
|
||||
// pointer side relative to anchor
|
||||
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
|
||||
/**
|
||||
* to flip an element:
|
||||
* 1. determine over which axis is the element being flipped
|
||||
* (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY`
|
||||
* 2. shift element's position by the amount of width or height (or both) or
|
||||
* mirror points in the case of linear & freedraw elemenets
|
||||
* 3. adjust element angle
|
||||
*/
|
||||
const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
|
||||
direction
|
||||
].map((condition) => (condition ? 1 : -1));
|
||||
const isFlippedByX = flipFactorX < 0;
|
||||
const isFlippedByY = flipFactorY < 0;
|
||||
|
||||
// stop resizing if a pointer is on the other side of selection
|
||||
if (pointerSideX < 0 && pointerSideY < 0) {
|
||||
return;
|
||||
}
|
||||
const elementsAndUpdates: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
update: Mutable<
|
||||
Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle">
|
||||
> & {
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
};
|
||||
boundText: {
|
||||
element: ExcalidrawTextElementWithContainer;
|
||||
fontSize: ExcalidrawTextElement["fontSize"];
|
||||
baseline: ExcalidrawTextElement["baseline"];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
const scale =
|
||||
Math.max(
|
||||
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
|
||||
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
||||
) * (shouldResizeFromCenter ? 2 : 1);
|
||||
for (const { orig, latest } of targetElements) {
|
||||
// bounded text elements are updated along with their container elements
|
||||
if (isTextElement(orig) && isBoundToContainer(orig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scale === 0) {
|
||||
return;
|
||||
}
|
||||
const width = orig.width * scale;
|
||||
const height = orig.height * scale;
|
||||
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
|
||||
|
||||
targetElements.forEach((element) => {
|
||||
const width = element.orig.width * scale;
|
||||
const height = element.orig.height * scale;
|
||||
const x = anchorX + (element.orig.x - anchorX) * scale;
|
||||
const y = anchorY + (element.orig.y - anchorY) * scale;
|
||||
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
|
||||
const offsetX = orig.x - anchorX;
|
||||
const offsetY = orig.y - anchorY;
|
||||
const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
|
||||
const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
|
||||
const x = anchorX + flipFactorX * (offsetX * scale + shiftX);
|
||||
const y = anchorY + flipFactorY * (offsetY * scale + shiftY);
|
||||
|
||||
// readjust points for linear & free draw elements
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
element.orig,
|
||||
width,
|
||||
height,
|
||||
orig,
|
||||
width * flipFactorX,
|
||||
height * flipFactorY,
|
||||
false,
|
||||
);
|
||||
|
||||
const update: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
points?: Point[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
} = {
|
||||
width,
|
||||
height,
|
||||
const update: typeof elementsAndUpdates[0]["update"] = {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||
if (isImageElement(orig) && targetElements.length === 1) {
|
||||
update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
if (isLinearElement(orig) && (isFlippedByX || isFlippedByY)) {
|
||||
const origBounds = getElementPointsCoords(orig, orig.points);
|
||||
const newBounds = getElementPointsCoords(
|
||||
{ ...orig, x, y },
|
||||
rescaledPoints.points!,
|
||||
);
|
||||
const origXY = [orig.x, orig.y];
|
||||
const newXY = [x, y];
|
||||
|
||||
if (boundTextElement || isTextElement(element.orig)) {
|
||||
const linearShift = (axis: "x" | "y") => {
|
||||
const i = axis === "x" ? 0 : 1;
|
||||
return (
|
||||
(newBounds[i + 2] -
|
||||
newXY[i] -
|
||||
(origXY[i] - origBounds[i]) * scale +
|
||||
(origBounds[i + 2] - origXY[i]) * scale -
|
||||
(newXY[i] - newBounds[i])) /
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
if (isFlippedByX) {
|
||||
update.x -= linearShift("x");
|
||||
}
|
||||
|
||||
if (isFlippedByY) {
|
||||
update.y -= linearShift("y");
|
||||
}
|
||||
}
|
||||
|
||||
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(latest);
|
||||
|
||||
if (boundTextElement || isTextElement(orig)) {
|
||||
const updatedElement = {
|
||||
...element.latest,
|
||||
...latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
boundTextElement ?? (orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
|
@ -725,29 +818,50 @@ const resizeMultipleElements = (
|
|||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(element.orig)) {
|
||||
if (isTextElement(orig)) {
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextUpdates = {
|
||||
boundText = {
|
||||
element: boundTextElement,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundElements(element.latest, { newSize: { width, height } });
|
||||
elementsAndUpdates.push({ element: latest, update, boundText });
|
||||
}
|
||||
|
||||
mutateElement(element.latest, update);
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
|
||||
if (boundTextElement && boundTextUpdates) {
|
||||
mutateElement(boundTextElement, boundTextUpdates);
|
||||
for (const { element, update, boundText } of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
handleBindTextResize(element.latest, transformHandleType);
|
||||
mutateElement(element, update, false);
|
||||
|
||||
updateBoundElements(element, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (boundText) {
|
||||
const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
...boundTextUpdates,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
|
||||
};
|
||||
|
||||
const rotateMultipleElements = (
|
||||
|
@ -765,39 +879,49 @@ const rotateMultipleElements = (
|
|||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(element, {
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
|
||||
boundTextElementId,
|
||||
);
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
mutateElement(textElement, {
|
||||
x: textElement.x + (rotatedCX - cx),
|
||||
y: textElement.y + (rotatedCY - cy),
|
||||
|
||||
elements
|
||||
.filter((element) => element.type !== "frame")
|
||||
.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
pointerDownState.originalElements.get(element.id)?.angle ??
|
||||
element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
updateBoundElements(element, { simultaneouslyUpdated: elements });
|
||||
|
||||
const boundText = getBoundTextElement(element);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
mutateElement(
|
||||
boundText,
|
||||
{
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Scene.getScene(elements[0])?.informMutation();
|
||||
};
|
||||
|
||||
export const getResizeOffsetXY = (
|
||||
|
|
|
@ -859,10 +859,12 @@ export const getTextBindableContainerAtPosition = (
|
|||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
|
||||
x,
|
||||
y,
|
||||
])
|
||||
isHittingElementNotConsideringBoundingBox(
|
||||
elements[index],
|
||||
appState,
|
||||
null,
|
||||
[x, y],
|
||||
)
|
||||
) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
|
|
|
@ -26,6 +26,17 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const getTextEditor = () => {
|
||||
return document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
|
@ -190,9 +201,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -214,9 +223,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -243,9 +250,7 @@ describe("textWysiwyg", () => {
|
|||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
textarea = getTextEditor();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -455,17 +460,11 @@ describe("textWysiwyg", () => {
|
|||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.change(textarea, {
|
||||
target: {
|
||||
value:
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
},
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
textarea = getTextEditor();
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
textarea.blur();
|
||||
expect(textarea.style.width).toBe("792px");
|
||||
|
@ -513,11 +512,9 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -543,11 +540,9 @@ describe("textWysiwyg", () => {
|
|||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -572,9 +567,7 @@ describe("textWysiwyg", () => {
|
|||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
|
@ -587,9 +580,7 @@ describe("textWysiwyg", () => {
|
|||
expect(diamond.height).toBe(50020);
|
||||
|
||||
// Clearing text to simulate height decrease
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value: "" } }),
|
||||
).not.toThrow();
|
||||
expect(() => updateTextEditor(editor, "")).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
|
@ -611,9 +602,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -628,11 +617,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -652,13 +639,11 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -689,11 +674,8 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -717,17 +699,9 @@ describe("textWysiwyg", () => {
|
|||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(freedraw.boundElements).toBe(null);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
@ -759,11 +733,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -776,17 +748,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
@ -826,12 +793,10 @@ describe("textWysiwyg", () => {
|
|||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
UI.clickTool("text");
|
||||
|
@ -841,9 +806,7 @@ describe("textWysiwyg", () => {
|
|||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
|
@ -876,17 +839,9 @@ describe("textWysiwyg", () => {
|
|||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
|
@ -905,17 +860,8 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
|
@ -943,13 +889,11 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -982,11 +926,9 @@ describe("textWysiwyg", () => {
|
|||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -1005,11 +947,9 @@ describe("textWysiwyg", () => {
|
|||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
|
@ -1024,9 +964,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1049,9 +987,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1089,11 +1025,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1106,11 +1040,9 @@ describe("textWysiwyg", () => {
|
|||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
|
@ -1128,11 +1060,9 @@ describe("textWysiwyg", () => {
|
|||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
|
@ -1162,11 +1092,9 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
|
@ -1201,54 +1129,64 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: " " } });
|
||||
updateTextEditor(editor, " ");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
height: 75,
|
||||
width: 90,
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
const originalRectHeight = container.height;
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Online whiteboard collaboration made easy",
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
let contextMenu = document.querySelector(".context-menu");
|
||||
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
|
||||
);
|
||||
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
|
||||
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
||||
);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
|
||||
expect(h.elements[0].boundElements).toEqual([]);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
expect(getOriginalContainerHeightFromCache(container.id)).toBe(null);
|
||||
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
|
@ -1258,9 +1196,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1273,12 +1209,8 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
mouse.select(rectangle);
|
||||
|
@ -1302,12 +1234,8 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
|
@ -1338,17 +1266,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
editor.select();
|
||||
});
|
||||
|
||||
|
@ -1459,17 +1382,12 @@ describe("textWysiwyg", () => {
|
|||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
|
||||
editor.select();
|
||||
|
@ -1521,7 +1439,7 @@ describe("textWysiwyg", () => {
|
|||
roundness: {
|
||||
type: 3,
|
||||
},
|
||||
strokeColor: "#000000",
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
type: "rectangle",
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -23,7 +23,6 @@ import { AppState } from "../types";
|
|||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
|
@ -36,6 +35,7 @@ import {
|
|||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
|
@ -185,13 +185,9 @@ export const textWysiwyg = ({
|
|||
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = Math.max(updatedTextElement.height, maxHeight);
|
||||
const textElementHeight = Math.max(updatedTextElement.height, maxHeight);
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
textElementHeight = Math.min(
|
||||
getBoundTextMaxWidth(container),
|
||||
textElementHeight,
|
||||
);
|
||||
if (isArrowElement(container)) {
|
||||
const boundTextCoords =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
|
@ -200,34 +196,12 @@ export const textWysiwyg = ({
|
|||
);
|
||||
coordX = boundTextCoords.x;
|
||||
coordY = boundTextCoords.y;
|
||||
} else {
|
||||
coordX = Math.max(coordX, getContainerCoords(container).x);
|
||||
}
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
const font = getFontString(updatedTextElement);
|
||||
textElementHeight =
|
||||
updatedTextElement.lineHeight *
|
||||
wrapText(
|
||||
updatedTextElement.originalText,
|
||||
font,
|
||||
getBoundTextMaxWidth(container),
|
||||
).split("\n").length;
|
||||
textElementHeight = Math.max(
|
||||
textElementHeight,
|
||||
updatedTextElement.height,
|
||||
);
|
||||
}
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
|
@ -272,22 +246,12 @@ export const textWysiwyg = ({
|
|||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
|
@ -448,25 +412,6 @@ export const textWysiwyg = ({
|
|||
};
|
||||
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
}
|
||||
|
@ -696,20 +641,46 @@ export const textWysiwyg = ({
|
|||
// in that same tick.
|
||||
const target = event?.target;
|
||||
|
||||
const isTargetColorPicker =
|
||||
target instanceof HTMLInputElement &&
|
||||
target.closest(".color-picker-input") &&
|
||||
isWritableElement(target);
|
||||
const isTargetPickerTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("active-color");
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
if (target && isTargetColorPicker) {
|
||||
target.onblur = () => {
|
||||
editable.focus();
|
||||
|
||||
if (isTargetPickerTrigger) {
|
||||
const callback = (
|
||||
mutationList: MutationRecord[],
|
||||
observer: MutationObserver,
|
||||
) => {
|
||||
const radixIsRemoved = mutationList.find(
|
||||
(mutation) =>
|
||||
mutation.removedNodes.length > 0 &&
|
||||
(mutation.removedNodes[0] as HTMLElement).dataset
|
||||
?.radixPopperContentWrapper !== undefined,
|
||||
);
|
||||
|
||||
if (radixIsRemoved) {
|
||||
// should work without this in theory
|
||||
// and i think it does actually but radix probably somewhere,
|
||||
// somehow sets the focus elsewhere
|
||||
setTimeout(() => {
|
||||
editable.focus();
|
||||
});
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(callback);
|
||||
|
||||
observer.observe(document.querySelector(".excalidraw-container")!, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isTargetColorPicker) {
|
||||
if (!isTargetPickerTrigger) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
@ -717,16 +688,16 @@ export const textWysiwyg = ({
|
|||
|
||||
// prevent blur when changing properties from the menu
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const isTargetColorPicker =
|
||||
event.target instanceof HTMLInputElement &&
|
||||
event.target.closest(".color-picker-input") &&
|
||||
isWritableElement(event.target);
|
||||
const isTargetPickerTrigger =
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.classList.contains("active-color");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isTargetColorPicker
|
||||
isTargetPickerTrigger
|
||||
) {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
|
@ -740,7 +711,7 @@ export const textWysiwyg = ({
|
|||
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
||||
updateWysiwygStyle();
|
||||
const isColorPickerActive = !!document.activeElement?.closest(
|
||||
".color-picker-input",
|
||||
".color-picker-content",
|
||||
);
|
||||
if (!isColorPickerActive) {
|
||||
editable.focus();
|
||||
|
|
|
@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
|
|||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
|
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
|||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_FRAME = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||
e: true,
|
||||
s: true,
|
||||
|
@ -249,6 +257,10 @@ export const getTransformHandles = (
|
|||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameElement(element)) {
|
||||
omitSides = {
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
? DEFAULT_SPACING + 8
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
RoundnessType,
|
||||
} from "./types";
|
||||
|
||||
|
@ -45,6 +46,12 @@ export const isTextElement = (
|
|||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameElement => {
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
|
|
|
@ -54,6 +54,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
frameId: string | null;
|
||||
/** other elements that are bound to this element */
|
||||
boundElements:
|
||||
| readonly Readonly<{
|
||||
|
@ -100,6 +101,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
|
|||
"fileId"
|
||||
>;
|
||||
|
||||
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
type: "frame";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
|
@ -119,7 +125,8 @@ export type ExcalidrawElement =
|
|||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
|
@ -150,7 +157,8 @@ export type ExcalidrawBindableElement =
|
|||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue