feat: Support labels for arrow 🔥 (#5723)
* feat: support arrow with text * render arrow -> clear rect-> render text * move bound text when linear elements move * fix centering cursor when linear element rotated * fix y coord when new line added and container has 3 points * update text position when 2nd point moved * support adding label on top of 2nd point when 3 points are present * change linear element editor shortcut to cmd+enter and fix tests * scale bound text points when resizing via bounding box * ohh yeah rotation works :) * fix coords when updating text properties * calculate new position after rotation always from original position * rotate the bound text by same angle as parent * don't rotate text and make sure dimensions and coords are always calculated from original point * hardcoding the text width for now * Move the linear element when bound text hit * Rotation working yaay * consider text element angle when editing * refactor * update x2 coords if needed when text updated * simplify * consider bound text to be part of bounding box when hit * show bounding box correctly when multiple element selected * fix typo * support rotating multiple elements * support multiple element resizing * shift bound text to mid point when odd points * Always render linear element handles inside editor after element rendered so point is visible for bound text * Delete bound text when point attached to it deleted * move bound to mid segement mid point when points are even * shift bound text when points nearby deleted and handle segment deletion * Resize working :) * more resize fixes * don't update cache-its breaking delete points, look for better soln * update mid point cache for bound elements when updated * introduce wrapping when resizing * wrap when resize for 2 pointer linear elements * support adding text for linear elements with more than 3 points * export to svg working :) * clip from nearest enclosing element with non transparent color if present when exporting and fill with correct color in canvas * fix snap * use visible elements * Make export to svg work with Mask :) * remove id * mask canvas linear element area where label is added * decide the position of bound text during render * fix coords when editing * fix multiple resize * update cache when bound text version changes * fix masking when rotated * render text in correct position in preview * remove unnecessary code * fix masking when rotating linear element * fix masking with zoom * fix mask in preview for export * fix offsets in export view * fix coords on svg export * fix mask when element rotated in svg * enable double-click to enter text * fix hint * Position cursor correctly and text dimensiosn when height of element is negative * don't allow 2 pointer linear element with bound text width to go beyond min width * code cleanup * fix freedraw * Add padding * don't show vertical align action for linear element containers * Add specs for getBoundTextElementPosition * more specs * move some utils to linearElementEditor.ts * remove only :p * check absoulte coods in test * Add test to hide vertical align for linear eleemnt with bound text * improve export preview * support labels only for arrows * spec * fix large texts * fix tests * fix zooming * enter line editor with cmd+double click * Allow points to move beyond min width/height for 2 pointer arrow with bound text * fix hint for line editing * attempt to fix arrow getting deselected * fix hint and shortcut * Add padding of 5px when creating bound text and add spec * Wrap bound text when arrow binding containers moved * Add spec * remove * set boundTextElementVersion to null if not present * dont use cache when version mismatch * Add a padding of 5px vertically when creating text * Add box sizing content box * Set bound elements when text element created to fix the padding * fix zooming in editor * fix zoom in export * remove globalCompositeOperation and use clearRect instead of fillRect
This commit is contained in:
parent
1933116261
commit
760fd7b3a6
@ -816,16 +816,19 @@ export const actionChangeVerticalAlign = register({
|
||||
value: VERTICAL_ALIGN.TOP,
|
||||
text: t("labels.alignTop"),
|
||||
icon: <TextAlignTopIcon theme={appState.theme} />,
|
||||
testId: "align-top",
|
||||
},
|
||||
{
|
||||
value: VERTICAL_ALIGN.MIDDLE,
|
||||
text: t("labels.centerVertically"),
|
||||
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
||||
testId: "align-middle",
|
||||
},
|
||||
{
|
||||
value: VERTICAL_ALIGN.BOTTOM,
|
||||
text: t("labels.alignBottom"),
|
||||
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
||||
testId: "align-bottom",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(elements, appState, (element) => {
|
||||
|
@ -25,11 +25,12 @@ import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import "./Actions.scss";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { shouldAllowVerticalAlign } from "../element/textElement";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@ -125,10 +126,8 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{targetElements.some(
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{shouldAllowVerticalAlign(targetElements) &&
|
||||
renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
|
@ -126,6 +126,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBindingElement,
|
||||
isBindingElementType,
|
||||
isBoundToContainer,
|
||||
@ -254,6 +255,7 @@ import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getTextBindableContainerAtPosition,
|
||||
isValidTextContainer,
|
||||
@ -2049,23 +2051,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
const selectedElement = selectedElements[0];
|
||||
|
||||
if (isLinearElement(selectedElement)) {
|
||||
if (
|
||||
!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElement,
|
||||
this.scene,
|
||||
),
|
||||
});
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
if (isLinearElement(selectedElement)) {
|
||||
if (
|
||||
!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElement,
|
||||
this.scene,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
isTextElement(selectedElement) ||
|
||||
@ -2075,9 +2077,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!isTextElement(selectedElement)) {
|
||||
container = selectedElement as ExcalidrawTextContainer;
|
||||
}
|
||||
const midPoint = getContainerCenter(selectedElement, this.state);
|
||||
const sceneX = midPoint.x;
|
||||
const sceneY = midPoint.y;
|
||||
this.startTextEditing({
|
||||
sceneX: selectedElement.x + selectedElement.width / 2,
|
||||
sceneY: selectedElement.y + selectedElement.height / 2,
|
||||
sceneX,
|
||||
sceneY,
|
||||
container,
|
||||
});
|
||||
event.preventDefault();
|
||||
@ -2521,7 +2526,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||
}
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
if (
|
||||
!existingTextElement &&
|
||||
shouldBindToContainer &&
|
||||
container &&
|
||||
!isArrowElement(container)
|
||||
) {
|
||||
const fontString = {
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
@ -2574,6 +2584,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
locked: false,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: element.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
this.setState({ editingElement: element });
|
||||
|
||||
if (!existingTextElement) {
|
||||
@ -2625,8 +2643,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !== selectedElements[0].id
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.setState({
|
||||
@ -2635,8 +2654,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene,
|
||||
),
|
||||
});
|
||||
return;
|
||||
} else if (
|
||||
this.state.editingLinearElement &&
|
||||
this.state.editingLinearElement.elementId === selectedElements[0].id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
resetCursor(this.canvas);
|
||||
@ -2680,9 +2704,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
sceneY,
|
||||
);
|
||||
if (container) {
|
||||
if (hasBoundTextElement(container)) {
|
||||
sceneX = container.x + container.width / 2;
|
||||
sceneY = container.y + container.height / 2;
|
||||
if (isArrowElement(container) || hasBoundTextElement(container)) {
|
||||
const midPoint = getContainerCenter(container, this.state);
|
||||
|
||||
sceneX = midPoint.x;
|
||||
sceneY = midPoint.y;
|
||||
}
|
||||
}
|
||||
this.startTextEditing({
|
||||
@ -2783,6 +2809,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
||||
|
||||
if (gesture.pointers.has(event.pointerId)) {
|
||||
gesture.pointers.set(event.pointerId, {
|
||||
x: event.clientX,
|
||||
@ -3091,15 +3118,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
} else if (
|
||||
// if using cmd/ctrl, we're not dragging
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
(hitElement ||
|
||||
this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||
scenePointer,
|
||||
selectedElements,
|
||||
)) &&
|
||||
!hitElement?.locked
|
||||
!event[KEYS.CTRL_OR_CMD]
|
||||
) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
if (
|
||||
(hitElement ||
|
||||
this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||
scenePointer,
|
||||
selectedElements,
|
||||
)) &&
|
||||
!hitElement?.locked
|
||||
) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
} else {
|
||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||
}
|
||||
@ -3209,6 +3239,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
linearElementEditor.elementId,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
@ -3249,6 +3281,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
)
|
||||
) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
} else if (
|
||||
boundTextElement &&
|
||||
hitTest(boundTextElement, this.state, scenePointerX, scenePointerY)
|
||||
) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -6305,8 +6342,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
container?: ExcalidrawTextContainer | null,
|
||||
) {
|
||||
if (container) {
|
||||
const elementCenterX = container.x + container.width / 2;
|
||||
const elementCenterY = container.y + container.height / 2;
|
||||
let elementCenterX = container.x + container.width / 2;
|
||||
let elementCenterY = container.y + container.height / 2;
|
||||
|
||||
const elementCenter = getContainerCenter(container, appState);
|
||||
if (elementCenter) {
|
||||
elementCenterX = elementCenter.x;
|
||||
elementCenterY = elementCenter.y;
|
||||
}
|
||||
const distanceToCenter = Math.hypot(
|
||||
x - elementCenterX,
|
||||
y - elementCenterY,
|
||||
|
@ -157,7 +157,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
|
||||
shortcuts={[
|
||||
getShortcutKey("CtrlOrCmd+Enter"),
|
||||
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
|
@ -26,6 +26,7 @@ import Scene from "../scene/Scene";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
|
||||
export type SuggestedBinding =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
@ -361,6 +362,10 @@ export const updateBoundElements = (
|
||||
endBinding,
|
||||
changedElement as ExcalidrawBindableElement,
|
||||
);
|
||||
const boundText = getBoundTextElement(element);
|
||||
if (boundText) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
@ -13,8 +14,15 @@ import {
|
||||
getShapeForElement,
|
||||
generateRoughOptions,
|
||||
} from "../renderer/renderElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { rescalePoints } from "../points";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
@ -24,17 +32,39 @@ type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export const getElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
): Bounds => {
|
||||
includeBoundText: boolean = false,
|
||||
): [number, number, number, number, number, number] => {
|
||||
if (isFreeDrawElement(element)) {
|
||||
return getFreeDrawElementAbsoluteCoords(element);
|
||||
} else if (isLinearElement(element)) {
|
||||
return getLinearElementAbsoluteCoords(element);
|
||||
return LinearElementEditor.getElementAbsoluteCoords(
|
||||
element,
|
||||
includeBoundText,
|
||||
);
|
||||
} else if (isTextElement(element)) {
|
||||
const container = getContainerElement(element);
|
||||
if (isArrowElement(container)) {
|
||||
const coords = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
element as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
return [
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.x + element.width,
|
||||
coords.y + element.height,
|
||||
coords.x + element.width / 2,
|
||||
coords.y + element.height / 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
return [
|
||||
element.x,
|
||||
element.y,
|
||||
element.x + element.width,
|
||||
element.y + element.height,
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
];
|
||||
};
|
||||
|
||||
@ -159,7 +189,7 @@ const getCubicBezierCurveBound = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
const getMinMaxXYFromCurvePathOps = (
|
||||
export const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
): [number, number, number, number] => {
|
||||
@ -230,59 +260,13 @@ const getBoundsFromPoints = (
|
||||
|
||||
const getFreeDrawElementAbsoluteCoords = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number, number, number] => {
|
||||
): [number, number, number, number, number, number] => {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
};
|
||||
|
||||
const getLinearElementAbsoluteCoords = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): [number, number, number, number] => {
|
||||
let coords: [number, number, number, number];
|
||||
|
||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
(limits, [x, y]) => {
|
||||
limits.minY = Math.min(limits.minY, y);
|
||||
limits.minX = Math.min(limits.minX, x);
|
||||
|
||||
limits.maxX = Math.max(limits.maxX, x);
|
||||
limits.maxY = Math.max(limits.maxY, y);
|
||||
|
||||
return limits;
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
coords = [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else {
|
||||
const shape = getShapeForElement(element)!;
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
|
||||
coords = [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
}
|
||||
|
||||
return coords;
|
||||
const x1 = minX + element.x;
|
||||
const y1 = minY + element.y;
|
||||
const x2 = maxX + element.x;
|
||||
const y2 = maxY + element.y;
|
||||
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
|
||||
};
|
||||
|
||||
export const getArrowheadPoints = (
|
||||
@ -420,7 +404,23 @@ const getLinearElementRotatedBounds = (
|
||||
cy,
|
||||
element.angle,
|
||||
);
|
||||
return [x, y, x, y];
|
||||
|
||||
let coords: [number, number, number, number] = [x, y, x, y];
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
element,
|
||||
[x, y, x, y],
|
||||
boundTextElement,
|
||||
);
|
||||
coords = [
|
||||
coordsWithBoundText[0],
|
||||
coordsWithBoundText[1],
|
||||
coordsWithBoundText[2],
|
||||
coordsWithBoundText[3],
|
||||
];
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
@ -429,8 +429,28 @@ const getLinearElementRotatedBounds = (
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = (x: number, y: number) =>
|
||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||
|
||||
return getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
let coords: [number, number, number, number] = [
|
||||
res[0],
|
||||
res[1],
|
||||
res[2],
|
||||
res[3],
|
||||
];
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
element,
|
||||
coords,
|
||||
boundTextElement,
|
||||
);
|
||||
coords = [
|
||||
coordsWithBoundText[0],
|
||||
coordsWithBoundText[1],
|
||||
coordsWithBoundText[2],
|
||||
coordsWithBoundText[3],
|
||||
];
|
||||
}
|
||||
return coords;
|
||||
};
|
||||
|
||||
// We could cache this stuff
|
||||
@ -439,9 +459,7 @@ export const getElementBounds = (
|
||||
): [number, number, number, number] => {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
|
@ -36,6 +36,7 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@ -72,6 +73,13 @@ export const hitTest = (
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
|
||||
if (isHittingBoundTextElement) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
||||
};
|
||||
|
||||
@ -83,6 +91,13 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
|
||||
// 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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
@ -95,7 +110,6 @@ export const isHittingElementNotConsideringBoundingBox = (
|
||||
point: Point,
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
|
||||
const check = isTextElement(element)
|
||||
? isStrictlyInside
|
||||
: isElementDraggableFromInside(element)
|
||||
@ -382,6 +396,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
|
||||
if (!getShapeForElement(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
|
||||
args.element,
|
||||
args.point,
|
||||
@ -434,8 +449,9 @@ const pointRelativeToElement = (
|
||||
pointTuple: Point,
|
||||
): [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(elementCoords);
|
||||
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,8 +482,8 @@ export const pointInAbsoluteCoords = (
|
||||
const relativizationToElementCenter = (
|
||||
element: ExcalidrawElement,
|
||||
): GA.Transform => {
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter(elementCoords);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
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(
|
||||
@ -524,8 +540,8 @@ export const determineFocusPoint = (
|
||||
adjecentPoint: Point,
|
||||
): Point => {
|
||||
if (focus === 0) {
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter(elementCoords);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
return GAPoint.toTuple(center);
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
PointBinding,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import {
|
||||
distance2d,
|
||||
@ -19,7 +20,11 @@ import {
|
||||
arePointsEqual,
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import { getElementPointsCoords } from "./bounds";
|
||||
import {
|
||||
getCurvePathOps,
|
||||
getElementPointsCoords,
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
import { Point, AppState, PointerCoords } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import History from "../history";
|
||||
@ -33,6 +38,8 @@ import {
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
@ -40,7 +47,6 @@ const editorMidPointsCache: {
|
||||
points: (Point | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@ -257,6 +263,11 @@ export class LinearElementEditor {
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
@ -388,8 +399,14 @@ export class LinearElementEditor {
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
): typeof editorMidPointsCache["points"] => {
|
||||
// Since its not needed outside editor unless 2 pointer lines
|
||||
if (!appState.editingLinearElement && element.points.length > 2) {
|
||||
const boundText = getBoundTextElement(element);
|
||||
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
element.points.length > 2 &&
|
||||
!boundText
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
@ -661,7 +678,6 @@ export class LinearElementEditor {
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint) {
|
||||
@ -1055,7 +1071,6 @@ export class LinearElementEditor {
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
@ -1223,7 +1238,6 @@ export class LinearElementEditor {
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
const dY = prevCenterY - nextCenterY;
|
||||
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
|
||||
|
||||
mutateElement(element, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
@ -1258,6 +1272,207 @@ export class LinearElementEditor {
|
||||
|
||||
return rotatePoint([width, height], [0, 0], -element.angle);
|
||||
}
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
): { x: number; y: number } => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
if (points.length < 2) {
|
||||
mutateElement(boundTextElement, { isDeleted: true });
|
||||
}
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if (element.points.length % 2 === 1) {
|
||||
const index = Math.floor(element.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[index],
|
||||
);
|
||||
x = midPoint[0] - boundTextElement.width / 2;
|
||||
y = midPoint[1] - boundTextElement.height / 2;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
|
||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
||||
if (element.points.length === 2) {
|
||||
midSegmentMidpoint = centerPoint(points[0], points[1]);
|
||||
}
|
||||
if (
|
||||
!midSegmentMidpoint ||
|
||||
editorMidPointsCache.version !== element.version
|
||||
) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
);
|
||||
}
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
static getMinMaxXYWithBoundText = (
|
||||
element: ExcalidrawLinearElement,
|
||||
elementBounds: [number, number, number, number],
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
): [number, number, number, number, number, number] => {
|
||||
let [x1, y1, x2, y2] = elementBounds;
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const { x: boundTextX1, y: boundTextY1 } =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
);
|
||||
const boundTextX2 = boundTextX1 + boundTextElement.width;
|
||||
const boundTextY2 = boundTextY1 + boundTextElement.height;
|
||||
|
||||
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
|
||||
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
|
||||
|
||||
const counterRotateBoundTextTopLeft = rotatePoint(
|
||||
[boundTextX1, boundTextY1],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
);
|
||||
const counterRotateBoundTextTopRight = rotatePoint(
|
||||
[boundTextX2, boundTextY1],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
);
|
||||
const counterRotateBoundTextBottomLeft = rotatePoint(
|
||||
[boundTextX1, boundTextY2],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
);
|
||||
const counterRotateBoundTextBottomRight = rotatePoint(
|
||||
[boundTextX2, boundTextY2],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
);
|
||||
|
||||
if (
|
||||
topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
|
||||
topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
|
||||
) {
|
||||
x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
|
||||
x2 = Math.max(
|
||||
x2,
|
||||
Math.max(
|
||||
counterRotateBoundTextTopRight[0],
|
||||
counterRotateBoundTextBottomRight[0],
|
||||
),
|
||||
);
|
||||
y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
|
||||
|
||||
y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
|
||||
} else if (
|
||||
topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
|
||||
topLeftRotatedPoint[1] > topRightRotatedPoint[1]
|
||||
) {
|
||||
x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
|
||||
x2 = Math.max(
|
||||
x2,
|
||||
Math.max(
|
||||
counterRotateBoundTextTopLeft[0],
|
||||
counterRotateBoundTextTopRight[0],
|
||||
),
|
||||
);
|
||||
y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
|
||||
|
||||
y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
|
||||
} else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
|
||||
x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
|
||||
x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
|
||||
y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
|
||||
|
||||
y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
|
||||
} else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
|
||||
x1 = Math.min(
|
||||
x1,
|
||||
Math.min(
|
||||
counterRotateBoundTextTopRight[0],
|
||||
counterRotateBoundTextTopLeft[0],
|
||||
),
|
||||
);
|
||||
|
||||
x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
|
||||
y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
|
||||
y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
|
||||
}
|
||||
|
||||
return [x1, y1, x2, y2, cx, cy];
|
||||
};
|
||||
|
||||
static getElementAbsoluteCoords = (
|
||||
element: ExcalidrawLinearElement,
|
||||
includeBoundText: boolean = false,
|
||||
): [number, number, number, number, number, number] => {
|
||||
let coords: [number, number, number, number, number, number];
|
||||
let x1;
|
||||
let y1;
|
||||
let x2;
|
||||
let y2;
|
||||
if (element.points.length < 2 || !getShapeForElement(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
(limits, [x, y]) => {
|
||||
limits.minY = Math.min(limits.minY, y);
|
||||
limits.minX = Math.min(limits.minX, x);
|
||||
|
||||
limits.maxX = Math.max(limits.maxX, x);
|
||||
limits.maxY = Math.max(limits.maxY, y);
|
||||
|
||||
return limits;
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
x1 = minX + element.x;
|
||||
y1 = minY + element.y;
|
||||
x2 = maxX + element.x;
|
||||
y2 = maxY + element.y;
|
||||
} else {
|
||||
const shape = getShapeForElement(element)!;
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
x1 = minX + element.x;
|
||||
y1 = minY + element.y;
|
||||
x2 = maxX + element.x;
|
||||
y2 = maxY + element.y;
|
||||
}
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
coords = [x1, y1, x2, y2, cx, cy];
|
||||
|
||||
if (!includeBoundText) {
|
||||
return coords;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
coords = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
element,
|
||||
[x1, y1, x2, y2],
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
|
||||
return coords;
|
||||
};
|
||||
}
|
||||
|
||||
const normalizeSelectedPoints = (
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawTextContainer,
|
||||
} from "../element/types";
|
||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
@ -22,6 +22,8 @@ import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
@ -29,6 +31,7 @@ import {
|
||||
wrapText,
|
||||
} from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@ -131,7 +134,7 @@ export const newTextElement = (
|
||||
fontFamily: FontFamilyValues;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId?: ExcalidrawRectangleElement["id"];
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const text = normalizeText(opts.text);
|
||||
@ -231,16 +234,21 @@ 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 - BOUND_TEXT_PADDING * 2) {
|
||||
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||
height = nextHeight + boundTextElementPadding * 2;
|
||||
}
|
||||
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
||||
width = nextWidth + BOUND_TEXT_PADDING * 2;
|
||||
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||
width = nextWidth + boundTextElementPadding * 2;
|
||||
}
|
||||
if (height !== containerDims.height || width !== containerDims.width) {
|
||||
if (
|
||||
!isArrowElement(container) &&
|
||||
(height !== containerDims.height || width !== containerDims.width)
|
||||
) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
@ -270,11 +278,35 @@ export const refreshTextDimensions = (
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
return getContainerDims(container).width - BOUND_TEXT_PADDING * 2;
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
return getContainerDims(container).height - BOUND_TEXT_PADDING * 2;
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
@ -12,6 +12,8 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@ -20,6 +22,7 @@ import {
|
||||
getCommonBoundingBox,
|
||||
} from "./bounds";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
@ -40,6 +43,7 @@ import {
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getBoundTextElementOffset,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
measureText,
|
||||
@ -75,6 +79,7 @@ export const transformElements = (
|
||||
pointerX,
|
||||
pointerY,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
pointerDownState.originalElements,
|
||||
);
|
||||
updateBoundElements(element);
|
||||
} else if (
|
||||
@ -142,6 +147,7 @@ const rotateSingleElement = (
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
@ -152,11 +158,17 @@ const rotateSingleElement = (
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
mutateElement(element, { angle });
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
mutateElement(element, { angle });
|
||||
if (boundTextElementId) {
|
||||
const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
|
||||
mutateElement(textElement!, { angle });
|
||||
const textElement = Scene.getScene(element)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
mutateElement(textElement, { angle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -412,10 +424,12 @@ export const resizeSingleElement = (
|
||||
};
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const boundTextElementPadding =
|
||||
getBoundTextElementOffset(boundTextElement);
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
boundTextElement,
|
||||
eleNewWidth - BOUND_TEXT_PADDING * 2,
|
||||
eleNewHeight - BOUND_TEXT_PADDING * 2,
|
||||
eleNewWidth - boundTextElementPadding * 2,
|
||||
eleNewHeight - boundTextElementPadding * 2,
|
||||
);
|
||||
if (nextFont === null) {
|
||||
return;
|
||||
@ -504,24 +518,36 @@ export const resizeSingleElement = (
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
|
||||
// Readjust points for linear elements
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
let rescaledElementPointsY;
|
||||
let rescaledPoints;
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
rescaledElementPointsY = rescalePoints(
|
||||
1,
|
||||
eleNewHeight,
|
||||
(stateAtResizeStart as ExcalidrawLinearElement).points,
|
||||
true,
|
||||
);
|
||||
|
||||
rescaledPoints = rescalePoints(
|
||||
0,
|
||||
eleNewWidth,
|
||||
rescaledElementPointsY,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
||||
|
||||
const resizedElement = {
|
||||
width: Math.abs(eleNewWidth),
|
||||
height: Math.abs(eleNewHeight),
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
...rescaledPoints,
|
||||
points: rescaledPoints,
|
||||
};
|
||||
|
||||
if ("scale" in element && "scale" in stateAtResizeStart) {
|
||||
@ -545,6 +571,7 @@ export const resizeSingleElement = (
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
||||
@ -667,7 +694,7 @@ const resizeMultipleElements = (
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
|
||||
if (boundTextElement || isTextElement(element.orig)) {
|
||||
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
|
||||
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
|
||||
const textMeasurements = measureFontSizeFromWH(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
width - optionalPadding,
|
||||
@ -697,6 +724,7 @@ const resizeMultipleElements = (
|
||||
|
||||
if (boundTextElement && boundTextUpdates) {
|
||||
mutateElement(boundTextElement, boundTextUpdates);
|
||||
|
||||
handleBindTextResize(element.latest, transformHandleType);
|
||||
}
|
||||
});
|
||||
@ -717,7 +745,7 @@ const rotateMultipleElements = (
|
||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
elements.forEach((element, index) => {
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
@ -737,13 +765,16 @@ const rotateMultipleElements = (
|
||||
});
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
Scene.getScene(element)!.getElement(boundTextElementId)!;
|
||||
mutateElement(textElement, {
|
||||
x: textElement.x + (rotatedCX - cx),
|
||||
y: textElement.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
const textElement = Scene.getScene(element)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
if (!isArrowElement(element)) {
|
||||
mutateElement(textElement, {
|
||||
x: textElement.x + (rotatedCX - cx),
|
||||
y: textElement.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
|
||||
pointerType: PointerType,
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0,
|
||||
zoom,
|
||||
pointerType,
|
||||
|
@ -13,11 +13,17 @@ import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isImageElement,
|
||||
isArrowElement,
|
||||
} from "./typeChecks";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { AppState } from "../types";
|
||||
import { isTextBindableContainer } from "./typeChecks";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
import { AppState } from "../types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { isImageElement } from "./typeChecks";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
@ -52,36 +58,47 @@ export const redrawTextBoundingBox = (
|
||||
let coordX = textElement.x;
|
||||
// Resize container and vertically center align the text
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let nextHeight = containerDims.height;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
metrics.height -
|
||||
BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > getMaxContainerHeight(container)) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
if (!isArrowElement(container)) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let nextHeight = containerDims.height;
|
||||
const boundTextElementPadding = getBoundTextElementOffset(textElement);
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y + boundTextElementPadding;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
metrics.height -
|
||||
boundTextElementPadding;
|
||||
} else {
|
||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > getMaxContainerHeight(container)) {
|
||||
nextHeight = metrics.height + boundTextElementPadding * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
}
|
||||
}
|
||||
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
coordX = container.x + boundTextElementPadding;
|
||||
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
coordX =
|
||||
container.x +
|
||||
containerDims.width -
|
||||
metrics.width -
|
||||
boundTextElementPadding;
|
||||
} else {
|
||||
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
coordX =
|
||||
container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
|
||||
mutateElement(container, { height: nextHeight });
|
||||
} else {
|
||||
coordX = container.x + container.width / 2 - metrics.width / 2;
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
coordY = centerY - (textElement.height + diffHeight) / 2;
|
||||
coordX = centerX - (textElement.width + diffWidth) / 2;
|
||||
}
|
||||
|
||||
mutateElement(container, { height: nextHeight });
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
@ -129,84 +146,113 @@ export const bindTextToShapeAfterDuplication = (
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement = Scene.getScene(element)!.getElement(
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
return;
|
||||
}
|
||||
let textElement = Scene.getScene(container)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElement;
|
||||
if (textElement && textElement.text) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
textElement = Scene.getScene(container)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElement;
|
||||
if (textElement && textElement.text) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
let containerHeight = element.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(element),
|
||||
);
|
||||
}
|
||||
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
let containerHeight = containerDims.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
element.width,
|
||||
maxWidth,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
|
||||
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
const diff = containerHeight - element.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n"
|
||||
? element.y - diff
|
||||
: element.y;
|
||||
mutateElement(element, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
});
|
||||
}
|
||||
|
||||
let updatedY;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
updatedY = element.y + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
updatedY = element.y + element.height / 2 - nextHeight / 2;
|
||||
}
|
||||
const updatedX =
|
||||
textElement.textAlign === TEXT_ALIGN.LEFT
|
||||
? element.x + BOUND_TEXT_PADDING
|
||||
: textElement.textAlign === TEXT_ALIGN.RIGHT
|
||||
? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
|
||||
: element.x + element.width / 2 - nextWidth / 2;
|
||||
mutateElement(textElement, {
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: updatedX,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
|
||||
const diff = containerHeight - containerDims.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
(transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n")
|
||||
? container.y - diff
|
||||
: container.y;
|
||||
mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
if (!isArrowElement(container)) {
|
||||
updateBoundTextPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const containerDims = getContainerDims(container);
|
||||
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
|
||||
let y;
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = container.y + boundTextElementPadding;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
boundTextElement.height -
|
||||
boundTextElementPadding;
|
||||
} else {
|
||||
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
|
||||
}
|
||||
const x =
|
||||
boundTextElement.textAlign === TEXT_ALIGN.LEFT
|
||||
? container.x + boundTextElementPadding
|
||||
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
|
||||
? container.x +
|
||||
containerDims.width -
|
||||
boundTextElement.width -
|
||||
boundTextElementPadding
|
||||
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
|
||||
|
||||
mutateElement(boundTextElement, { x, y });
|
||||
};
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
export const measureText = (
|
||||
text: string,
|
||||
@ -411,6 +457,7 @@ export const charWidth = (() => {
|
||||
})();
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
@ -491,7 +538,9 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
|
||||
|
||||
export const getContainerElement = (
|
||||
element:
|
||||
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
|
||||
| (ExcalidrawElement & {
|
||||
containerId: ExcalidrawElement["id"] | null;
|
||||
})
|
||||
| null,
|
||||
) => {
|
||||
if (!element) {
|
||||
@ -504,9 +553,106 @@ export const getContainerElement = (
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
const MIN_WIDTH = 300;
|
||||
if (isArrowElement(element)) {
|
||||
const width = Math.max(element.width, MIN_WIDTH);
|
||||
const height = element.height;
|
||||
return { width, height };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
if (!isArrowElement(container)) {
|
||||
return {
|
||||
x: container.x + container.width / 2,
|
||||
y: container.y + container.height / 2,
|
||||
};
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
|
||||
if (points.length % 2 === 1) {
|
||||
const index = Math.floor(container.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
container,
|
||||
container.points[index],
|
||||
);
|
||||
return { x: midPoint[0], y: midPoint[1] };
|
||||
}
|
||||
const index = container.points.length / 2 - 1;
|
||||
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
||||
container,
|
||||
appState,
|
||||
)[index];
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
};
|
||||
|
||||
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
|
||||
const container = getContainerElement(textElement);
|
||||
if (!container || isArrowElement(container)) {
|
||||
return textElement.angle;
|
||||
}
|
||||
return container.angle;
|
||||
};
|
||||
|
||||
export const getBoundTextElementOffset = (
|
||||
boundTextElement: ExcalidrawTextElement | null,
|
||||
) => {
|
||||
const container = getContainerElement(boundTextElement);
|
||||
if (!container) {
|
||||
return 0;
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
return BOUND_TEXT_PADDING * 8;
|
||||
}
|
||||
return BOUND_TEXT_PADDING;
|
||||
};
|
||||
|
||||
export const getBoundTextElementPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
const hasBoundContainer = isBoundToContainer(element);
|
||||
if (hasBoundContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (isTextElement(element) && isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
if (isArrowElement(element)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const getTextBindableContainerAtPosition = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@ -515,7 +661,9 @@ export const getTextBindableContainerAtPosition = (
|
||||
): ExcalidrawTextContainer | null => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (selectedElements.length === 1) {
|
||||
return selectedElements[0] as ExcalidrawTextContainer;
|
||||
return isTextBindableContainer(selectedElements[0], false)
|
||||
? selectedElements[0]
|
||||
: null;
|
||||
}
|
||||
let hitElement = null;
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
@ -524,7 +672,16 @@ export const getTextBindableContainerAtPosition = (
|
||||
continue;
|
||||
}
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
||||
if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
|
||||
x,
|
||||
y,
|
||||
])
|
||||
) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
}
|
||||
@ -538,6 +695,7 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
|
||||
element.type === "rectangle" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond" ||
|
||||
isImageElement(element)
|
||||
isImageElement(element) ||
|
||||
isArrowElement(element)
|
||||
);
|
||||
};
|
||||
|
@ -513,6 +513,9 @@ describe("textWysiwyg", () => {
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
@ -586,20 +589,19 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
it("shouldn't bind to non-text-bindable containers", async () => {
|
||||
const line = API.createElement({
|
||||
type: "line",
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
],
|
||||
});
|
||||
h.elements = [line];
|
||||
h.elements = [freedraw];
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2);
|
||||
mouse.clickAt(
|
||||
freedraw.x + freedraw.width / 2,
|
||||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
@ -613,20 +615,22 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(line.boundElements).toBe(null);
|
||||
expect(freedraw.boundElements).toBe(null);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
|
||||
});
|
||||
|
||||
it("shouldn't create text element when pressing 'Enter' key on non text bindable container", async () => {
|
||||
h.elements = [];
|
||||
const freeDraw = UI.createElement("freedraw", {
|
||||
width: 100,
|
||||
height: 50,
|
||||
["freedraw", "line"].forEach((type: any) => {
|
||||
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
|
||||
h.elements = [];
|
||||
const elemnet = UI.createElement(type, {
|
||||
width: 100,
|
||||
height: 50,
|
||||
});
|
||||
API.setSelectedElements([elemnet]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
API.setSelectedElements([freeDraw]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should'nt bind text to container when not double clicked on center", async () => {
|
||||
@ -1206,7 +1210,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
fireEvent.change(editor, { target: { value: " " } });
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toBeNull();
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
@ -6,11 +6,16 @@ import {
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isBoundToContainer, isTextElement } from "./typeChecks";
|
||||
import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "./types";
|
||||
import { AppState } from "../types";
|
||||
@ -18,8 +23,10 @@ import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
measureText,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
@ -30,7 +37,8 @@ import {
|
||||
} from "../actions/actionProperties";
|
||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { getMaxContainerWidth } from "./newElement";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
|
||||
const getTransform = (
|
||||
@ -108,7 +116,7 @@ export const textWysiwyg = ({
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
const coordX = updatedTextElement.x;
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
const container = getContainerElement(updatedTextElement);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
@ -119,6 +127,15 @@ export const textWysiwyg = ({
|
||||
// what is going to be used for unbounded text
|
||||
let height = updatedTextElement.height;
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
const boundTextCoords =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordX = boundTextCoords.x;
|
||||
coordY = boundTextCoords.y;
|
||||
}
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
editable,
|
||||
@ -138,16 +155,19 @@ export const textWysiwyg = ({
|
||||
if (!originalContainerHeight) {
|
||||
originalContainerHeight = containerDims.height;
|
||||
}
|
||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
||||
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxHeight = getMaxContainerHeight(container);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (height > maxHeight) {
|
||||
|
||||
if (!isArrowElement(container) && height > maxHeight) {
|
||||
const diff = Math.min(height - maxHeight, approxLineHeight);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
return;
|
||||
} else if (
|
||||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
containerDims.height > originalContainerHeight &&
|
||||
height < maxHeight
|
||||
) {
|
||||
@ -159,11 +179,16 @@ export const textWysiwyg = ({
|
||||
else {
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
coordY = container.y + containerDims.height / 2 - height / 2;
|
||||
if (!isArrowElement(container)) {
|
||||
coordY = container.y + containerDims.height / 2 - height / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y + containerDims.height - height - BOUND_TEXT_PADDING;
|
||||
container.y +
|
||||
containerDims.height -
|
||||
height -
|
||||
getBoundTextElementOffset(updatedTextElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -197,7 +222,7 @@ export const textWysiwyg = ({
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
const angle = container ? container.angle : updatedTextElement.angle;
|
||||
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
@ -209,7 +234,7 @@ export const textWysiwyg = ({
|
||||
transform: getTransform(
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
getTextElementAngle(updatedTextElement),
|
||||
appState,
|
||||
maxWidth,
|
||||
editorMaxHeight,
|
||||
@ -246,6 +271,8 @@ export const textWysiwyg = ({
|
||||
whiteSpace = "pre-wrap";
|
||||
wordBreak = "break-word";
|
||||
}
|
||||
const isContainerArrow = isArrowElement(getContainerElement(element));
|
||||
const background = isContainerArrow ? "#fff" : "transparent";
|
||||
Object.assign(editable.style, {
|
||||
position: "absolute",
|
||||
display: "inline-block",
|
||||
@ -256,7 +283,7 @@ export const textWysiwyg = ({
|
||||
border: 0,
|
||||
outline: 0,
|
||||
resize: "none",
|
||||
background: "transparent",
|
||||
background,
|
||||
overflow: "hidden",
|
||||
// must be specified because in dark mode canvas creates a stacking context
|
||||
zIndex: "var(--zIndex-wysiwyg)",
|
||||
@ -264,6 +291,7 @@ export const textWysiwyg = ({
|
||||
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||
whiteSpace,
|
||||
overflowWrap: "break-word",
|
||||
boxSizing: "content-box",
|
||||
});
|
||||
updateWysiwygStyle();
|
||||
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
PointerType,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
@ -81,7 +81,7 @@ const generateTransformHandle = (
|
||||
};
|
||||
|
||||
export const getTransformHandlesFromCoords = (
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||
angle: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
@ -97,8 +97,6 @@ export const getTransformHandlesFromCoords = (
|
||||
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const dashedLineMargin = margin / zoom.value;
|
||||
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
|
||||
|
||||
@ -256,7 +254,7 @@ export const getTransformHandles = (
|
||||
? DEFAULT_SPACING + 8
|
||||
: DEFAULT_SPACING;
|
||||
return getTransformHandlesFromCoords(
|
||||
getElementAbsoluteCoords(element),
|
||||
getElementAbsoluteCoords(element, true),
|
||||
element.angle,
|
||||
zoom,
|
||||
pointerType,
|
||||
|
@ -60,6 +60,12 @@ export const isLinearElement = (
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return element != null && element.type === "arrow";
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
): boolean => {
|
||||
@ -110,7 +116,8 @@ export const isTextBindableContainer = (
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image")
|
||||
element.type === "image" ||
|
||||
isArrowElement(element))
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -141,7 +141,8 @@ export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawArrowEleement;
|
||||
|
||||
export type ExcalidrawTextElementWithContainer = {
|
||||
containerId: ExcalidrawTextContainer["id"];
|
||||
@ -166,6 +167,11 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawArrowEleement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "arrow";
|
||||
}>;
|
||||
|
||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "freedraw";
|
||||
|
@ -237,7 +237,7 @@
|
||||
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
|
||||
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
|
||||
"rotate": "You can constrain angles by holding SHIFT while rotating",
|
||||
"lineEditor_info": "Double-click or press Enter to edit points",
|
||||
"lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
|
||||
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
|
||||
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
|
||||
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
||||
|
@ -51,6 +51,5 @@ export const rescalePoints = (
|
||||
return currentDimension === dimension ? value + translation : value;
|
||||
}) as [number, number],
|
||||
);
|
||||
|
||||
return nextPoints;
|
||||
};
|
||||
|
@ -6,12 +6,14 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "../element/types";
|
||||
import {
|
||||
isTextElement,
|
||||
isLinearElement,
|
||||
isFreeDrawElement,
|
||||
isInitializedImageElement,
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
getDiamondPoints,
|
||||
@ -37,7 +39,13 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||
import { getApproxLineHeight } from "../element/textElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementOffset,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
@ -80,6 +88,7 @@ export interface ExcalidrawElementWithCanvas {
|
||||
canvasZoom: Zoom["value"];
|
||||
canvasOffsetX: number;
|
||||
canvasOffsetY: number;
|
||||
boundTextElementVersion: number | null;
|
||||
}
|
||||
|
||||
const generateElementCanvas = (
|
||||
@ -148,6 +157,7 @@ const generateElementCanvas = (
|
||||
canvasZoom: zoom.value,
|
||||
canvasOffsetX,
|
||||
canvasOffsetY,
|
||||
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||
};
|
||||
};
|
||||
|
||||
@ -272,7 +282,7 @@ const drawElementOnCanvas = (
|
||||
: element.height / lines.length;
|
||||
let verticalOffset = element.height - element.baseline;
|
||||
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
verticalOffset = BOUND_TEXT_PADDING;
|
||||
verticalOffset = getBoundTextElementOffset(element);
|
||||
}
|
||||
|
||||
const horizontalOffset =
|
||||
@ -656,11 +666,13 @@ const generateElementWithCanvas = (
|
||||
prevElementWithCanvas &&
|
||||
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
||||
!renderConfig?.shouldCacheIgnoreZoom;
|
||||
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||
|
||||
if (
|
||||
!prevElementWithCanvas ||
|
||||
shouldRegenerateBecauseZoom ||
|
||||
prevElementWithCanvas.theme !== renderConfig.theme
|
||||
prevElementWithCanvas.theme !== renderConfig.theme ||
|
||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
|
||||
) {
|
||||
const elementWithCanvas = generateElementCanvas(
|
||||
element,
|
||||
@ -683,6 +695,7 @@ const drawElementFromCanvas = (
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
const padding = getCanvasPadding(element);
|
||||
const zoom = elementWithCanvas.canvasZoom;
|
||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
// Free draw elements will otherwise "shuffle" as the min x and y change
|
||||
@ -712,18 +725,93 @@ const drawElementFromCanvas = (
|
||||
(1 / window.devicePixelRatio) * scaleXFactor,
|
||||
(1 / window.devicePixelRatio) * scaleYFactor,
|
||||
);
|
||||
context.translate(cx * scaleXFactor, cy * scaleYFactor);
|
||||
context.rotate(element.angle * scaleXFactor * scaleYFactor);
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(-(x2 - x1) / 2) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
||||
(-(y2 - y1) / 2) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
|
||||
);
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
const tempCanvasContext = tempCanvas.getContext("2d")!;
|
||||
|
||||
// Take max dimensions of arrow canvas so that when canvas is rotated
|
||||
// the arrow doesn't get clipped
|
||||
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
||||
tempCanvas.width =
|
||||
maxDim * window.devicePixelRatio * zoom +
|
||||
padding * elementWithCanvas.canvasZoom * 10;
|
||||
tempCanvas.height =
|
||||
maxDim * window.devicePixelRatio * zoom +
|
||||
padding * elementWithCanvas.canvasZoom * 10;
|
||||
const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
|
||||
const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
|
||||
|
||||
tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2);
|
||||
tempCanvasContext.rotate(element.angle);
|
||||
|
||||
tempCanvasContext.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
-elementWithCanvas.canvas.width / 2,
|
||||
-elementWithCanvas.canvas.height / 2,
|
||||
elementWithCanvas.canvas.width,
|
||||
elementWithCanvas.canvas.height,
|
||||
);
|
||||
|
||||
const [, , , , boundTextCx, boundTextCy] =
|
||||
getElementAbsoluteCoords(boundTextElement);
|
||||
|
||||
tempCanvasContext.rotate(-element.angle);
|
||||
|
||||
// Shift the canvas to the center of the bound text element
|
||||
const shiftX =
|
||||
tempCanvas.width / 2 -
|
||||
(boundTextCx - x1) * window.devicePixelRatio * zoom -
|
||||
offsetX -
|
||||
padding * zoom;
|
||||
|
||||
const shiftY =
|
||||
tempCanvas.height / 2 -
|
||||
(boundTextCy - y1) * window.devicePixelRatio * zoom -
|
||||
offsetY -
|
||||
padding * zoom;
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
// Clear the bound text area
|
||||
tempCanvasContext.clearRect(
|
||||
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
zoom,
|
||||
);
|
||||
|
||||
context.translate(cx * scaleXFactor, cy * scaleYFactor);
|
||||
context.drawImage(
|
||||
tempCanvas,
|
||||
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
|
||||
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
|
||||
tempCanvas.width / zoom,
|
||||
tempCanvas.height / zoom,
|
||||
);
|
||||
} else {
|
||||
context.translate(cx * scaleXFactor, cy * scaleYFactor);
|
||||
|
||||
context.rotate(element.angle * scaleXFactor * scaleYFactor);
|
||||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(-(x2 - x1) / 2) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
||||
(-(y2 - y1) / 2) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
|
||||
// Clear the nested element we appended to the DOM
|
||||
@ -734,6 +822,7 @@ export const renderElement = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: RenderConfig,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const generator = rc.generator;
|
||||
switch (element.type) {
|
||||
@ -796,21 +885,94 @@ export const renderElement = (
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
|
||||
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
|
||||
const shiftX = (x2 - x1) / 2 - (element.x - x1);
|
||||
const shiftY = (y2 - y1) / 2 - (element.y - y1);
|
||||
let shiftX = (x2 - x1) / 2 - (element.x - x1);
|
||||
let shiftY = (y2 - y1) / 2 - (element.y - y1);
|
||||
if (isTextElement(element)) {
|
||||
const container = getContainerElement(element);
|
||||
if (isArrowElement(container)) {
|
||||
const boundTextCoords =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
element as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
|
||||
shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
|
||||
}
|
||||
}
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
if (element.type === "image") {
|
||||
context.scale(element.scale[0], element.scale[1]);
|
||||
}
|
||||
context.translate(-shiftX, -shiftY);
|
||||
|
||||
if (shouldResetImageFilter(element, renderConfig)) {
|
||||
context.filter = "none";
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
|
||||
const tempCanvasContext = tempCanvas.getContext("2d")!;
|
||||
|
||||
// Take max dimensions of arrow canvas so that when canvas is rotated
|
||||
// the arrow doesn't get clipped
|
||||
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
||||
const padding = getCanvasPadding(element);
|
||||
tempCanvas.width =
|
||||
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
|
||||
tempCanvas.height =
|
||||
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
|
||||
|
||||
tempCanvasContext.translate(
|
||||
tempCanvas.width / 2,
|
||||
tempCanvas.height / 2,
|
||||
);
|
||||
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
|
||||
|
||||
// Shift the canvas to left most point of the arrow
|
||||
shiftX = element.width / 2 - (element.x - x1);
|
||||
shiftY = element.height / 2 - (element.y - y1);
|
||||
|
||||
tempCanvasContext.rotate(element.angle);
|
||||
const tempRc = rough.canvas(tempCanvas);
|
||||
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||
|
||||
tempCanvasContext.translate(shiftX, shiftY);
|
||||
|
||||
tempCanvasContext.rotate(-element.angle);
|
||||
|
||||
// Shift the canvas to center of bound text
|
||||
const [, , , , boundTextCx, boundTextCy] =
|
||||
getElementAbsoluteCoords(boundTextElement);
|
||||
const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
|
||||
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
|
||||
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
|
||||
|
||||
// Clear the bound text area
|
||||
tempCanvasContext.clearRect(
|
||||
-boundTextElement.width / 2,
|
||||
-boundTextElement.height / 2,
|
||||
boundTextElement.width,
|
||||
boundTextElement.height,
|
||||
);
|
||||
context.scale(1 / appState.exportScale, 1 / appState.exportScale);
|
||||
context.drawImage(
|
||||
tempCanvas,
|
||||
-tempCanvas.width / 2,
|
||||
-tempCanvas.height / 2,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
);
|
||||
} else {
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
context.restore();
|
||||
// not exporting → optimized rendering (cache & render from element
|
||||
// canvases)
|
||||
@ -851,13 +1013,28 @@ export const renderElementToSvg = (
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
files: BinaryFiles,
|
||||
offsetX?: number,
|
||||
offsetY?: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
exportWithDarkMode?: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x2 - x1) / 2 - (element.x - x1);
|
||||
const cy = (y2 - y1) / 2 - (element.y - y1);
|
||||
let cx = (x2 - x1) / 2 - (element.x - x1);
|
||||
let cy = (y2 - y1) / 2 - (element.y - y1);
|
||||
if (isTextElement(element)) {
|
||||
const container = getContainerElement(element);
|
||||
if (isArrowElement(container)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
|
||||
|
||||
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
element as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
|
||||
cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
|
||||
offsetX = offsetX + boundTextCoords.x - element.x;
|
||||
offsetY = offsetY + boundTextCoords.y - element.y;
|
||||
}
|
||||
}
|
||||
const degree = (180 * element.angle) / Math.PI;
|
||||
const generator = rsvg.generator;
|
||||
|
||||
@ -904,8 +1081,54 @@ export const renderElementToSvg = (
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
const boundText = getBoundTextElement(element);
|
||||
const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
|
||||
if (boundText) {
|
||||
maskPath.setAttribute("id", `mask-${element.id}`);
|
||||
const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
|
||||
SVG_NS,
|
||||
"rect",
|
||||
);
|
||||
offsetX = offsetX || 0;
|
||||
offsetY = offsetY || 0;
|
||||
maskRectVisible.setAttribute("x", "0");
|
||||
maskRectVisible.setAttribute("y", "0");
|
||||
maskRectVisible.setAttribute("fill", "#fff");
|
||||
maskRectVisible.setAttribute(
|
||||
"width",
|
||||
`${element.width + 100 + offsetX}`,
|
||||
);
|
||||
maskRectVisible.setAttribute(
|
||||
"height",
|
||||
`${element.height + 100 + offsetY}`,
|
||||
);
|
||||
|
||||
maskPath.appendChild(maskRectVisible);
|
||||
const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
|
||||
SVG_NS,
|
||||
"rect",
|
||||
);
|
||||
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundText,
|
||||
);
|
||||
|
||||
const maskX = offsetX + boundTextCoords.x - element.x;
|
||||
const maskY = offsetY + boundTextCoords.y - element.y;
|
||||
|
||||
maskRectInvisible.setAttribute("x", maskX.toString());
|
||||
maskRectInvisible.setAttribute("y", maskY.toString());
|
||||
maskRectInvisible.setAttribute("fill", "#000");
|
||||
maskRectInvisible.setAttribute("width", `${boundText.width}`);
|
||||
maskRectInvisible.setAttribute("height", `${boundText.height}`);
|
||||
maskRectInvisible.setAttribute("opacity", "1");
|
||||
maskPath.appendChild(maskRectInvisible);
|
||||
}
|
||||
generateElementShape(element, generator);
|
||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
if (boundText) {
|
||||
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||
}
|
||||
const opacity = element.opacity / 100;
|
||||
group.setAttribute("stroke-linecap", "round");
|
||||
|
||||
@ -935,6 +1158,7 @@ export const renderElementToSvg = (
|
||||
group.appendChild(node);
|
||||
});
|
||||
root.appendChild(group);
|
||||
root.append(maskPath);
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
@ -1033,6 +1257,7 @@ export const renderElementToSvg = (
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${
|
||||
|
@ -348,7 +348,6 @@ export const _renderScene = ({
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.save();
|
||||
context.scale(scale, scale);
|
||||
|
||||
// When doing calculations based on canvas width we should used normalized one
|
||||
const normalizedCanvasWidth = canvas.width / scale;
|
||||
const normalizedCanvasHeight = canvas.height / scale;
|
||||
@ -410,7 +409,7 @@ export const _renderScene = ({
|
||||
undefined;
|
||||
visibleElements.forEach((element) => {
|
||||
try {
|
||||
renderElement(element, rc, context, renderConfig);
|
||||
renderElement(element, rc, context, renderConfig, appState);
|
||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||
// ShapeCache returns empty hence making sure that we get the
|
||||
// correct element from visible elements
|
||||
@ -440,7 +439,13 @@ export const _renderScene = ({
|
||||
// Paint selection element
|
||||
if (appState.selectionElement) {
|
||||
try {
|
||||
renderElement(appState.selectionElement, rc, context, renderConfig);
|
||||
renderElement(
|
||||
appState.selectionElement,
|
||||
rc,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
@ -453,6 +458,22 @@ export const _renderScene = ({
|
||||
renderBindingHighlight(context, renderConfig, suggestedBinding!);
|
||||
});
|
||||
}
|
||||
const locallySelectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||
// ShapeCache returns empty hence making sure that we get the
|
||||
// correct element from visible elements
|
||||
if (
|
||||
locallySelectedElements.length === 1 &&
|
||||
appState.editingLinearElement?.elementId === locallySelectedElements[0].id
|
||||
) {
|
||||
renderLinearPointHandles(
|
||||
context,
|
||||
appState,
|
||||
renderConfig,
|
||||
locallySelectedElements[0] as NonDeleted<ExcalidrawLinearElement>,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
appState.selectedLinearElement &&
|
||||
@ -466,7 +487,6 @@ export const _renderScene = ({
|
||||
!appState.multiElement &&
|
||||
!appState.editingLinearElement
|
||||
) {
|
||||
const locallySelectedElements = getSelectedElements(elements, appState);
|
||||
const showBoundingBox = shouldShowBoundingBox(
|
||||
locallySelectedElements,
|
||||
appState,
|
||||
@ -515,8 +535,8 @@ export const _renderScene = ({
|
||||
}
|
||||
|
||||
if (selectionColors.length) {
|
||||
const [elementX1, elementY1, elementX2, elementY2] =
|
||||
getElementAbsoluteCoords(element);
|
||||
const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
|
||||
getElementAbsoluteCoords(element, true);
|
||||
acc.push({
|
||||
angle: element.angle,
|
||||
elementX1,
|
||||
@ -525,10 +545,12 @@ export const _renderScene = ({
|
||||
elementY2,
|
||||
selectionColors,
|
||||
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
|
||||
cx,
|
||||
cy,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean }[]);
|
||||
}, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[]; dashed?: boolean; cx: number; cy: number }[]);
|
||||
|
||||
const addSelectionForGroupId = (groupId: GroupId) => {
|
||||
const groupElements = getElementsInGroup(elements, groupId);
|
||||
@ -540,8 +562,10 @@ export const _renderScene = ({
|
||||
elementX2,
|
||||
elementY1,
|
||||
elementY2,
|
||||
selectionColors: [selectionColor],
|
||||
selectionColors: [oc.black],
|
||||
dashed: true,
|
||||
cx: elementX1 + (elementX2 - elementX1) / 2,
|
||||
cy: elementY1 + (elementY2 - elementY1) / 2,
|
||||
});
|
||||
};
|
||||
|
||||
@ -600,7 +624,7 @@ export const _renderScene = ({
|
||||
context.lineWidth = lineWidth;
|
||||
context.setLineDash(initialLineDash);
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0,
|
||||
renderConfig.zoom,
|
||||
"mouse",
|
||||
@ -861,6 +885,8 @@ const renderSelectionBorder = (
|
||||
elementY2: number;
|
||||
selectionColors: string[];
|
||||
dashed?: boolean;
|
||||
cx: number;
|
||||
cy: number;
|
||||
},
|
||||
padding = DEFAULT_SPACING * 2,
|
||||
) => {
|
||||
@ -871,6 +897,8 @@ const renderSelectionBorder = (
|
||||
elementX2,
|
||||
elementY2,
|
||||
selectionColors,
|
||||
cx,
|
||||
cy,
|
||||
dashed,
|
||||
} = elementProperties;
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
@ -900,8 +928,8 @@ const renderSelectionBorder = (
|
||||
elementY1 - linePadding,
|
||||
elementWidth + linePadding * 2,
|
||||
elementHeight + linePadding * 2,
|
||||
elementX1 + elementWidth / 2,
|
||||
elementY1 + elementHeight / 2,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
@ -1117,7 +1145,7 @@ export const renderSceneToSvg = (
|
||||
return;
|
||||
}
|
||||
// render elements
|
||||
elements.forEach((element) => {
|
||||
elements.forEach((element, index) => {
|
||||
if (!element.isDeleted) {
|
||||
try {
|
||||
renderElementToSvg(
|
||||
|
@ -109,6 +109,9 @@ export class API {
|
||||
fileId?: T extends "image" ? string : never;
|
||||
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
||||
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
||||
endBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["endBinding"]
|
||||
: never;
|
||||
}): T extends "arrow" | "line"
|
||||
? ExcalidrawLinearElement
|
||||
: T extends "freedraw"
|
||||
|
@ -1,20 +1,30 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
} from "../element/types";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { centerPoint } from "../math";
|
||||
import { reseed } from "../random";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { Keyboard, Pointer } from "./helpers/ui";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Point } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { queryByText } from "@testing-library/react";
|
||||
import { queryByTestId, queryByText } from "@testing-library/react";
|
||||
import { resize, rotate } from "./utils";
|
||||
import { getBoundTextElementPosition, wrapText } from "../element/textElement";
|
||||
import { getMaxContainerWidth } from "../element/newElement";
|
||||
import * as textElementUtils from "../element/textElement";
|
||||
|
||||
const renderScene = jest.spyOn(Renderer, "renderScene");
|
||||
|
||||
const { h } = window;
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
describe("Test Linear Elements", () => {
|
||||
let container: HTMLElement;
|
||||
@ -44,23 +54,23 @@ describe("Test Linear Elements", () => {
|
||||
strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp",
|
||||
roughness: ExcalidrawLinearElement["roughness"] = 0,
|
||||
) => {
|
||||
h.elements = [
|
||||
API.createElement({
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
type,
|
||||
roughness,
|
||||
points: [
|
||||
[0, 0],
|
||||
[p2[0] - p1[0], p2[1] - p1[1]],
|
||||
],
|
||||
strokeSharpness,
|
||||
}),
|
||||
];
|
||||
const line = API.createElement({
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
type,
|
||||
roughness,
|
||||
points: [
|
||||
[0, 0],
|
||||
[p2[0] - p1[0], p2[1] - p1[1]],
|
||||
],
|
||||
strokeSharpness,
|
||||
});
|
||||
h.elements = [line];
|
||||
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
return line;
|
||||
};
|
||||
|
||||
const createThreePointerLinearElement = (
|
||||
@ -70,23 +80,23 @@ describe("Test Linear Elements", () => {
|
||||
) => {
|
||||
//dragging line from midpoint
|
||||
const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
|
||||
h.elements = [
|
||||
API.createElement({
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p3[0] - p1[0],
|
||||
height: 0,
|
||||
type,
|
||||
roughness,
|
||||
points: [
|
||||
[0, 0],
|
||||
[p3[0], p3[1]],
|
||||
[p2[0] - p1[0], p2[1] - p1[1]],
|
||||
],
|
||||
strokeSharpness,
|
||||
}),
|
||||
];
|
||||
const line = API.createElement({
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p3[0] - p1[0],
|
||||
height: 0,
|
||||
type,
|
||||
roughness,
|
||||
points: [
|
||||
[0, 0],
|
||||
[p3[0], p3[1]],
|
||||
[p2[0] - p1[0], p2[1] - p1[1]],
|
||||
],
|
||||
strokeSharpness,
|
||||
});
|
||||
h.elements = [line];
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
return line;
|
||||
};
|
||||
|
||||
const enterLineEditingMode = (
|
||||
@ -98,7 +108,9 @@ describe("Test Linear Elements", () => {
|
||||
} else {
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
}
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
||||
};
|
||||
|
||||
@ -216,6 +228,16 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should enter line editor when using double clicked with ctrl key", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
describe("Inside editor", () => {
|
||||
it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
@ -358,8 +380,8 @@ describe("Test Linear Elements", () => {
|
||||
let line: ExcalidrawLinearElement;
|
||||
|
||||
beforeEach(() => {
|
||||
createThreePointerLinearElement("line");
|
||||
line = h.elements[0] as ExcalidrawLinearElement;
|
||||
line = createThreePointerLinearElement("line");
|
||||
|
||||
expect(line.points.length).toEqual(3);
|
||||
|
||||
enterLineEditingMode(line);
|
||||
@ -478,7 +500,7 @@ describe("Test Linear Elements", () => {
|
||||
// delete 3rd point
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderScene).toHaveBeenCalledTimes(21);
|
||||
expect(renderScene).toHaveBeenCalledTimes(22);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
line,
|
||||
@ -503,8 +525,7 @@ describe("Test Linear Elements", () => {
|
||||
let line: ExcalidrawLinearElement;
|
||||
|
||||
beforeEach(() => {
|
||||
createThreePointerLinearElement("line", "round");
|
||||
line = h.elements[0] as ExcalidrawLinearElement;
|
||||
line = createThreePointerLinearElement("line", "round");
|
||||
expect(line.points.length).toEqual(3);
|
||||
|
||||
enterLineEditingMode(line);
|
||||
@ -667,7 +688,6 @@ describe("Test Linear Elements", () => {
|
||||
fillStyle: "solid",
|
||||
}),
|
||||
];
|
||||
const origPoints = line.points.map((point) => [...point]);
|
||||
const dragEndPositionOffset = [100, 100] as const;
|
||||
API.setSelectedElements([line]);
|
||||
enterLineEditingMode(line, true);
|
||||
@ -682,11 +702,457 @@ describe("Test Linear Elements", () => {
|
||||
0,
|
||||
],
|
||||
Array [
|
||||
${origPoints[1][0] - dragEndPositionOffset[0]},
|
||||
${origPoints[1][1] - dragEndPositionOffset[1]},
|
||||
-60,
|
||||
-100,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test bound text element", () => {
|
||||
const DEFAULT_TEXT = "Online whiteboard collaboration made easy";
|
||||
|
||||
const createBoundTextElement = (
|
||||
text: string,
|
||||
container: ExcalidrawLinearElement,
|
||||
) => {
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: wrapText(text, font, getMaxContainerWidth(container)),
|
||||
containerId: container.id,
|
||||
width: 30,
|
||||
height: 20,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
container = {
|
||||
...container,
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
};
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
h.elements.forEach((element) => {
|
||||
if (element.id === container.id) {
|
||||
elements.push(container);
|
||||
} else {
|
||||
elements.push(element);
|
||||
}
|
||||
});
|
||||
const updatedTextElement = { ...textElement, originalText: text };
|
||||
h.elements = [...elements, updatedTextElement];
|
||||
return { textElement: updatedTextElement, container };
|
||||
};
|
||||
|
||||
describe("Test getBoundTextElementPosition", () => {
|
||||
it("should return correct position for 2 pointer arrow", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
const { textElement, container } = createBoundTextElement(
|
||||
DEFAULT_TEXT,
|
||||
arrow,
|
||||
);
|
||||
const position = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 25,
|
||||
"y": 10,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return correct position for arrow with odd points", () => {
|
||||
createThreePointerLinearElement("arrow", "round");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
const { textElement, container } = createBoundTextElement(
|
||||
DEFAULT_TEXT,
|
||||
arrow,
|
||||
);
|
||||
|
||||
const position = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return correct position for arrow with even points", () => {
|
||||
createThreePointerLinearElement("arrow", "round");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
const { textElement, container } = createBoundTextElement(
|
||||
DEFAULT_TEXT,
|
||||
arrow,
|
||||
);
|
||||
enterLineEditingMode(container);
|
||||
// This is the expected midpoint for line with round edge
|
||||
// hence hardcoding it so if later some bug is introduced
|
||||
// this will fail and we can fix it
|
||||
const firstSegmentMidpoint: Point = [
|
||||
55.9697848965255, 47.442326230998205,
|
||||
];
|
||||
// drag line from first segment midpoint
|
||||
drag(firstSegmentMidpoint, [
|
||||
firstSegmentMidpoint[0] + delta,
|
||||
firstSegmentMidpoint[1] + delta,
|
||||
]);
|
||||
|
||||
const position = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 85.82201843191861,
|
||||
"y": 75.63461309860818,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind text to arrow when double clicked", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(arrow.id);
|
||||
mouse.doubleClickAt(arrow.x, arrow.y);
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(arrow.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: DEFAULT_TEXT },
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(arrow.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
|
||||
const arrow = createTwoPointerLinearElement("arrow");
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(arrow.id);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(textElement.type).toBe("text");
|
||||
expect(textElement.containerId).toBe(arrow.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: DEFAULT_TEXT },
|
||||
});
|
||||
editor.blur();
|
||||
expect(arrow.boundElements).toStrictEqual([
|
||||
{ id: textElement.id, type: "text" },
|
||||
]);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not bind text to line when double clicked", async () => {
|
||||
const line = createTwoPointerLinearElement("line");
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
mouse.doubleClickAt(line.x, line.y);
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBeNull();
|
||||
expect(line.boundElements).toBeNull();
|
||||
});
|
||||
|
||||
it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => {
|
||||
createThreePointerLinearElement("arrow", "round");
|
||||
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
const { textElement, container } = createBoundTextElement(
|
||||
DEFAULT_TEXT,
|
||||
arrow,
|
||||
);
|
||||
|
||||
expect(container.angle).toBe(0);
|
||||
expect(textElement.angle).toBe(0);
|
||||
expect(getBoundTextElementPosition(arrow, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
20,
|
||||
105,
|
||||
80,
|
||||
55.45893770831013,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
|
||||
rotate(container, -35, 55);
|
||||
expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
|
||||
expect(textElement.angle).toBe(0);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 21.73926141863671,
|
||||
"y": 73.31003398390868,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
20,
|
||||
102.41961302274555,
|
||||
86.49012635273976,
|
||||
55.45893770831013,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
|
||||
createThreePointerLinearElement("arrow", "round");
|
||||
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
const { textElement, container } = createBoundTextElement(
|
||||
DEFAULT_TEXT,
|
||||
arrow,
|
||||
);
|
||||
expect(container.width).toBe(70);
|
||||
expect(container.height).toBe(50);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
20,
|
||||
105,
|
||||
80,
|
||||
55.45893770831013,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
|
||||
resize(container, "ne", [300, 200]);
|
||||
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 10,
|
||||
"width": 367,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 386.5,
|
||||
"y": 70,
|
||||
}
|
||||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`);
|
||||
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
60,
|
||||
391.8122896842806,
|
||||
70,
|
||||
205.9061448421403,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
const { textElement, container } = createBoundTextElement(
|
||||
DEFAULT_TEXT,
|
||||
arrow,
|
||||
);
|
||||
expect(container.width).toBe(40);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 25,
|
||||
"y": 10,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(container);
|
||||
|
||||
// Drag from last point
|
||||
drag(points[1], [points[1][0] + 300, points[1][1]]);
|
||||
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 0,
|
||||
"width": 340,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 189.5,
|
||||
"y": 20,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not render vertical align tool when element selected", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
createBoundTextElement(DEFAULT_TEXT, arrow);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(queryByTestId(container, "align-top")).toBeNull();
|
||||
expect(queryByTestId(container, "align-middle")).toBeNull();
|
||||
expect(queryByTestId(container, "align-bottom")).toBeNull();
|
||||
});
|
||||
|
||||
it("should wrap the bound text when arrow bound container moves", async () => {
|
||||
const rect = UI.createElement("rectangle", {
|
||||
x: 400,
|
||||
width: 200,
|
||||
height: 500,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 250,
|
||||
width: 400,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
mouse.select(arrow);
|
||||
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: DEFAULT_TEXT } });
|
||||
editor.blur();
|
||||
|
||||
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
expect(arrow.width).toBe(400);
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
`);
|
||||
const handleBindTextResizeSpy = jest.spyOn(
|
||||
textElementUtils,
|
||||
"handleBindTextResize",
|
||||
);
|
||||
|
||||
mouse.select(rect);
|
||||
mouse.downAt(rect.x, rect.y);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
|
||||
expect(arrow.width).toBe(170);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
h.elements[1],
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -27,3 +27,22 @@ export const resize = (
|
||||
mouse.up();
|
||||
});
|
||||
};
|
||||
|
||||
export const rotate = (
|
||||
element: ExcalidrawElement,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
keyboardModifiers: KeyboardModifiers = {},
|
||||
) => {
|
||||
mouse.select(element);
|
||||
const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!;
|
||||
const clientX = handle[0] + handle[2] / 2;
|
||||
const clientY = handle[1] + handle[3] / 2;
|
||||
|
||||
Keyboard.withModifierKeys(keyboardModifiers, () => {
|
||||
mouse.reset();
|
||||
mouse.down(clientX, clientY);
|
||||
mouse.move(clientX + deltaX, clientY + deltaY);
|
||||
mouse.up();
|
||||
});
|
||||
};
|
||||
|
@ -327,13 +327,12 @@ export const getShortcutKey = (shortcut: string): string => {
|
||||
.replace(/\bAlt\b/i, "Alt")
|
||||
.replace(/\bShift\b/i, "Shift")
|
||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
||||
|
||||
if (isDarwin) {
|
||||
return shortcut
|
||||
.replace(/\bCtrlOrCmd\b/i, "Cmd")
|
||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
||||
.replace(/\bAlt\b/i, "Option");
|
||||
}
|
||||
return shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl");
|
||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
||||
};
|
||||
|
||||
export const viewportCoordsToSceneCoords = (
|
||||
|
Loading…
x
Reference in New Issue
Block a user