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,
|
value: VERTICAL_ALIGN.TOP,
|
||||||
text: t("labels.alignTop"),
|
text: t("labels.alignTop"),
|
||||||
icon: <TextAlignTopIcon theme={appState.theme} />,
|
icon: <TextAlignTopIcon theme={appState.theme} />,
|
||||||
|
testId: "align-top",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: VERTICAL_ALIGN.MIDDLE,
|
value: VERTICAL_ALIGN.MIDDLE,
|
||||||
text: t("labels.centerVertically"),
|
text: t("labels.centerVertically"),
|
||||||
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
||||||
|
testId: "align-middle",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: VERTICAL_ALIGN.BOTTOM,
|
value: VERTICAL_ALIGN.BOTTOM,
|
||||||
text: t("labels.alignBottom"),
|
text: t("labels.alignBottom"),
|
||||||
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
||||||
|
testId: "align-bottom",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(elements, appState, (element) => {
|
value={getFormValue(elements, appState, (element) => {
|
||||||
|
@ -25,11 +25,12 @@ import Stack from "./Stack";
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
import { hasBoundTextElement } from "../element/typeChecks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { actionToggleZenMode } from "../actions";
|
import { actionToggleZenMode } from "../actions";
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
import { shouldAllowVerticalAlign } from "../element/textElement";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@ -125,10 +126,8 @@ export const SelectedShapeActions = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{targetElements.some(
|
{shouldAllowVerticalAlign(targetElements) &&
|
||||||
(element) =>
|
renderAction("changeVerticalAlign")}
|
||||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
|
||||||
) && renderAction("changeVerticalAlign")}
|
|
||||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||||
<>{renderAction("changeArrowhead")}</>
|
<>{renderAction("changeArrowhead")}</>
|
||||||
|
@ -126,6 +126,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
|
|||||||
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isArrowElement,
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isBindingElementType,
|
isBindingElementType,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
@ -254,6 +255,7 @@ import {
|
|||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
getApproxMinLineWidth,
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
|
getContainerCenter,
|
||||||
getContainerDims,
|
getContainerDims,
|
||||||
getTextBindableContainerAtPosition,
|
getTextBindableContainerAtPosition,
|
||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
@ -2049,10 +2051,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
const selectedElement = selectedElements[0];
|
const selectedElement = selectedElements[0];
|
||||||
|
if (event[KEYS.CTRL_OR_CMD]) {
|
||||||
if (isLinearElement(selectedElement)) {
|
if (isLinearElement(selectedElement)) {
|
||||||
if (
|
if (
|
||||||
!this.state.editingLinearElement ||
|
!this.state.editingLinearElement ||
|
||||||
@ -2067,6 +2068,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
isTextElement(selectedElement) ||
|
isTextElement(selectedElement) ||
|
||||||
isValidTextContainer(selectedElement)
|
isValidTextContainer(selectedElement)
|
||||||
@ -2075,9 +2077,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (!isTextElement(selectedElement)) {
|
if (!isTextElement(selectedElement)) {
|
||||||
container = selectedElement as ExcalidrawTextContainer;
|
container = selectedElement as ExcalidrawTextContainer;
|
||||||
}
|
}
|
||||||
|
const midPoint = getContainerCenter(selectedElement, this.state);
|
||||||
|
const sceneX = midPoint.x;
|
||||||
|
const sceneY = midPoint.y;
|
||||||
this.startTextEditing({
|
this.startTextEditing({
|
||||||
sceneX: selectedElement.x + selectedElement.width / 2,
|
sceneX,
|
||||||
sceneY: selectedElement.y + selectedElement.height / 2,
|
sceneY,
|
||||||
container,
|
container,
|
||||||
});
|
});
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -2521,7 +2526,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingTextElement && shouldBindToContainer && container) {
|
if (
|
||||||
|
!existingTextElement &&
|
||||||
|
shouldBindToContainer &&
|
||||||
|
container &&
|
||||||
|
!isArrowElement(container)
|
||||||
|
) {
|
||||||
const fontString = {
|
const fontString = {
|
||||||
fontSize: this.state.currentItemFontSize,
|
fontSize: this.state.currentItemFontSize,
|
||||||
fontFamily: this.state.currentItemFontFamily,
|
fontFamily: this.state.currentItemFontFamily,
|
||||||
@ -2574,6 +2584,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
locked: false,
|
locked: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!existingTextElement && shouldBindToContainer && container) {
|
||||||
|
mutateElement(container, {
|
||||||
|
boundElements: (container.boundElements || []).concat({
|
||||||
|
type: "text",
|
||||||
|
id: element.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
this.setState({ editingElement: element });
|
this.setState({ editingElement: element });
|
||||||
|
|
||||||
if (!existingTextElement) {
|
if (!existingTextElement) {
|
||||||
@ -2625,8 +2643,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||||
if (
|
if (
|
||||||
!this.state.editingLinearElement ||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
this.state.editingLinearElement.elementId !== selectedElements[0].id
|
(!this.state.editingLinearElement ||
|
||||||
|
this.state.editingLinearElement.elementId !== selectedElements[0].id)
|
||||||
) {
|
) {
|
||||||
this.history.resumeRecording();
|
this.history.resumeRecording();
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -2635,8 +2654,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene,
|
this.scene,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
|
} else if (
|
||||||
|
this.state.editingLinearElement &&
|
||||||
|
this.state.editingLinearElement.elementId === selectedElements[0].id
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCursor(this.canvas);
|
resetCursor(this.canvas);
|
||||||
@ -2680,9 +2704,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
sceneY,
|
sceneY,
|
||||||
);
|
);
|
||||||
if (container) {
|
if (container) {
|
||||||
if (hasBoundTextElement(container)) {
|
if (isArrowElement(container) || hasBoundTextElement(container)) {
|
||||||
sceneX = container.x + container.width / 2;
|
const midPoint = getContainerCenter(container, this.state);
|
||||||
sceneY = container.y + container.height / 2;
|
|
||||||
|
sceneX = midPoint.x;
|
||||||
|
sceneY = midPoint.y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.startTextEditing({
|
this.startTextEditing({
|
||||||
@ -2783,6 +2809,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
) => {
|
) => {
|
||||||
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
|
||||||
|
|
||||||
if (gesture.pointers.has(event.pointerId)) {
|
if (gesture.pointers.has(event.pointerId)) {
|
||||||
gesture.pointers.set(event.pointerId, {
|
gesture.pointers.set(event.pointerId, {
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
@ -3091,7 +3118,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
// if using cmd/ctrl, we're not dragging
|
// if using cmd/ctrl, we're not dragging
|
||||||
!event[KEYS.CTRL_OR_CMD] &&
|
!event[KEYS.CTRL_OR_CMD]
|
||||||
|
) {
|
||||||
|
if (
|
||||||
(hitElement ||
|
(hitElement ||
|
||||||
this.isHittingCommonBoundingBoxOfSelectedElements(
|
this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||||
scenePointer,
|
scenePointer,
|
||||||
@ -3100,6 +3129,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!hitElement?.locked
|
!hitElement?.locked
|
||||||
) {
|
) {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||||
}
|
}
|
||||||
@ -3209,6 +3239,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
linearElementEditor.elementId,
|
linearElementEditor.elementId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(element);
|
||||||
|
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -3249,6 +3281,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||||
|
} else if (
|
||||||
|
boundTextElement &&
|
||||||
|
hitTest(boundTextElement, this.state, scenePointerX, scenePointerY)
|
||||||
|
) {
|
||||||
|
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -6305,8 +6342,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
container?: ExcalidrawTextContainer | null,
|
container?: ExcalidrawTextContainer | null,
|
||||||
) {
|
) {
|
||||||
if (container) {
|
if (container) {
|
||||||
const elementCenterX = container.x + container.width / 2;
|
let elementCenterX = container.x + container.width / 2;
|
||||||
const elementCenterY = container.y + container.height / 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(
|
const distanceToCenter = Math.hypot(
|
||||||
x - elementCenterX,
|
x - elementCenterX,
|
||||||
y - elementCenterY,
|
y - elementCenterY,
|
||||||
|
@ -157,7 +157,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.editSelectedShape")}
|
label={t("helpDialog.editSelectedShape")}
|
||||||
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
|
shortcuts={[
|
||||||
|
getShortcutKey("CtrlOrCmd+Enter"),
|
||||||
|
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.textNewLine")}
|
label={t("helpDialog.textNewLine")}
|
||||||
|
@ -26,6 +26,7 @@ import Scene from "../scene/Scene";
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { arrayToMap, tupleToCoors } from "../utils";
|
import { arrayToMap, tupleToCoors } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
| NonDeleted<ExcalidrawBindableElement>
|
| NonDeleted<ExcalidrawBindableElement>
|
||||||
@ -361,6 +362,10 @@ export const updateBoundElements = (
|
|||||||
endBinding,
|
endBinding,
|
||||||
changedElement as ExcalidrawBindableElement,
|
changedElement as ExcalidrawBindableElement,
|
||||||
);
|
);
|
||||||
|
const boundText = getBoundTextElement(element);
|
||||||
|
if (boundText) {
|
||||||
|
handleBindTextResize(element, false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate } from "../math";
|
import { distance2d, rotate } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
@ -13,8 +14,15 @@ import {
|
|||||||
getShapeForElement,
|
getShapeForElement,
|
||||||
generateRoughOptions,
|
generateRoughOptions,
|
||||||
} from "../renderer/renderElement";
|
} from "../renderer/renderElement";
|
||||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isFreeDrawElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "./typeChecks";
|
||||||
import { rescalePoints } from "../points";
|
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
|
// x and y position of top left corner, x and y position of bottom right corner
|
||||||
export type Bounds = readonly [number, number, number, number];
|
export type 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.
|
// This set of functions retrieves the absolute position of the 4 points.
|
||||||
export const getElementAbsoluteCoords = (
|
export const getElementAbsoluteCoords = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): Bounds => {
|
includeBoundText: boolean = false,
|
||||||
|
): [number, number, number, number, number, number] => {
|
||||||
if (isFreeDrawElement(element)) {
|
if (isFreeDrawElement(element)) {
|
||||||
return getFreeDrawElementAbsoluteCoords(element);
|
return getFreeDrawElementAbsoluteCoords(element);
|
||||||
} else if (isLinearElement(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 [
|
return [
|
||||||
element.x,
|
element.x,
|
||||||
element.y,
|
element.y,
|
||||||
element.x + element.width,
|
element.x + element.width,
|
||||||
element.y + element.height,
|
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];
|
return [minX, minY, maxX, maxY];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMinMaxXYFromCurvePathOps = (
|
export const getMinMaxXYFromCurvePathOps = (
|
||||||
ops: Op[],
|
ops: Op[],
|
||||||
transformXY?: (x: number, y: number) => [number, number],
|
transformXY?: (x: number, y: number) => [number, number],
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
@ -230,59 +260,13 @@ const getBoundsFromPoints = (
|
|||||||
|
|
||||||
const getFreeDrawElementAbsoluteCoords = (
|
const getFreeDrawElementAbsoluteCoords = (
|
||||||
element: ExcalidrawFreeDrawElement,
|
element: ExcalidrawFreeDrawElement,
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number, number, number] => {
|
||||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
|
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
|
||||||
|
const x1 = minX + element.x;
|
||||||
return [
|
const y1 = minY + element.y;
|
||||||
minX + element.x,
|
const x2 = maxX + element.x;
|
||||||
minY + element.y,
|
const y2 = maxY + element.y;
|
||||||
maxX + element.x,
|
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getArrowheadPoints = (
|
export const getArrowheadPoints = (
|
||||||
@ -420,7 +404,23 @@ const getLinearElementRotatedBounds = (
|
|||||||
cy,
|
cy,
|
||||||
element.angle,
|
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
|
// first element is always the curve
|
||||||
@ -429,8 +429,28 @@ const getLinearElementRotatedBounds = (
|
|||||||
const ops = getCurvePathOps(shape);
|
const ops = getCurvePathOps(shape);
|
||||||
const transformXY = (x: number, y: number) =>
|
const transformXY = (x: number, y: number) =>
|
||||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||||
|
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||||
return 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
|
// We could cache this stuff
|
||||||
@ -439,9 +459,7 @@ export const getElementBounds = (
|
|||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
let bounds: [number, number, number, number];
|
let bounds: [number, number, number, number];
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
|
||||||
const cy = (y1 + y2) / 2;
|
|
||||||
if (isFreeDrawElement(element)) {
|
if (isFreeDrawElement(element)) {
|
||||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||||
element.points.map(([x, y]) =>
|
element.points.map(([x, y]) =>
|
||||||
|
@ -36,6 +36,7 @@ import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
|||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { isTransparent } from "../utils";
|
import { isTransparent } from "../utils";
|
||||||
import { shouldShowBoundingBox } from "./transformHandles";
|
import { shouldShowBoundingBox } from "./transformHandles";
|
||||||
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
const isElementDraggableFromInside = (
|
const isElementDraggableFromInside = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
@ -72,6 +73,13 @@ export const hitTest = (
|
|||||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
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);
|
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,6 +91,13 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
const threshold = 10 / appState.zoom.value;
|
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 (
|
return (
|
||||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
||||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||||
@ -95,7 +110,6 @@ export const isHittingElementNotConsideringBoundingBox = (
|
|||||||
point: Point,
|
point: Point,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const threshold = 10 / appState.zoom.value;
|
const threshold = 10 / appState.zoom.value;
|
||||||
|
|
||||||
const check = isTextElement(element)
|
const check = isTextElement(element)
|
||||||
? isStrictlyInside
|
? isStrictlyInside
|
||||||
: isElementDraggableFromInside(element)
|
: isElementDraggableFromInside(element)
|
||||||
@ -382,6 +396,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
|
|||||||
if (!getShapeForElement(element)) {
|
if (!getShapeForElement(element)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
|
const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
|
||||||
args.element,
|
args.element,
|
||||||
args.point,
|
args.point,
|
||||||
@ -434,8 +449,9 @@ const pointRelativeToElement = (
|
|||||||
pointTuple: Point,
|
pointTuple: Point,
|
||||||
): [GA.Point, GA.Point, number, number] => {
|
): [GA.Point, GA.Point, number, number] => {
|
||||||
const point = GAPoint.from(pointTuple);
|
const point = GAPoint.from(pointTuple);
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const elementCoords = getElementAbsoluteCoords(element);
|
const elementCoords = getElementAbsoluteCoords(element);
|
||||||
const center = coordsCenter(elementCoords);
|
const center = coordsCenter([x1, y1, x2, y2]);
|
||||||
// GA has angle orientation opposite to `rotate`
|
// GA has angle orientation opposite to `rotate`
|
||||||
const rotate = GATransform.rotation(center, element.angle);
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
const pointRotated = GATransform.apply(rotate, point);
|
const pointRotated = GATransform.apply(rotate, point);
|
||||||
@ -466,8 +482,8 @@ export const pointInAbsoluteCoords = (
|
|||||||
const relativizationToElementCenter = (
|
const relativizationToElementCenter = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): GA.Transform => {
|
): GA.Transform => {
|
||||||
const elementCoords = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const center = coordsCenter(elementCoords);
|
const center = coordsCenter([x1, y1, x2, y2]);
|
||||||
// GA has angle orientation opposite to `rotate`
|
// GA has angle orientation opposite to `rotate`
|
||||||
const rotate = GATransform.rotation(center, element.angle);
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
const translate = GA.reverse(
|
const translate = GA.reverse(
|
||||||
@ -524,8 +540,8 @@ export const determineFocusPoint = (
|
|||||||
adjecentPoint: Point,
|
adjecentPoint: Point,
|
||||||
): Point => {
|
): Point => {
|
||||||
if (focus === 0) {
|
if (focus === 0) {
|
||||||
const elementCoords = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const center = coordsCenter(elementCoords);
|
const center = coordsCenter([x1, y1, x2, y2]);
|
||||||
return GAPoint.toTuple(center);
|
return GAPoint.toTuple(center);
|
||||||
}
|
}
|
||||||
const relateToCenter = relativizationToElementCenter(element);
|
const relateToCenter = relativizationToElementCenter(element);
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
distance2d,
|
distance2d,
|
||||||
@ -19,7 +20,11 @@ import {
|
|||||||
arePointsEqual,
|
arePointsEqual,
|
||||||
} from "../math";
|
} from "../math";
|
||||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||||
import { getElementPointsCoords } from "./bounds";
|
import {
|
||||||
|
getCurvePathOps,
|
||||||
|
getElementPointsCoords,
|
||||||
|
getMinMaxXYFromCurvePathOps,
|
||||||
|
} from "./bounds";
|
||||||
import { Point, AppState, PointerCoords } from "../types";
|
import { Point, AppState, PointerCoords } from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import History from "../history";
|
import History from "../history";
|
||||||
@ -33,6 +38,8 @@ import {
|
|||||||
import { tupleToCoors } from "../utils";
|
import { tupleToCoors } from "../utils";
|
||||||
import { isBindingElement } from "./typeChecks";
|
import { isBindingElement } from "./typeChecks";
|
||||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||||
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
import { DRAGGING_THRESHOLD } from "../constants";
|
import { DRAGGING_THRESHOLD } from "../constants";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
@ -40,7 +47,6 @@ const editorMidPointsCache: {
|
|||||||
points: (Point | null)[];
|
points: (Point | null)[];
|
||||||
zoom: number | null;
|
zoom: number | null;
|
||||||
} = { version: null, points: [], zoom: null };
|
} = { version: null, points: [], zoom: null };
|
||||||
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public readonly elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_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
|
// suggest bindings for first and last point if selected
|
||||||
@ -388,8 +399,14 @@ export class LinearElementEditor {
|
|||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): typeof editorMidPointsCache["points"] => {
|
): typeof editorMidPointsCache["points"] => {
|
||||||
// Since its not needed outside editor unless 2 pointer lines
|
const boundText = getBoundTextElement(element);
|
||||||
if (!appState.editingLinearElement && element.points.length > 2) {
|
|
||||||
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
|
if (
|
||||||
|
!appState.editingLinearElement &&
|
||||||
|
element.points.length > 2 &&
|
||||||
|
!boundText
|
||||||
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -661,7 +678,6 @@ export class LinearElementEditor {
|
|||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
|
|
||||||
// if we clicked on a point, set the element as hitElement otherwise
|
// if we clicked on a point, set the element as hitElement otherwise
|
||||||
// it would get deselected if the point is outside the hitbox area
|
// it would get deselected if the point is outside the hitbox area
|
||||||
if (clickedPointIndex >= 0 || segmentMidpoint) {
|
if (clickedPointIndex >= 0 || segmentMidpoint) {
|
||||||
@ -1055,7 +1071,6 @@ export class LinearElementEditor {
|
|||||||
const offsetY = 0;
|
const offsetY = 0;
|
||||||
|
|
||||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1223,7 +1238,6 @@ export class LinearElementEditor {
|
|||||||
const dX = prevCenterX - nextCenterX;
|
const dX = prevCenterX - nextCenterX;
|
||||||
const dY = prevCenterY - nextCenterY;
|
const dY = prevCenterY - nextCenterY;
|
||||||
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
|
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
...otherUpdates,
|
...otherUpdates,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
@ -1258,6 +1272,207 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return rotatePoint([width, height], [0, 0], -element.angle);
|
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 = (
|
const normalizeSelectedPoints = (
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawTextContainer,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||||
import { randomInteger, randomId } from "../random";
|
import { randomInteger, randomId } from "../random";
|
||||||
@ -22,6 +22,8 @@ import { getElementAbsoluteCoords } from ".";
|
|||||||
import { adjustXYWithRotation } from "../math";
|
import { adjustXYWithRotation } from "../math";
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import {
|
import {
|
||||||
|
getBoundTextElement,
|
||||||
|
getBoundTextElementOffset,
|
||||||
getContainerDims,
|
getContainerDims,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
measureText,
|
measureText,
|
||||||
@ -29,6 +31,7 @@ import {
|
|||||||
wrapText,
|
wrapText,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||||
|
import { isArrowElement } from "./typeChecks";
|
||||||
|
|
||||||
type ElementConstructorOpts = MarkOptional<
|
type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
@ -131,7 +134,7 @@ export const newTextElement = (
|
|||||||
fontFamily: FontFamilyValues;
|
fontFamily: FontFamilyValues;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
verticalAlign: VerticalAlign;
|
verticalAlign: VerticalAlign;
|
||||||
containerId?: ExcalidrawRectangleElement["id"];
|
containerId?: ExcalidrawTextContainer["id"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): NonDeleted<ExcalidrawTextElement> => {
|
||||||
const text = normalizeText(opts.text);
|
const text = normalizeText(opts.text);
|
||||||
@ -231,16 +234,21 @@ const getAdjustedDimensions = (
|
|||||||
// make sure container dimensions are set properly when
|
// make sure container dimensions are set properly when
|
||||||
// text editor overflows beyond viewport dimensions
|
// text editor overflows beyond viewport dimensions
|
||||||
if (container) {
|
if (container) {
|
||||||
|
const boundTextElementPadding = getBoundTextElementOffset(element);
|
||||||
|
|
||||||
const containerDims = getContainerDims(container);
|
const containerDims = getContainerDims(container);
|
||||||
let height = containerDims.height;
|
let height = containerDims.height;
|
||||||
let width = containerDims.width;
|
let width = containerDims.width;
|
||||||
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||||
height = nextHeight + BOUND_TEXT_PADDING * 2;
|
height = nextHeight + boundTextElementPadding * 2;
|
||||||
}
|
}
|
||||||
if (nextWidth > width - BOUND_TEXT_PADDING * 2) {
|
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||||
width = nextWidth + BOUND_TEXT_PADDING * 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 });
|
mutateElement(container, { height, width });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -270,11 +278,35 @@ export const refreshTextDimensions = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
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) => {
|
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 = (
|
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 { rescalePoints } from "../points";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,6 +12,8 @@ import {
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@ -20,6 +22,7 @@ import {
|
|||||||
getCommonBoundingBox,
|
getCommonBoundingBox,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@ -40,6 +43,7 @@ import {
|
|||||||
getApproxMinLineWidth,
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
|
getBoundTextElementOffset,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
measureText,
|
measureText,
|
||||||
@ -75,6 +79,7 @@ export const transformElements = (
|
|||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
shouldRotateWithDiscreteAngle,
|
shouldRotateWithDiscreteAngle,
|
||||||
|
pointerDownState.originalElements,
|
||||||
);
|
);
|
||||||
updateBoundElements(element);
|
updateBoundElements(element);
|
||||||
} else if (
|
} else if (
|
||||||
@ -142,6 +147,7 @@ const rotateSingleElement = (
|
|||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
shouldRotateWithDiscreteAngle: boolean,
|
shouldRotateWithDiscreteAngle: boolean,
|
||||||
|
originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
@ -152,11 +158,17 @@ const rotateSingleElement = (
|
|||||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
angle = normalizeAngle(angle);
|
angle = normalizeAngle(angle);
|
||||||
mutateElement(element, { angle });
|
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
|
mutateElement(element, { angle });
|
||||||
if (boundTextElementId) {
|
if (boundTextElementId) {
|
||||||
const textElement = Scene.getScene(element)!.getElement(boundTextElementId);
|
const textElement = Scene.getScene(element)!.getElement(
|
||||||
mutateElement(textElement!, { angle });
|
boundTextElementId,
|
||||||
|
) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
|
if (!isArrowElement(element)) {
|
||||||
|
mutateElement(textElement, { angle });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -412,10 +424,12 @@ export const resizeSingleElement = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (shouldMaintainAspectRatio) {
|
if (shouldMaintainAspectRatio) {
|
||||||
|
const boundTextElementPadding =
|
||||||
|
getBoundTextElementOffset(boundTextElement);
|
||||||
const nextFont = measureFontSizeFromWH(
|
const nextFont = measureFontSizeFromWH(
|
||||||
boundTextElement,
|
boundTextElement,
|
||||||
eleNewWidth - BOUND_TEXT_PADDING * 2,
|
eleNewWidth - boundTextElementPadding * 2,
|
||||||
eleNewHeight - BOUND_TEXT_PADDING * 2,
|
eleNewHeight - boundTextElementPadding * 2,
|
||||||
);
|
);
|
||||||
if (nextFont === null) {
|
if (nextFont === null) {
|
||||||
return;
|
return;
|
||||||
@ -504,24 +518,36 @@ export const resizeSingleElement = (
|
|||||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||||
|
|
||||||
// Readjust points for linear elements
|
// Readjust points for linear elements
|
||||||
const rescaledPoints = rescalePointsInElement(
|
let rescaledElementPointsY;
|
||||||
stateAtResizeStart,
|
let rescaledPoints;
|
||||||
eleNewWidth,
|
|
||||||
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
|
rescaledElementPointsY = rescalePoints(
|
||||||
|
1,
|
||||||
eleNewHeight,
|
eleNewHeight,
|
||||||
|
(stateAtResizeStart as ExcalidrawLinearElement).points,
|
||||||
true,
|
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
|
// 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
|
// So we need to readjust (x,y) to be where the first point should be
|
||||||
const newOrigin = [...newTopLeft];
|
const newOrigin = [...newTopLeft];
|
||||||
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
||||||
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
||||||
|
|
||||||
const resizedElement = {
|
const resizedElement = {
|
||||||
width: Math.abs(eleNewWidth),
|
width: Math.abs(eleNewWidth),
|
||||||
height: Math.abs(eleNewHeight),
|
height: Math.abs(eleNewHeight),
|
||||||
x: newOrigin[0],
|
x: newOrigin[0],
|
||||||
y: newOrigin[1],
|
y: newOrigin[1],
|
||||||
...rescaledPoints,
|
points: rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("scale" in element && "scale" in stateAtResizeStart) {
|
if ("scale" in element && "scale" in stateAtResizeStart) {
|
||||||
@ -545,6 +571,7 @@ export const resizeSingleElement = (
|
|||||||
updateBoundElements(element, {
|
updateBoundElements(element, {
|
||||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||||
});
|
});
|
||||||
|
|
||||||
mutateElement(element, resizedElement);
|
mutateElement(element, resizedElement);
|
||||||
if (boundTextElement && boundTextFont) {
|
if (boundTextElement && boundTextFont) {
|
||||||
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
||||||
@ -667,7 +694,7 @@ const resizeMultipleElements = (
|
|||||||
const boundTextElement = getBoundTextElement(element.latest);
|
const boundTextElement = getBoundTextElement(element.latest);
|
||||||
|
|
||||||
if (boundTextElement || isTextElement(element.orig)) {
|
if (boundTextElement || isTextElement(element.orig)) {
|
||||||
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
|
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
|
||||||
const textMeasurements = measureFontSizeFromWH(
|
const textMeasurements = measureFontSizeFromWH(
|
||||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||||
width - optionalPadding,
|
width - optionalPadding,
|
||||||
@ -697,6 +724,7 @@ const resizeMultipleElements = (
|
|||||||
|
|
||||||
if (boundTextElement && boundTextUpdates) {
|
if (boundTextElement && boundTextUpdates) {
|
||||||
mutateElement(boundTextElement, boundTextUpdates);
|
mutateElement(boundTextElement, boundTextUpdates);
|
||||||
|
|
||||||
handleBindTextResize(element.latest, transformHandleType);
|
handleBindTextResize(element.latest, transformHandleType);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -717,7 +745,7 @@ const rotateMultipleElements = (
|
|||||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||||
}
|
}
|
||||||
elements.forEach((element, index) => {
|
elements.forEach((element) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
@ -737,14 +765,17 @@ const rotateMultipleElements = (
|
|||||||
});
|
});
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
if (boundTextElementId) {
|
if (boundTextElementId) {
|
||||||
const textElement =
|
const textElement = Scene.getScene(element)!.getElement(
|
||||||
Scene.getScene(element)!.getElement(boundTextElementId)!;
|
boundTextElementId,
|
||||||
|
) as ExcalidrawTextElementWithContainer;
|
||||||
|
if (!isArrowElement(element)) {
|
||||||
mutateElement(textElement, {
|
mutateElement(textElement, {
|
||||||
x: textElement.x + (rotatedCX - cx),
|
x: textElement.x + (rotatedCX - cx),
|
||||||
y: textElement.y + (rotatedCY - cy),
|
y: textElement.y + (rotatedCY - cy),
|
||||||
angle: normalizeAngle(centerAngle + origAngle),
|
angle: normalizeAngle(centerAngle + origAngle),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ export const getTransformHandleTypeFromCoords = (
|
|||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
const transformHandles = getTransformHandlesFromCoords(
|
const transformHandles = getTransformHandlesFromCoords(
|
||||||
[x1, y1, x2, y2],
|
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||||
0,
|
0,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
|
@ -13,11 +13,17 @@ import { MaybeTransformHandleType } from "./transformHandles";
|
|||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||||
|
import {
|
||||||
|
isBoundToContainer,
|
||||||
|
isImageElement,
|
||||||
|
isArrowElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { AppState } from "../types";
|
||||||
import { isTextBindableContainer } from "./typeChecks";
|
import { isTextBindableContainer } from "./typeChecks";
|
||||||
import { getElementAbsoluteCoords } from "../element";
|
import { getElementAbsoluteCoords } from "../element";
|
||||||
import { AppState } from "../types";
|
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { isImageElement } from "./typeChecks";
|
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
@ -52,36 +58,47 @@ export const redrawTextBoundingBox = (
|
|||||||
let coordX = textElement.x;
|
let coordX = textElement.x;
|
||||||
// Resize container and vertically center align the text
|
// Resize container and vertically center align the text
|
||||||
if (container) {
|
if (container) {
|
||||||
|
if (!isArrowElement(container)) {
|
||||||
const containerDims = getContainerDims(container);
|
const containerDims = getContainerDims(container);
|
||||||
let nextHeight = containerDims.height;
|
let nextHeight = containerDims.height;
|
||||||
|
const boundTextElementPadding = getBoundTextElementOffset(textElement);
|
||||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||||
coordY = container.y + BOUND_TEXT_PADDING;
|
coordY = container.y + boundTextElementPadding;
|
||||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
coordY =
|
coordY =
|
||||||
container.y +
|
container.y +
|
||||||
containerDims.height -
|
containerDims.height -
|
||||||
metrics.height -
|
metrics.height -
|
||||||
BOUND_TEXT_PADDING;
|
boundTextElementPadding;
|
||||||
} else {
|
} else {
|
||||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||||
if (metrics.height > getMaxContainerHeight(container)) {
|
if (metrics.height > getMaxContainerHeight(container)) {
|
||||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
nextHeight = metrics.height + boundTextElementPadding * 2;
|
||||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||||
coordX = container.x + BOUND_TEXT_PADDING;
|
coordX = container.x + boundTextElementPadding;
|
||||||
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||||
coordX =
|
coordX =
|
||||||
container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
|
container.x +
|
||||||
|
containerDims.width -
|
||||||
|
metrics.width -
|
||||||
|
boundTextElementPadding;
|
||||||
} else {
|
} else {
|
||||||
coordX = container.x + container.width / 2 - metrics.width / 2;
|
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight });
|
||||||
|
} else {
|
||||||
|
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(textElement, {
|
mutateElement(textElement, {
|
||||||
width: metrics.width,
|
width: metrics.width,
|
||||||
height: metrics.height,
|
height: metrics.height,
|
||||||
@ -129,84 +146,113 @@ export const bindTextToShapeAfterDuplication = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleBindTextResize = (
|
export const handleBindTextResize = (
|
||||||
element: NonDeletedExcalidrawElement,
|
container: NonDeletedExcalidrawElement,
|
||||||
transformHandleType: MaybeTransformHandleType,
|
transformHandleType: MaybeTransformHandleType,
|
||||||
) => {
|
) => {
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(container);
|
||||||
if (boundTextElementId) {
|
if (!boundTextElementId) {
|
||||||
const textElement = Scene.getScene(element)!.getElement(
|
return;
|
||||||
|
}
|
||||||
|
let textElement = Scene.getScene(container)!.getElement(
|
||||||
boundTextElementId,
|
boundTextElementId,
|
||||||
) as ExcalidrawTextElement;
|
) as ExcalidrawTextElement;
|
||||||
if (textElement && textElement.text) {
|
if (textElement && textElement.text) {
|
||||||
if (!element) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textElement = Scene.getScene(container)!.getElement(
|
||||||
|
boundTextElementId,
|
||||||
|
) as ExcalidrawTextElement;
|
||||||
let text = textElement.text;
|
let text = textElement.text;
|
||||||
let nextHeight = textElement.height;
|
let nextHeight = textElement.height;
|
||||||
let nextWidth = textElement.width;
|
let nextWidth = textElement.width;
|
||||||
let containerHeight = element.height;
|
const containerDims = getContainerDims(container);
|
||||||
|
const maxWidth = getMaxContainerWidth(container);
|
||||||
|
const maxHeight = getMaxContainerHeight(container);
|
||||||
|
let containerHeight = containerDims.height;
|
||||||
let nextBaseLine = textElement.baseline;
|
let nextBaseLine = textElement.baseline;
|
||||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||||
if (text) {
|
if (text) {
|
||||||
text = wrapText(
|
text = wrapText(
|
||||||
textElement.originalText,
|
textElement.originalText,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
getMaxContainerWidth(element),
|
maxWidth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dimensions = measureText(
|
const dimensions = measureText(
|
||||||
text,
|
text,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
element.width,
|
maxWidth,
|
||||||
);
|
);
|
||||||
nextHeight = dimensions.height;
|
nextHeight = dimensions.height;
|
||||||
nextWidth = dimensions.width;
|
nextWidth = dimensions.width;
|
||||||
nextBaseLine = dimensions.baseline;
|
nextBaseLine = dimensions.baseline;
|
||||||
}
|
}
|
||||||
// increase height in case text element height exceeds
|
// increase height in case text element height exceeds
|
||||||
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
|
if (nextHeight > maxHeight) {
|
||||||
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
|
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
|
||||||
const diff = containerHeight - element.height;
|
const diff = containerHeight - containerDims.height;
|
||||||
// fix the y coord when resizing from ne/nw/n
|
// fix the y coord when resizing from ne/nw/n
|
||||||
const updatedY =
|
const updatedY =
|
||||||
transformHandleType === "ne" ||
|
!isArrowElement(container) &&
|
||||||
|
(transformHandleType === "ne" ||
|
||||||
transformHandleType === "nw" ||
|
transformHandleType === "nw" ||
|
||||||
transformHandleType === "n"
|
transformHandleType === "n")
|
||||||
? element.y - diff
|
? container.y - diff
|
||||||
: element.y;
|
: container.y;
|
||||||
mutateElement(element, {
|
mutateElement(container, {
|
||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
y: updatedY,
|
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, {
|
mutateElement(textElement, {
|
||||||
text,
|
text,
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: updatedX,
|
|
||||||
y: updatedY,
|
|
||||||
baseline: nextBaseLine,
|
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
|
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||||
export const measureText = (
|
export const measureText = (
|
||||||
text: string,
|
text: string,
|
||||||
@ -411,6 +457,7 @@ export const charWidth = (() => {
|
|||||||
})();
|
})();
|
||||||
export const getApproxMinLineWidth = (font: FontString) => {
|
export const getApproxMinLineWidth = (font: FontString) => {
|
||||||
const maxCharWidth = getMaxCharWidth(font);
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
|
|
||||||
if (maxCharWidth === 0) {
|
if (maxCharWidth === 0) {
|
||||||
return (
|
return (
|
||||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||||
@ -491,7 +538,9 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
|
|||||||
|
|
||||||
export const getContainerElement = (
|
export const getContainerElement = (
|
||||||
element:
|
element:
|
||||||
| (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
|
| (ExcalidrawElement & {
|
||||||
|
containerId: ExcalidrawElement["id"] | null;
|
||||||
|
})
|
||||||
| null,
|
| null,
|
||||||
) => {
|
) => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@ -504,9 +553,106 @@ export const getContainerElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
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 };
|
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 = (
|
export const getTextBindableContainerAtPosition = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -515,7 +661,9 @@ export const getTextBindableContainerAtPosition = (
|
|||||||
): ExcalidrawTextContainer | null => {
|
): ExcalidrawTextContainer | null => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
return selectedElements[0] as ExcalidrawTextContainer;
|
return isTextBindableContainer(selectedElements[0], false)
|
||||||
|
? selectedElements[0]
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
let hitElement = null;
|
let hitElement = null;
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
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];
|
hitElement = elements[index];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -538,6 +695,7 @@ export const isValidTextContainer = (element: ExcalidrawElement) => {
|
|||||||
element.type === "rectangle" ||
|
element.type === "rectangle" ||
|
||||||
element.type === "ellipse" ||
|
element.type === "ellipse" ||
|
||||||
element.type === "diamond" ||
|
element.type === "diamond" ||
|
||||||
isImageElement(element)
|
isImageElement(element) ||
|
||||||
|
isArrowElement(element)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -513,6 +513,9 @@ describe("textWysiwyg", () => {
|
|||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
|
{ id: text.id, type: "text" },
|
||||||
|
]);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = document.querySelector(
|
const editor = document.querySelector(
|
||||||
".excalidraw-textEditorContainer > textarea",
|
".excalidraw-textEditorContainer > textarea",
|
||||||
@ -586,20 +589,19 @@ describe("textWysiwyg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shouldn't bind to non-text-bindable containers", async () => {
|
it("shouldn't bind to non-text-bindable containers", async () => {
|
||||||
const line = API.createElement({
|
const freedraw = API.createElement({
|
||||||
type: "line",
|
type: "freedraw",
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 0,
|
height: 0,
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[100, 0],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
h.elements = [line];
|
h.elements = [freedraw];
|
||||||
|
|
||||||
UI.clickTool("text");
|
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(
|
const editor = document.querySelector(
|
||||||
".excalidraw-textEditorContainer > textarea",
|
".excalidraw-textEditorContainer > textarea",
|
||||||
@ -613,21 +615,23 @@ describe("textWysiwyg", () => {
|
|||||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||||
editor.dispatchEvent(new Event("input"));
|
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].type).toBe("text");
|
||||||
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
|
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 () => {
|
["freedraw", "line"].forEach((type: any) => {
|
||||||
|
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
|
||||||
h.elements = [];
|
h.elements = [];
|
||||||
const freeDraw = UI.createElement("freedraw", {
|
const elemnet = UI.createElement(type, {
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 50,
|
height: 50,
|
||||||
});
|
});
|
||||||
API.setSelectedElements([freeDraw]);
|
API.setSelectedElements([elemnet]);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should'nt bind text to container when not double clicked on center", async () => {
|
it("should'nt bind text to container when not double clicked on center", async () => {
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
@ -1206,7 +1210,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
fireEvent.change(editor, { target: { value: " " } });
|
fireEvent.change(editor, { target: { value: " " } });
|
||||||
editor.blur();
|
editor.blur();
|
||||||
expect(rectangle.boundElements).toBeNull();
|
expect(rectangle.boundElements).toStrictEqual([]);
|
||||||
expect(h.elements[1].isDeleted).toBe(true);
|
expect(h.elements[1].isDeleted).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,11 +6,16 @@ import {
|
|||||||
isTestEnv,
|
isTestEnv,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { isBoundToContainer, isTextElement } from "./typeChecks";
|
import {
|
||||||
import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
isArrowElement,
|
||||||
|
isBoundToContainer,
|
||||||
|
isTextElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
@ -18,8 +23,10 @@ import { mutateElement } from "./mutateElement";
|
|||||||
import {
|
import {
|
||||||
getApproxLineHeight,
|
getApproxLineHeight,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
|
getBoundTextElementOffset,
|
||||||
getContainerDims,
|
getContainerDims,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
|
getTextElementAngle,
|
||||||
measureText,
|
measureText,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
wrapText,
|
wrapText,
|
||||||
@ -30,7 +37,8 @@ import {
|
|||||||
} from "../actions/actionProperties";
|
} from "../actions/actionProperties";
|
||||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||||
import App from "../components/App";
|
import App from "../components/App";
|
||||||
import { getMaxContainerWidth } from "./newElement";
|
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { parseClipboard } from "../clipboard";
|
import { parseClipboard } from "../clipboard";
|
||||||
|
|
||||||
const getTransform = (
|
const getTransform = (
|
||||||
@ -108,7 +116,7 @@ export const textWysiwyg = ({
|
|||||||
getFontString(updatedTextElement),
|
getFontString(updatedTextElement),
|
||||||
);
|
);
|
||||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||||
const coordX = updatedTextElement.x;
|
let coordX = updatedTextElement.x;
|
||||||
let coordY = updatedTextElement.y;
|
let coordY = updatedTextElement.y;
|
||||||
const container = getContainerElement(updatedTextElement);
|
const container = getContainerElement(updatedTextElement);
|
||||||
let maxWidth = updatedTextElement.width;
|
let maxWidth = updatedTextElement.width;
|
||||||
@ -119,6 +127,15 @@ export const textWysiwyg = ({
|
|||||||
// what is going to be used for unbounded text
|
// what is going to be used for unbounded text
|
||||||
let height = updatedTextElement.height;
|
let height = updatedTextElement.height;
|
||||||
if (container && updatedTextElement.containerId) {
|
if (container && updatedTextElement.containerId) {
|
||||||
|
if (isArrowElement(container)) {
|
||||||
|
const boundTextCoords =
|
||||||
|
LinearElementEditor.getBoundTextElementPosition(
|
||||||
|
container,
|
||||||
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
|
);
|
||||||
|
coordX = boundTextCoords.x;
|
||||||
|
coordY = boundTextCoords.y;
|
||||||
|
}
|
||||||
const propertiesUpdated = textPropertiesUpdated(
|
const propertiesUpdated = textPropertiesUpdated(
|
||||||
updatedTextElement,
|
updatedTextElement,
|
||||||
editable,
|
editable,
|
||||||
@ -138,16 +155,19 @@ export const textWysiwyg = ({
|
|||||||
if (!originalContainerHeight) {
|
if (!originalContainerHeight) {
|
||||||
originalContainerHeight = containerDims.height;
|
originalContainerHeight = containerDims.height;
|
||||||
}
|
}
|
||||||
maxWidth = containerDims.width - BOUND_TEXT_PADDING * 2;
|
maxWidth = getMaxContainerWidth(container);
|
||||||
maxHeight = containerDims.height - BOUND_TEXT_PADDING * 2;
|
maxHeight = getMaxContainerHeight(container);
|
||||||
|
|
||||||
// autogrow container height if text exceeds
|
// autogrow container height if text exceeds
|
||||||
if (height > maxHeight) {
|
|
||||||
|
if (!isArrowElement(container) && height > maxHeight) {
|
||||||
const diff = Math.min(height - maxHeight, approxLineHeight);
|
const diff = Math.min(height - maxHeight, approxLineHeight);
|
||||||
mutateElement(container, { height: containerDims.height + diff });
|
mutateElement(container, { height: containerDims.height + diff });
|
||||||
return;
|
return;
|
||||||
} else if (
|
} else if (
|
||||||
// autoshrink container height until original container height
|
// autoshrink container height until original container height
|
||||||
// is reached when text is removed
|
// is reached when text is removed
|
||||||
|
!isArrowElement(container) &&
|
||||||
containerDims.height > originalContainerHeight &&
|
containerDims.height > originalContainerHeight &&
|
||||||
height < maxHeight
|
height < maxHeight
|
||||||
) {
|
) {
|
||||||
@ -159,11 +179,16 @@ export const textWysiwyg = ({
|
|||||||
else {
|
else {
|
||||||
// vertically center align the text
|
// vertically center align the text
|
||||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||||
|
if (!isArrowElement(container)) {
|
||||||
coordY = container.y + containerDims.height / 2 - height / 2;
|
coordY = container.y + containerDims.height / 2 - height / 2;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
coordY =
|
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
|
// Make sure text editor height doesn't go beyond viewport
|
||||||
const editorMaxHeight =
|
const editorMaxHeight =
|
||||||
(appState.height - viewportY) / appState.zoom.value;
|
(appState.height - viewportY) / appState.zoom.value;
|
||||||
const angle = container ? container.angle : updatedTextElement.angle;
|
|
||||||
Object.assign(editable.style, {
|
Object.assign(editable.style, {
|
||||||
font: getFontString(updatedTextElement),
|
font: getFontString(updatedTextElement),
|
||||||
// must be defined *after* font ¯\_(ツ)_/¯
|
// must be defined *after* font ¯\_(ツ)_/¯
|
||||||
@ -209,7 +234,7 @@ export const textWysiwyg = ({
|
|||||||
transform: getTransform(
|
transform: getTransform(
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
angle,
|
getTextElementAngle(updatedTextElement),
|
||||||
appState,
|
appState,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
editorMaxHeight,
|
editorMaxHeight,
|
||||||
@ -246,6 +271,8 @@ export const textWysiwyg = ({
|
|||||||
whiteSpace = "pre-wrap";
|
whiteSpace = "pre-wrap";
|
||||||
wordBreak = "break-word";
|
wordBreak = "break-word";
|
||||||
}
|
}
|
||||||
|
const isContainerArrow = isArrowElement(getContainerElement(element));
|
||||||
|
const background = isContainerArrow ? "#fff" : "transparent";
|
||||||
Object.assign(editable.style, {
|
Object.assign(editable.style, {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
@ -256,7 +283,7 @@ export const textWysiwyg = ({
|
|||||||
border: 0,
|
border: 0,
|
||||||
outline: 0,
|
outline: 0,
|
||||||
resize: "none",
|
resize: "none",
|
||||||
background: "transparent",
|
background,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
// must be specified because in dark mode canvas creates a stacking context
|
// must be specified because in dark mode canvas creates a stacking context
|
||||||
zIndex: "var(--zIndex-wysiwyg)",
|
zIndex: "var(--zIndex-wysiwyg)",
|
||||||
@ -264,6 +291,7 @@ export const textWysiwyg = ({
|
|||||||
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||||
whiteSpace,
|
whiteSpace,
|
||||||
overflowWrap: "break-word",
|
overflowWrap: "break-word",
|
||||||
|
boxSizing: "content-box",
|
||||||
});
|
});
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
PointerType,
|
PointerType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import { rotate } from "../math";
|
import { rotate } from "../math";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
@ -81,7 +81,7 @@ const generateTransformHandle = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getTransformHandlesFromCoords = (
|
export const getTransformHandlesFromCoords = (
|
||||||
[x1, y1, x2, y2]: Bounds,
|
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||||
angle: number,
|
angle: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
@ -97,8 +97,6 @@ export const getTransformHandlesFromCoords = (
|
|||||||
|
|
||||||
const width = x2 - x1;
|
const width = x2 - x1;
|
||||||
const height = y2 - y1;
|
const height = y2 - y1;
|
||||||
const cx = (x1 + x2) / 2;
|
|
||||||
const cy = (y1 + y2) / 2;
|
|
||||||
const dashedLineMargin = margin / zoom.value;
|
const dashedLineMargin = margin / zoom.value;
|
||||||
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
|
const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value);
|
||||||
|
|
||||||
@ -256,7 +254,7 @@ export const getTransformHandles = (
|
|||||||
? DEFAULT_SPACING + 8
|
? DEFAULT_SPACING + 8
|
||||||
: DEFAULT_SPACING;
|
: DEFAULT_SPACING;
|
||||||
return getTransformHandlesFromCoords(
|
return getTransformHandlesFromCoords(
|
||||||
getElementAbsoluteCoords(element),
|
getElementAbsoluteCoords(element, true),
|
||||||
element.angle,
|
element.angle,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
|
@ -60,6 +60,12 @@ export const isLinearElement = (
|
|||||||
return element != null && isLinearElementType(element.type);
|
return element != null && isLinearElementType(element.type);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isArrowElement = (
|
||||||
|
element?: ExcalidrawElement | null,
|
||||||
|
): element is ExcalidrawLinearElement => {
|
||||||
|
return element != null && element.type === "arrow";
|
||||||
|
};
|
||||||
|
|
||||||
export const isLinearElementType = (
|
export const isLinearElementType = (
|
||||||
elementType: AppState["activeTool"]["type"],
|
elementType: AppState["activeTool"]["type"],
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@ -110,7 +116,8 @@ export const isTextBindableContainer = (
|
|||||||
(element.type === "rectangle" ||
|
(element.type === "rectangle" ||
|
||||||
element.type === "diamond" ||
|
element.type === "diamond" ||
|
||||||
element.type === "ellipse" ||
|
element.type === "ellipse" ||
|
||||||
element.type === "image")
|
element.type === "image" ||
|
||||||
|
isArrowElement(element))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -141,7 +141,8 @@ export type ExcalidrawTextContainer =
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement
|
| ExcalidrawEllipseElement
|
||||||
| ExcalidrawImageElement;
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawArrowEleement;
|
||||||
|
|
||||||
export type ExcalidrawTextElementWithContainer = {
|
export type ExcalidrawTextElementWithContainer = {
|
||||||
containerId: ExcalidrawTextContainer["id"];
|
containerId: ExcalidrawTextContainer["id"];
|
||||||
@ -166,6 +167,11 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||||||
endArrowhead: Arrowhead | null;
|
endArrowhead: Arrowhead | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ExcalidrawArrowEleement = ExcalidrawLinearElement &
|
||||||
|
Readonly<{
|
||||||
|
type: "arrow";
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
type: "freedraw";
|
type: "freedraw";
|
||||||
|
@ -237,7 +237,7 @@
|
|||||||
"resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
|
"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",
|
"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",
|
"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_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",
|
"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",
|
"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;
|
return currentDimension === dimension ? value + translation : value;
|
||||||
}) as [number, number],
|
}) as [number, number],
|
||||||
);
|
);
|
||||||
|
|
||||||
return nextPoints;
|
return nextPoints;
|
||||||
};
|
};
|
||||||
|
@ -6,12 +6,14 @@ import {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
|
isArrowElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
getDiamondPoints,
|
getDiamondPoints,
|
||||||
@ -37,7 +39,13 @@ import {
|
|||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
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
|
// 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
|
// as a temp hack to make images in dark theme look closer to original
|
||||||
@ -80,6 +88,7 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
canvasZoom: Zoom["value"];
|
canvasZoom: Zoom["value"];
|
||||||
canvasOffsetX: number;
|
canvasOffsetX: number;
|
||||||
canvasOffsetY: number;
|
canvasOffsetY: number;
|
||||||
|
boundTextElementVersion: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateElementCanvas = (
|
const generateElementCanvas = (
|
||||||
@ -148,6 +157,7 @@ const generateElementCanvas = (
|
|||||||
canvasZoom: zoom.value,
|
canvasZoom: zoom.value,
|
||||||
canvasOffsetX,
|
canvasOffsetX,
|
||||||
canvasOffsetY,
|
canvasOffsetY,
|
||||||
|
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -272,7 +282,7 @@ const drawElementOnCanvas = (
|
|||||||
: element.height / lines.length;
|
: element.height / lines.length;
|
||||||
let verticalOffset = element.height - element.baseline;
|
let verticalOffset = element.height - element.baseline;
|
||||||
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||||
verticalOffset = BOUND_TEXT_PADDING;
|
verticalOffset = getBoundTextElementOffset(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
const horizontalOffset =
|
const horizontalOffset =
|
||||||
@ -656,11 +666,13 @@ const generateElementWithCanvas = (
|
|||||||
prevElementWithCanvas &&
|
prevElementWithCanvas &&
|
||||||
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
||||||
!renderConfig?.shouldCacheIgnoreZoom;
|
!renderConfig?.shouldCacheIgnoreZoom;
|
||||||
|
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!prevElementWithCanvas ||
|
!prevElementWithCanvas ||
|
||||||
shouldRegenerateBecauseZoom ||
|
shouldRegenerateBecauseZoom ||
|
||||||
prevElementWithCanvas.theme !== renderConfig.theme
|
prevElementWithCanvas.theme !== renderConfig.theme ||
|
||||||
|
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
|
||||||
) {
|
) {
|
||||||
const elementWithCanvas = generateElementCanvas(
|
const elementWithCanvas = generateElementCanvas(
|
||||||
element,
|
element,
|
||||||
@ -683,6 +695,7 @@ const drawElementFromCanvas = (
|
|||||||
) => {
|
) => {
|
||||||
const element = elementWithCanvas.element;
|
const element = elementWithCanvas.element;
|
||||||
const padding = getCanvasPadding(element);
|
const padding = getCanvasPadding(element);
|
||||||
|
const zoom = elementWithCanvas.canvasZoom;
|
||||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
|
||||||
// Free draw elements will otherwise "shuffle" as the min x and y change
|
// Free draw elements will otherwise "shuffle" as the min x and y change
|
||||||
@ -712,7 +725,81 @@ const drawElementFromCanvas = (
|
|||||||
(1 / window.devicePixelRatio) * scaleXFactor,
|
(1 / window.devicePixelRatio) * scaleXFactor,
|
||||||
(1 / window.devicePixelRatio) * scaleYFactor,
|
(1 / window.devicePixelRatio) * scaleYFactor,
|
||||||
);
|
);
|
||||||
|
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));
|
||||||
|
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.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.rotate(element.angle * scaleXFactor * scaleYFactor);
|
||||||
|
|
||||||
context.drawImage(
|
context.drawImage(
|
||||||
@ -724,6 +811,7 @@ const drawElementFromCanvas = (
|
|||||||
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
|
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
|
||||||
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
|
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
// Clear the nested element we appended to the DOM
|
// Clear the nested element we appended to the DOM
|
||||||
@ -734,6 +822,7 @@ export const renderElement = (
|
|||||||
rc: RoughCanvas,
|
rc: RoughCanvas,
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: RenderConfig,
|
renderConfig: RenderConfig,
|
||||||
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
const generator = rc.generator;
|
const generator = rc.generator;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
@ -796,21 +885,94 @@ export const renderElement = (
|
|||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
|
const cx = (x1 + x2) / 2 + renderConfig.scrollX;
|
||||||
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
|
const cy = (y1 + y2) / 2 + renderConfig.scrollY;
|
||||||
const shiftX = (x2 - x1) / 2 - (element.x - x1);
|
let shiftX = (x2 - x1) / 2 - (element.x - x1);
|
||||||
const shiftY = (y2 - y1) / 2 - (element.y - y1);
|
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.save();
|
||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
context.rotate(element.angle);
|
|
||||||
if (element.type === "image") {
|
if (element.type === "image") {
|
||||||
context.scale(element.scale[0], element.scale[1]);
|
context.scale(element.scale[0], element.scale[1]);
|
||||||
}
|
}
|
||||||
context.translate(-shiftX, -shiftY);
|
|
||||||
|
|
||||||
if (shouldResetImageFilter(element, renderConfig)) {
|
if (shouldResetImageFilter(element, renderConfig)) {
|
||||||
context.filter = "none";
|
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();
|
context.restore();
|
||||||
// not exporting → optimized rendering (cache & render from element
|
// not exporting → optimized rendering (cache & render from element
|
||||||
// canvases)
|
// canvases)
|
||||||
@ -851,13 +1013,28 @@ export const renderElementToSvg = (
|
|||||||
rsvg: RoughSVG,
|
rsvg: RoughSVG,
|
||||||
svgRoot: SVGElement,
|
svgRoot: SVGElement,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
offsetX?: number,
|
offsetX: number,
|
||||||
offsetY?: number,
|
offsetY: number,
|
||||||
exportWithDarkMode?: boolean,
|
exportWithDarkMode?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x2 - x1) / 2 - (element.x - x1);
|
let cx = (x2 - x1) / 2 - (element.x - x1);
|
||||||
const cy = (y2 - y1) / 2 - (element.y - y1);
|
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 degree = (180 * element.angle) / Math.PI;
|
||||||
const generator = rsvg.generator;
|
const generator = rsvg.generator;
|
||||||
|
|
||||||
@ -904,8 +1081,54 @@ export const renderElementToSvg = (
|
|||||||
}
|
}
|
||||||
case "line":
|
case "line":
|
||||||
case "arrow": {
|
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);
|
generateElementShape(element, generator);
|
||||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
|
if (boundText) {
|
||||||
|
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||||
|
}
|
||||||
const opacity = element.opacity / 100;
|
const opacity = element.opacity / 100;
|
||||||
group.setAttribute("stroke-linecap", "round");
|
group.setAttribute("stroke-linecap", "round");
|
||||||
|
|
||||||
@ -935,6 +1158,7 @@ export const renderElementToSvg = (
|
|||||||
group.appendChild(node);
|
group.appendChild(node);
|
||||||
});
|
});
|
||||||
root.appendChild(group);
|
root.appendChild(group);
|
||||||
|
root.append(maskPath);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
@ -1033,6 +1257,7 @@ export const renderElementToSvg = (
|
|||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
node.setAttribute("fill-opacity", `${opacity}`);
|
node.setAttribute("fill-opacity", `${opacity}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
node.setAttribute(
|
node.setAttribute(
|
||||||
"transform",
|
"transform",
|
||||||
`translate(${offsetX || 0} ${
|
`translate(${offsetX || 0} ${
|
||||||
|
@ -348,7 +348,6 @@ export const _renderScene = ({
|
|||||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
context.save();
|
context.save();
|
||||||
context.scale(scale, scale);
|
context.scale(scale, scale);
|
||||||
|
|
||||||
// When doing calculations based on canvas width we should used normalized one
|
// When doing calculations based on canvas width we should used normalized one
|
||||||
const normalizedCanvasWidth = canvas.width / scale;
|
const normalizedCanvasWidth = canvas.width / scale;
|
||||||
const normalizedCanvasHeight = canvas.height / scale;
|
const normalizedCanvasHeight = canvas.height / scale;
|
||||||
@ -410,7 +409,7 @@ export const _renderScene = ({
|
|||||||
undefined;
|
undefined;
|
||||||
visibleElements.forEach((element) => {
|
visibleElements.forEach((element) => {
|
||||||
try {
|
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
|
// 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
|
// ShapeCache returns empty hence making sure that we get the
|
||||||
// correct element from visible elements
|
// correct element from visible elements
|
||||||
@ -440,7 +439,13 @@ export const _renderScene = ({
|
|||||||
// Paint selection element
|
// Paint selection element
|
||||||
if (appState.selectionElement) {
|
if (appState.selectionElement) {
|
||||||
try {
|
try {
|
||||||
renderElement(appState.selectionElement, rc, context, renderConfig);
|
renderElement(
|
||||||
|
appState.selectionElement,
|
||||||
|
rc,
|
||||||
|
context,
|
||||||
|
renderConfig,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
@ -453,6 +458,22 @@ export const _renderScene = ({
|
|||||||
renderBindingHighlight(context, renderConfig, suggestedBinding!);
|
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 (
|
if (
|
||||||
appState.selectedLinearElement &&
|
appState.selectedLinearElement &&
|
||||||
@ -466,7 +487,6 @@ export const _renderScene = ({
|
|||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.editingLinearElement
|
!appState.editingLinearElement
|
||||||
) {
|
) {
|
||||||
const locallySelectedElements = getSelectedElements(elements, appState);
|
|
||||||
const showBoundingBox = shouldShowBoundingBox(
|
const showBoundingBox = shouldShowBoundingBox(
|
||||||
locallySelectedElements,
|
locallySelectedElements,
|
||||||
appState,
|
appState,
|
||||||
@ -515,8 +535,8 @@ export const _renderScene = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectionColors.length) {
|
if (selectionColors.length) {
|
||||||
const [elementX1, elementY1, elementX2, elementY2] =
|
const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
|
||||||
getElementAbsoluteCoords(element);
|
getElementAbsoluteCoords(element, true);
|
||||||
acc.push({
|
acc.push({
|
||||||
angle: element.angle,
|
angle: element.angle,
|
||||||
elementX1,
|
elementX1,
|
||||||
@ -525,10 +545,12 @@ export const _renderScene = ({
|
|||||||
elementY2,
|
elementY2,
|
||||||
selectionColors,
|
selectionColors,
|
||||||
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
|
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return acc;
|
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 addSelectionForGroupId = (groupId: GroupId) => {
|
||||||
const groupElements = getElementsInGroup(elements, groupId);
|
const groupElements = getElementsInGroup(elements, groupId);
|
||||||
@ -540,8 +562,10 @@ export const _renderScene = ({
|
|||||||
elementX2,
|
elementX2,
|
||||||
elementY1,
|
elementY1,
|
||||||
elementY2,
|
elementY2,
|
||||||
selectionColors: [selectionColor],
|
selectionColors: [oc.black],
|
||||||
dashed: true,
|
dashed: true,
|
||||||
|
cx: elementX1 + (elementX2 - elementX1) / 2,
|
||||||
|
cy: elementY1 + (elementY2 - elementY1) / 2,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -600,7 +624,7 @@ export const _renderScene = ({
|
|||||||
context.lineWidth = lineWidth;
|
context.lineWidth = lineWidth;
|
||||||
context.setLineDash(initialLineDash);
|
context.setLineDash(initialLineDash);
|
||||||
const transformHandles = getTransformHandlesFromCoords(
|
const transformHandles = getTransformHandlesFromCoords(
|
||||||
[x1, y1, x2, y2],
|
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||||
0,
|
0,
|
||||||
renderConfig.zoom,
|
renderConfig.zoom,
|
||||||
"mouse",
|
"mouse",
|
||||||
@ -861,6 +885,8 @@ const renderSelectionBorder = (
|
|||||||
elementY2: number;
|
elementY2: number;
|
||||||
selectionColors: string[];
|
selectionColors: string[];
|
||||||
dashed?: boolean;
|
dashed?: boolean;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
},
|
},
|
||||||
padding = DEFAULT_SPACING * 2,
|
padding = DEFAULT_SPACING * 2,
|
||||||
) => {
|
) => {
|
||||||
@ -871,6 +897,8 @@ const renderSelectionBorder = (
|
|||||||
elementX2,
|
elementX2,
|
||||||
elementY2,
|
elementY2,
|
||||||
selectionColors,
|
selectionColors,
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
dashed,
|
dashed,
|
||||||
} = elementProperties;
|
} = elementProperties;
|
||||||
const elementWidth = elementX2 - elementX1;
|
const elementWidth = elementX2 - elementX1;
|
||||||
@ -900,8 +928,8 @@ const renderSelectionBorder = (
|
|||||||
elementY1 - linePadding,
|
elementY1 - linePadding,
|
||||||
elementWidth + linePadding * 2,
|
elementWidth + linePadding * 2,
|
||||||
elementHeight + linePadding * 2,
|
elementHeight + linePadding * 2,
|
||||||
elementX1 + elementWidth / 2,
|
cx,
|
||||||
elementY1 + elementHeight / 2,
|
cy,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1117,7 +1145,7 @@ export const renderSceneToSvg = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// render elements
|
// render elements
|
||||||
elements.forEach((element) => {
|
elements.forEach((element, index) => {
|
||||||
if (!element.isDeleted) {
|
if (!element.isDeleted) {
|
||||||
try {
|
try {
|
||||||
renderElementToSvg(
|
renderElementToSvg(
|
||||||
|
@ -109,6 +109,9 @@ export class API {
|
|||||||
fileId?: T extends "image" ? string : never;
|
fileId?: T extends "image" ? string : never;
|
||||||
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
||||||
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
||||||
|
endBinding?: T extends "arrow"
|
||||||
|
? ExcalidrawLinearElement["endBinding"]
|
||||||
|
: never;
|
||||||
}): T extends "arrow" | "line"
|
}): T extends "arrow" | "line"
|
||||||
? ExcalidrawLinearElement
|
? ExcalidrawLinearElement
|
||||||
: T extends "freedraw"
|
: T extends "freedraw"
|
||||||
|
@ -1,20 +1,30 @@
|
|||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElementWithContainer,
|
||||||
|
FontString,
|
||||||
|
} from "../element/types";
|
||||||
import ExcalidrawApp from "../excalidraw-app";
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
import { centerPoint } from "../math";
|
import { centerPoint } from "../math";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import * as Renderer from "../renderer/renderScene";
|
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 { screen, render, fireEvent, GlobalTestState } from "./test-utils";
|
||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
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 renderScene = jest.spyOn(Renderer, "renderScene");
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||||
|
|
||||||
describe("Test Linear Elements", () => {
|
describe("Test Linear Elements", () => {
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
@ -44,8 +54,7 @@ describe("Test Linear Elements", () => {
|
|||||||
strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp",
|
strokeSharpness: ExcalidrawLinearElement["strokeSharpness"] = "sharp",
|
||||||
roughness: ExcalidrawLinearElement["roughness"] = 0,
|
roughness: ExcalidrawLinearElement["roughness"] = 0,
|
||||||
) => {
|
) => {
|
||||||
h.elements = [
|
const line = API.createElement({
|
||||||
API.createElement({
|
|
||||||
x: p1[0],
|
x: p1[0],
|
||||||
y: p1[1],
|
y: p1[1],
|
||||||
width: p2[0] - p1[0],
|
width: p2[0] - p1[0],
|
||||||
@ -57,10 +66,11 @@ describe("Test Linear Elements", () => {
|
|||||||
[p2[0] - p1[0], p2[1] - p1[1]],
|
[p2[0] - p1[0], p2[1] - p1[1]],
|
||||||
],
|
],
|
||||||
strokeSharpness,
|
strokeSharpness,
|
||||||
}),
|
});
|
||||||
];
|
h.elements = [line];
|
||||||
|
|
||||||
mouse.clickAt(p1[0], p1[1]);
|
mouse.clickAt(p1[0], p1[1]);
|
||||||
|
return line;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createThreePointerLinearElement = (
|
const createThreePointerLinearElement = (
|
||||||
@ -70,8 +80,7 @@ describe("Test Linear Elements", () => {
|
|||||||
) => {
|
) => {
|
||||||
//dragging line from midpoint
|
//dragging line from midpoint
|
||||||
const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
|
const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
|
||||||
h.elements = [
|
const line = API.createElement({
|
||||||
API.createElement({
|
|
||||||
x: p1[0],
|
x: p1[0],
|
||||||
y: p1[1],
|
y: p1[1],
|
||||||
width: p3[0] - p1[0],
|
width: p3[0] - p1[0],
|
||||||
@ -84,9 +93,10 @@ describe("Test Linear Elements", () => {
|
|||||||
[p2[0] - p1[0], p2[1] - p1[1]],
|
[p2[0] - p1[0], p2[1] - p1[1]],
|
||||||
],
|
],
|
||||||
strokeSharpness,
|
strokeSharpness,
|
||||||
}),
|
});
|
||||||
];
|
h.elements = [line];
|
||||||
mouse.clickAt(p1[0], p1[1]);
|
mouse.clickAt(p1[0], p1[1]);
|
||||||
|
return line;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterLineEditingMode = (
|
const enterLineEditingMode = (
|
||||||
@ -98,7 +108,9 @@ describe("Test Linear Elements", () => {
|
|||||||
} else {
|
} else {
|
||||||
mouse.clickAt(p1[0], p1[1]);
|
mouse.clickAt(p1[0], p1[1]);
|
||||||
}
|
}
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
});
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -216,6 +228,16 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
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", () => {
|
describe("Inside editor", () => {
|
||||||
it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
|
it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
@ -358,8 +380,8 @@ describe("Test Linear Elements", () => {
|
|||||||
let line: ExcalidrawLinearElement;
|
let line: ExcalidrawLinearElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createThreePointerLinearElement("line");
|
line = createThreePointerLinearElement("line");
|
||||||
line = h.elements[0] as ExcalidrawLinearElement;
|
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
|
|
||||||
enterLineEditingMode(line);
|
enterLineEditingMode(line);
|
||||||
@ -478,7 +500,7 @@ describe("Test Linear Elements", () => {
|
|||||||
// delete 3rd point
|
// delete 3rd point
|
||||||
deletePoint(points[2]);
|
deletePoint(points[2]);
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(renderScene).toHaveBeenCalledTimes(21);
|
expect(renderScene).toHaveBeenCalledTimes(22);
|
||||||
|
|
||||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
line,
|
line,
|
||||||
@ -503,8 +525,7 @@ describe("Test Linear Elements", () => {
|
|||||||
let line: ExcalidrawLinearElement;
|
let line: ExcalidrawLinearElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createThreePointerLinearElement("line", "round");
|
line = createThreePointerLinearElement("line", "round");
|
||||||
line = h.elements[0] as ExcalidrawLinearElement;
|
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
|
|
||||||
enterLineEditingMode(line);
|
enterLineEditingMode(line);
|
||||||
@ -667,7 +688,6 @@ describe("Test Linear Elements", () => {
|
|||||||
fillStyle: "solid",
|
fillStyle: "solid",
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const origPoints = line.points.map((point) => [...point]);
|
|
||||||
const dragEndPositionOffset = [100, 100] as const;
|
const dragEndPositionOffset = [100, 100] as const;
|
||||||
API.setSelectedElements([line]);
|
API.setSelectedElements([line]);
|
||||||
enterLineEditingMode(line, true);
|
enterLineEditingMode(line, true);
|
||||||
@ -682,11 +702,457 @@ describe("Test Linear Elements", () => {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
Array [
|
Array [
|
||||||
${origPoints[1][0] - dragEndPositionOffset[0]},
|
-60,
|
||||||
${origPoints[1][1] - dragEndPositionOffset[1]},
|
-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();
|
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(/\bAlt\b/i, "Alt")
|
||||||
.replace(/\bShift\b/i, "Shift")
|
.replace(/\bShift\b/i, "Shift")
|
||||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
.replace(/\b(Enter|Return)\b/i, "Enter");
|
||||||
|
|
||||||
if (isDarwin) {
|
if (isDarwin) {
|
||||||
return shortcut
|
return shortcut
|
||||||
.replace(/\bCtrlOrCmd\b/i, "Cmd")
|
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
||||||
.replace(/\bAlt\b/i, "Option");
|
.replace(/\bAlt\b/i, "Option");
|
||||||
}
|
}
|
||||||
return shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl");
|
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewportCoordsToSceneCoords = (
|
export const viewportCoordsToSceneCoords = (
|
||||||
|
Loading…
x
Reference in New Issue
Block a user