Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-03-06 09:06:55 -06:00
commit 28261c4b29
23 changed files with 480 additions and 72 deletions

View File

@ -22,3 +22,8 @@ REACT_APP_DEV_ENABLE_SW=
REACT_APP_DEV_DISABLE_LIVE_RELOAD= REACT_APP_DEV_DISABLE_LIVE_RELOAD=
FAST_REFRESH=false FAST_REFRESH=false
#Debug flags
# To enable bounding box for text containers
REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=

View File

@ -1785,9 +1785,9 @@
"@hapi/hoek" "^9.0.0" "@hapi/hoek" "^9.0.0"
"@sideway/formula@^3.0.0": "@sideway/formula@^3.0.0":
version "3.0.0" version "3.0.1"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
"@sideway/pinpoint@^2.0.0": "@sideway/pinpoint@^2.0.0":
version "2.0.0" version "2.0.0"
@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1:
entities "^4.3.0" entities "^4.3.0"
http-cache-semantics@^4.0.0: http-cache-semantics@^4.0.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
http-deceiver@^1.2.7: http-deceiver@^1.2.7:
version "1.2.7" version "1.2.7"

View File

@ -1,7 +1,8 @@
import { VERTICAL_ALIGN } from "../constants"; import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements, isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { import {
computeContainerDimensionForBoundText,
getBoundTextElement, getBoundTextElement,
measureTextElement, measureTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
@ -13,8 +14,11 @@ import {
import { import {
hasBoundTextElement, hasBoundTextElement,
isTextBindableContainer, isTextBindableContainer,
isUsingAdaptiveRadius,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
} from "../element/types"; } from "../element/types";
@ -127,19 +131,151 @@ export const actionBindText = register({
}), }),
}); });
redrawTextBoundingBox(textElement, container); redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return { return {
elements: updatedElements, elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } }, appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true, commitToHistory: true,
}; };
}, },
}); });
const pushTextAboveContainer = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return updatedElements;
};
const pushContainerBelowText = (
elements: readonly ExcalidrawElement[],
container: ExcalidrawElement,
textElement: ExcalidrawTextElement,
) => {
const updatedElements = elements.slice();
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex, 1);
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
return updatedElements;
};
export const actionCreateContainerFromText = register({
name: "createContainerFromText",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1 && isTextElement(selectedElements[0]);
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = elements.slice();
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
const textElement = selectedElements[0];
const container = newElement({
type: "rectangle",
backgroundColor: appState.currentItemBackgroundColor,
boundElements: [
...(textElement.boundElements || []),
{ id: textElement.id, type: "text" },
],
angle: textElement.angle,
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: appState.currentItemStrokeWidth,
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius("rectangle")
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
opacity: 100,
locked: false,
x: textElement.x - BOUND_TEXT_PADDING,
y: textElement.y - BOUND_TEXT_PADDING,
width: computeContainerDimensionForBoundText(
textElement.width,
"rectangle",
),
height: computeContainerDimensionForBoundText(
textElement.height,
"rectangle",
),
groupIds: textElement.groupIds,
});
// update bindings
if (textElement.boundElements?.length) {
const linearElementIds = textElement.boundElements
.filter((ele) => ele.type === "arrow")
.map((el) => el.id);
const linearElements = updatedElements.filter((ele) =>
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = null;
let endBinding = null;
if (ele.startBinding) {
startBinding = { ...ele.startBinding, elementId: container.id };
}
if (ele.endBinding) {
endBinding = { ...ele.endBinding, elementId: container.id };
}
mutateElement(ele, { startBinding, endBinding });
});
}
mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
});
redrawTextBoundingBox(textElement, container);
return {
elements: pushContainerBelowText(
[...elements, container],
container,
textElement,
),
appState: {
...appState,
selectedElementIds: {
[container.id]: true,
[textElement.id]: false,
},
},
commitToHistory: true,
};
}
return {
elements: updatedElements,
appState,
commitToHistory: true,
};
},
});

View File

@ -122,7 +122,8 @@ export type ActionName =
| "toggleLock" | "toggleLock"
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool"; | "toggleHandTool"
| "createContainerFromText";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View File

@ -1,6 +1,7 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions"; import { actionClearCanvas } from "../actions";
import { t } from "../i18n"; import { t } from "../i18n";
import { jotaiScope } from "../jotai";
import { useExcalidrawActionManager } from "./App"; import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => { export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom, activeConfirmDialogAtom,
jotaiScope,
); );
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();

View File

@ -293,6 +293,7 @@ import { actionPaste } from "../actions/actionClipboard";
import { actionToggleHandTool } from "../actions/actionCanvas"; import { actionToggleHandTool } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionCreateContainerFromText } from "../actions/actionBoundText";
const deviceContextInitialValue = { const deviceContextInitialValue = {
isSmScreen: false, isSmScreen: false,
@ -2830,7 +2831,6 @@ class App extends React.Component<AppProps, AppState> {
); );
if (container) { if (container) {
if ( if (
isArrowElement(container) ||
hasBoundTextElement(container) || hasBoundTextElement(container) ||
!isTransparent(container.backgroundColor) || !isTransparent(container.backgroundColor) ||
isHittingElementNotConsideringBoundingBox(container, this.state, [ isHittingElementNotConsideringBoundingBox(container, this.state, [
@ -6327,6 +6327,7 @@ class App extends React.Component<AppProps, AppState> {
actionGroup, actionGroup,
actionUnbindText, actionUnbindText,
actionBindText, actionBindText,
actionCreateContainerFromText,
actionUngroup, actionUngroup,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionAddToLibrary, actionAddToLibrary,

View File

@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => {
...rest ...rest
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
return ( return (
<Dialog <Dialog

View File

@ -16,6 +16,7 @@ import { AppState } from "../types";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
import { jotaiScope } from "../jotai";
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
@ -72,7 +73,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
const onClose = () => { const onClose = () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });

View File

@ -1,7 +1,7 @@
import { t } from "../i18n";
import { HelpIcon } from "./icons"; import { HelpIcon } from "./icons";
type HelpButtonProps = { type HelpButtonProps = {
title?: string;
name?: string; name?: string;
id?: string; id?: string;
onClick?(): void; onClick?(): void;
@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => (
className="help-icon" className="help-icon"
onClick={props.onClick} onClick={props.onClick}
type="button" type="button"
title={`${props.title} — ?`} title={`${t("helpDialog.title")} — ?`}
aria-label={props.title} aria-label={t("helpDialog.title")}
> >
{HelpIcon} {HelpIcon}
</button> </button>

View File

@ -48,6 +48,7 @@ export const LibraryMenuHeader: React.FC<{
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope,
); );
const renderRemoveLibAlert = useCallback(() => { const renderRemoveLibAlert = useCallback(() => {
const content = selectedItems.length const content = selectedItems.length

View File

@ -31,6 +31,7 @@ import "./DefaultItems.scss";
import clsx from "clsx"; import clsx from "clsx";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai";
export const LoadScene = () => { export const LoadScene = () => {
const { t } = useI18n(); const { t } = useI18n();
@ -113,7 +114,10 @@ Help.displayName = "Help";
export const ClearCanvas = () => { export const ClearCanvas = () => {
const { t } = useI18n(); const { t } = useI18n();
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); const setActiveConfirmDialog = useSetAtom(
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) { if (!actionManager.isActionEnabled(actionClearCanvas)) {

View File

@ -693,7 +693,9 @@ const resizeMultipleElements = (
}; };
const fontSize = measureFontSizeFromWidth( const fontSize = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (element.orig as ExcalidrawTextElement),
getMaxContainerWidth(updatedElement), boundTextElement
? getMaxContainerWidth(updatedElement)
: updatedElement.width,
); );
if (!fontSize) { if (!fontSize) {

View File

@ -1,7 +1,7 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING } from "../constants";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
computeContainerHeightForBoundText, computeContainerDimensionForBoundText,
getContainerCoords, getContainerCoords,
getMaxContainerWidth, getMaxContainerWidth,
getMaxContainerHeight, getMaxContainerHeight,
@ -35,10 +35,11 @@ describe("Test wrapText", () => {
describe("When text doesn't contain new lines", () => { describe("When text doesn't contain new lines", () => {
const text = "Hello whats up"; const text = "Hello whats up";
[ [
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 90, width: 80,
res: `Hello res: `Hello
whats whats
up`, up`,
@ -62,7 +63,7 @@ p`,
{ {
desc: "break words as per the width", desc: "break words as per the width",
width: 150, width: 140,
res: `Hello whats res: `Hello whats
up`, up`,
}, },
@ -93,7 +94,7 @@ whats up`;
[ [
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 90, width: 80,
res: `Hello res: `Hello
whats whats
up`, up`,
@ -214,7 +215,7 @@ describe("Test measureText", () => {
}); });
}); });
describe("Test computeContainerHeightForBoundText", () => { describe("Test computeContainerDimensionForBoundText", () => {
const params = { const params = {
width: 178, width: 178,
height: 194, height: 194,
@ -225,7 +226,9 @@ describe("Test measureText", () => {
type: "rectangle", type: "rectangle",
...params, ...params,
}); });
expect(computeContainerHeightForBoundText(element, 150)).toEqual(160); expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
160,
);
}); });
it("should compute container height correctly for ellipse", () => { it("should compute container height correctly for ellipse", () => {
@ -233,7 +236,9 @@ describe("Test measureText", () => {
type: "ellipse", type: "ellipse",
...params, ...params,
}); });
expect(computeContainerHeightForBoundText(element, 150)).toEqual(226); expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
226,
);
}); });
it("should compute container height correctly for diamond", () => { it("should compute container height correctly for diamond", () => {
@ -241,7 +246,9 @@ describe("Test measureText", () => {
type: "diamond", type: "diamond",
...params, ...params,
}); });
expect(computeContainerHeightForBoundText(element, 150)).toEqual(320); expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
320,
);
}); });
}); });

View File

@ -13,11 +13,7 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles"; import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isTextElement } from "."; import { isTextElement } from ".";
import { import { isBoundToContainer, isArrowElement } from "./typeChecks";
isBoundToContainer,
isImageElement,
isArrowElement,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types"; import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks"; import { isTextBindableContainer } from "./typeChecks";
@ -112,9 +108,9 @@ export const redrawTextBoundingBox = (
let nextHeight = containerDims.height; let nextHeight = containerDims.height;
if (metrics.height > maxContainerHeight) { if (metrics.height > maxContainerHeight) {
nextHeight = computeContainerHeightForBoundText( nextHeight = computeContainerDimensionForBoundText(
container,
metrics.height, metrics.height,
container.type,
); );
mutateElement(container, { height: nextHeight }); mutateElement(container, { height: nextHeight });
maxContainerHeight = getMaxContainerHeight(container); maxContainerHeight = getMaxContainerHeight(container);
@ -212,9 +208,9 @@ export const handleBindTextResize = (
} }
// increase height in case text element height exceeds // increase height in case text element height exceeds
if (nextHeight > maxHeight) { if (nextHeight > maxHeight) {
containerHeight = computeContainerHeightForBoundText( containerHeight = computeContainerDimensionForBoundText(
container,
nextHeight, nextHeight,
container.type,
); );
const diff = containerHeight - containerDims.height; const diff = containerHeight - containerDims.height;
@ -348,7 +344,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const lines: Array<string> = []; const lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font); const spaceWidth = getLineWidth(" ", font);
const push = (str: string) => { const push = (str: string) => {
if (str.trim()) { if (str.trim()) {
lines.push(str); lines.push(str);
@ -422,7 +417,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
const word = words[index]; const word = words[index];
currentLineWidthTillNow = getLineWidth(currentLine + word, font); currentLineWidthTillNow = getLineWidth(currentLine + word, font);
if (currentLineWidthTillNow >= maxWidth) { if (currentLineWidthTillNow > maxWidth) {
push(currentLine); push(currentLine);
currentLineWidthTillNow = 0; currentLineWidthTillNow = 0;
currentLine = ""; currentLine = "";
@ -738,32 +733,34 @@ export const getTextBindableContainerAtPosition = (
return isTextBindableContainer(hitElement, false) ? hitElement : null; return isTextBindableContainer(hitElement, false) ? hitElement : null;
}; };
export const isValidTextContainer = (element: ExcalidrawElement) => { const VALID_CONTAINER_TYPES = new Set([
return ( "rectangle",
element.type === "rectangle" || "ellipse",
element.type === "ellipse" || "diamond",
element.type === "diamond" || "image",
isImageElement(element) || "arrow",
isArrowElement(element) ]);
);
};
export const computeContainerHeightForBoundText = ( export const isValidTextContainer = (element: ExcalidrawElement) =>
container: NonDeletedExcalidrawElement, VALID_CONTAINER_TYPES.has(element.type);
boundTextElementHeight: number,
export const computeContainerDimensionForBoundText = (
dimension: number,
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
) => { ) => {
if (container.type === "ellipse") { dimension = Math.ceil(dimension);
return Math.round( const padding = BOUND_TEXT_PADDING * 2;
((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2,
); if (containerType === "ellipse") {
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
} }
if (isArrowElement(container)) { if (containerType === "arrow") {
return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2; return dimension + padding * 8;
} }
if (container.type === "diamond") { if (containerType === "diamond") {
return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2); return 2 * (dimension + padding);
} }
return boundTextElementHeight + BOUND_TEXT_PADDING * 2; return dimension + padding;
}; };
export const getMaxContainerWidth = (container: ExcalidrawElement) => { export const getMaxContainerWidth = (container: ExcalidrawElement) => {

View File

@ -10,7 +10,7 @@ import {
} from "../tests/test-utils"; } from "../tests/test-utils";
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { FONT_FAMILY } from "../constants"; import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
import { import {
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
@ -19,6 +19,7 @@ import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils"; import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -1307,5 +1308,91 @@ describe("textWysiwyg", () => {
`); `);
}); });
}); });
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text");
mouse.clickAt(20, 30);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Excalidraw is an opensource virtual collaborative whiteboard",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0));
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600);
expect(textElement.height).toBe(24);
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
expect((textElement as ExcalidrawTextElement).text).toBe(
"Excalidraw is an opensource virtual collaborative whiteboard",
);
API.setSelectedElements([textElement]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 20,
clientY: 30,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Wrap text in a container")!,
);
expect(h.elements.length).toBe(3);
expect(h.elements[1]).toEqual(
expect.objectContaining({
angle: 0,
backgroundColor: "transparent",
boundElements: [
{
id: h.elements[2].id,
type: "text",
},
],
fillStyle: "hachure",
groupIds: [],
height: 34,
isDeleted: false,
link: null,
locked: false,
opacity: 100,
roughness: 1,
roundness: {
type: 3,
},
strokeColor: "#000000",
strokeStyle: "solid",
strokeWidth: 1,
type: "rectangle",
updated: 1,
version: 1,
width: 610,
x: 15,
y: 25,
}),
);
expect(h.elements[2] as ExcalidrawTextElement).toEqual(
expect.objectContaining({
text: "Excalidraw is an opensource virtual collaborative whiteboard",
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.LEFT,
boundElements: null,
}),
);
});
}); });
}); });

View File

@ -0,0 +1,66 @@
import { API } from "../tests/helpers/api";
import { hasBoundTextElement } from "./typeChecks";
describe("Test TypeChecks", () => {
describe("Test hasBoundTextElement", () => {
it("should return true for text bindable containers with bound text", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "rectangle",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "ellipse",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "arrow",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
});
it("should return false for text bindable containers without bound text", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "freedraw",
boundElements: [{ type: "arrow", id: "arrow-id" }],
}),
),
).toBeFalsy();
});
it("should return false for non text bindable containers", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "freedraw",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
});

View File

@ -139,7 +139,7 @@ export const hasBoundTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => { ): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
return ( return (
isBindableElement(element) && isTextBindableContainer(element) &&
!!element.boundElements?.some(({ type }) => type === "text") !!element.boundElements?.some(({ type }) => type === "text")
); );
}; };

2
src/global.d.ts vendored
View File

@ -166,3 +166,5 @@ declare module "image-blob-reduce" {
const reduce: ImageBlobReduce.ImageBlobReduceStatic; const reduce: ImageBlobReduce.ImageBlobReduceStatic;
export = reduce; export = reduce;
} }
type ExtractSetType<T extends Set<any>> = T extends Set<infer U> ? U : never;

View File

@ -111,6 +111,7 @@
"increaseFontSize": "Increase font size", "increaseFontSize": "Increase font size",
"unbindText": "Unbind text", "unbindText": "Unbind text",
"bindText": "Bind text to the container", "bindText": "Bind text to the container",
"createContainerFromText": "Wrap text in a container",
"link": { "link": {
"edit": "Edit link", "edit": "Edit link",
"create": "Create link", "create": "Create link",

View File

@ -2032,9 +2032,9 @@ dns-equal@^1.0.0:
integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0=
dns-packet@^5.2.2: dns-packet@^5.2.2:
version "5.3.1" version "5.4.0"
resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.3.1.tgz#eb94413789daec0f0ebe2fcc230bdc9d7c91b43d" resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.4.0.tgz#1f88477cf9f27e78a213fb6d118ae38e759a879b"
integrity sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw== integrity sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==
dependencies: dependencies:
"@leichtgewicht/ip-codec" "^2.0.1" "@leichtgewicht/ip-codec" "^2.0.1"

View File

@ -14,6 +14,7 @@ import {
isFreeDrawElement, isFreeDrawElement,
isInitializedImageElement, isInitializedImageElement,
isArrowElement, isArrowElement,
hasBoundTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
getDiamondPoints, getDiamondPoints,
@ -42,7 +43,10 @@ import { getStroke, StrokeOptions } from "perfect-freehand";
import { import {
getApproxLineHeight, getApproxLineHeight,
getBoundTextElement, getBoundTextElement,
getContainerCoords,
getContainerElement, getContainerElement,
getMaxContainerHeight,
getMaxContainerWidth,
} from "../element/textElement"; } from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
@ -818,6 +822,21 @@ const drawElementFromCanvas = (
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom, elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
); );
if (
process.env.REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX &&
hasBoundTextElement(element)
) {
const coords = getContainerCoords(element);
context.strokeStyle = "#c92a2a";
context.lineWidth = 3;
context.strokeRect(
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
getMaxContainerWidth(element) * window.devicePixelRatio,
getMaxContainerHeight(element) * window.devicePixelRatio,
);
}
} }
context.restore(); context.restore();

View File

@ -119,6 +119,15 @@ Object {
"category": "element", "category": "element",
}, },
}, },
Object {
"contextItemLabel": "labels.createContainerFromText",
"name": "createContainerFromText",
"perform": [Function],
"predicate": [Function],
"trackEvent": Object {
"category": "element",
},
},
Object { Object {
"PanelComponent": [Function], "PanelComponent": [Function],
"contextItemLabel": "labels.ungroup", "contextItemLabel": "labels.ungroup",
@ -4507,6 +4516,15 @@ Object {
"category": "element", "category": "element",
}, },
}, },
Object {
"contextItemLabel": "labels.createContainerFromText",
"name": "createContainerFromText",
"perform": [Function],
"predicate": [Function],
"trackEvent": Object {
"category": "element",
},
},
Object { Object {
"PanelComponent": [Function], "PanelComponent": [Function],
"contextItemLabel": "labels.ungroup", "contextItemLabel": "labels.ungroup",
@ -5048,6 +5066,15 @@ Object {
"category": "element", "category": "element",
}, },
}, },
Object {
"contextItemLabel": "labels.createContainerFromText",
"name": "createContainerFromText",
"perform": [Function],
"predicate": [Function],
"trackEvent": Object {
"category": "element",
},
},
Object { Object {
"PanelComponent": [Function], "PanelComponent": [Function],
"contextItemLabel": "labels.ungroup", "contextItemLabel": "labels.ungroup",
@ -5888,6 +5915,15 @@ Object {
"category": "element", "category": "element",
}, },
}, },
Object {
"contextItemLabel": "labels.createContainerFromText",
"name": "createContainerFromText",
"perform": [Function],
"predicate": [Function],
"trackEvent": Object {
"category": "element",
},
},
Object { Object {
"PanelComponent": [Function], "PanelComponent": [Function],
"contextItemLabel": "labels.ungroup", "contextItemLabel": "labels.ungroup",
@ -6225,6 +6261,15 @@ Object {
"category": "element", "category": "element",
}, },
}, },
Object {
"contextItemLabel": "labels.createContainerFromText",
"name": "createContainerFromText",
"perform": [Function],
"predicate": [Function],
"trackEvent": Object {
"category": "element",
},
},
Object { Object {
"PanelComponent": [Function], "PanelComponent": [Function],
"contextItemLabel": "labels.ungroup", "contextItemLabel": "labels.ungroup",

View File

@ -0,0 +1,30 @@
import { KEYS } from "../keys";
import { Excalidraw } from "../packages/excalidraw/entry";
import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui";
import { fireEvent, render, waitFor } from "./test-utils";
describe("shortcuts", () => {
it("Clear canvas shortcut should display confirm dialog", async () => {
await render(
<Excalidraw
initialData={{ elements: [API.createElement({ type: "rectangle" })] }}
handleKeyboardGlobally
/>,
);
expect(window.h.elements.length).toBe(1);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyDown(KEYS.DELETE);
});
const confirmDialog = document.querySelector(".confirm-dialog")!;
expect(confirmDialog).not.toBe(null);
fireEvent.click(confirmDialog.querySelector('[aria-label="Confirm"]')!);
await waitFor(() => {
expect(window.h.elements[0].isDeleted).toBe(true);
});
});
});