feat: introduce frames (#6123)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2023-06-15 00:42:01 +08:00 committed by GitHub
parent 4d7d96eb7b
commit 81ebf82979
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 4563 additions and 480 deletions

View file

@ -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);

View file

@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
];
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLCanvasElement>,
event: React.PointerEvent<HTMLElement>,
) => {
return !event[KEYS.CTRL_OR_CMD];
};

View file

@ -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,

View file

@ -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,

View file

@ -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),
});
});
};

View file

@ -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;

View file

@ -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 },

View file

@ -12,6 +12,7 @@ import {
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawTextContainer,
ExcalidrawFrameElement,
} from "../element/types";
import {
arrayToMap,
@ -50,6 +51,7 @@ type ElementConstructorOpts = MarkOptional<
| "height"
| "angle"
| "groupIds"
| "frameId"
| "boundElements"
| "seed"
| "version"
@ -82,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0,
angle = 0,
groupIds = [],
frameId = null,
roundness = null,
boundElements = null,
link = null,
@ -106,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness,
opacity,
groupIds,
frameId,
roundness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
@ -126,6 +130,21 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> =>
_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: {
@ -158,6 +177,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;
@ -192,6 +212,7 @@ export const newTextElement = (
containerId: opts.containerId || null,
originalText: text,
lineHeight,
isFrameName: opts.isFrameName || false,
},
{},
);
@ -612,6 +633,10 @@ export const duplicateElements = (
: null;
}
if (clonedElement.frameId) {
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
}
clonedElements.push(clonedElement);
}

View file

@ -27,6 +27,7 @@ import {
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
isFreeDrawElement,
isImageElement,
isLinearElement,
@ -160,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 });
@ -877,45 +883,47 @@ 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),
},
false,
);
updateBoundElements(element, { simultaneouslyUpdated: elements });
const boundText = getBoundTextElement(element);
if (boundText && !isArrowElement(element)) {
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(
boundText,
element,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
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();
};

View file

@ -840,10 +840,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;

View file

@ -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

View file

@ -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 => {

View file

@ -53,6 +53,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<{
@ -98,6 +99,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.
*/
@ -117,7 +123,8 @@ export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement;
| ExcalidrawImageElement
| ExcalidrawFrameElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
@ -148,7 +155,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement;
| ExcalidrawImageElement
| ExcalidrawFrameElement;
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement