diff --git a/.env.development b/.env.development index 72b67ecea..397a56565 100644 --- a/.env.development +++ b/.env.development @@ -22,3 +22,8 @@ REACT_APP_DEV_ENABLE_SW= REACT_APP_DEV_DISABLE_LIVE_RELOAD= FAST_REFRESH=false + +#Debug flags + +# To enable bounding box for text containers +REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX= diff --git a/dev-docs/yarn.lock b/dev-docs/yarn.lock index 5aaa9689f..f6d61d9b6 100644 --- a/dev-docs/yarn.lock +++ b/dev-docs/yarn.lock @@ -1785,9 +1785,9 @@ "@hapi/hoek" "^9.0.0" "@sideway/formula@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" - integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== "@sideway/pinpoint@^2.0.0": version "2.0.0" @@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1: entities "^4.3.0" http-cache-semantics@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" - integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== http-deceiver@^1.2.7: version "1.2.7" diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index afdb85c44..8e5ecbc2c 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -1,7 +1,8 @@ -import { VERTICAL_ALIGN } from "../constants"; -import { getNonDeletedElements, isTextElement } from "../element"; +import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants"; +import { getNonDeletedElements, isTextElement, newElement } from "../element"; import { mutateElement } from "../element/mutateElement"; import { + computeContainerDimensionForBoundText, getBoundTextElement, measureTextElement, redrawTextBoundingBox, @@ -13,8 +14,11 @@ import { import { hasBoundTextElement, isTextBindableContainer, + isUsingAdaptiveRadius, } from "../element/typeChecks"; import { + ExcalidrawElement, + ExcalidrawLinearElement, ExcalidrawTextContainer, ExcalidrawTextElement, } from "../element/types"; @@ -127,19 +131,151 @@ export const actionBindText = register({ }), }); 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 { - elements: updatedElements, + elements: pushTextAboveContainer(elements, container, textElement), appState: { ...appState, selectedElementIds: { [container.id]: 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, + }; + }, +}); diff --git a/src/actions/types.ts b/src/actions/types.ts index ad8f4138a..cf18b04b6 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -122,7 +122,8 @@ export type ActionName = | "toggleLock" | "toggleLinearEditor" | "toggleEraserTool" - | "toggleHandTool"; + | "toggleHandTool" + | "createContainerFromText"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/src/components/ActiveConfirmDialog.tsx b/src/components/ActiveConfirmDialog.tsx index 3c79a5190..44a26e9a6 100644 --- a/src/components/ActiveConfirmDialog.tsx +++ b/src/components/ActiveConfirmDialog.tsx @@ -1,6 +1,7 @@ import { atom, useAtom } from "jotai"; import { actionClearCanvas } from "../actions"; import { t } from "../i18n"; +import { jotaiScope } from "../jotai"; import { useExcalidrawActionManager } from "./App"; import ConfirmDialog from "./ConfirmDialog"; @@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null); export const ActiveConfirmDialog = () => { const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( activeConfirmDialogAtom, + jotaiScope, ); const actionManager = useExcalidrawActionManager(); diff --git a/src/components/App.tsx b/src/components/App.tsx index c416e80b0..f5b96e8a3 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -293,6 +293,7 @@ import { actionPaste } from "../actions/actionClipboard"; import { actionToggleHandTool } from "../actions/actionCanvas"; import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; +import { actionCreateContainerFromText } from "../actions/actionBoundText"; const deviceContextInitialValue = { isSmScreen: false, @@ -2830,7 +2831,6 @@ class App extends React.Component { ); if (container) { if ( - isArrowElement(container) || hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || isHittingElementNotConsideringBoundingBox(container, this.state, [ @@ -6327,6 +6327,7 @@ class App extends React.Component { actionGroup, actionUnbindText, actionBindText, + actionCreateContainerFromText, actionUngroup, CONTEXT_MENU_SEPARATOR, actionAddToLibrary, diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 9a1a91039..aebb42de7 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton"; import { useSetAtom } from "jotai"; import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent"; import { useExcalidrawSetAppState } from "./App"; +import { jotaiScope } from "../jotai"; interface Props extends Omit { onConfirm: () => void; @@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => { ...rest } = props; const setAppState = useExcalidrawSetAppState(); - const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); + const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); return ( { }, [islandNode, props.autofocus]); const setAppState = useExcalidrawSetAppState(); - const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom); + const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const onClose = () => { setAppState({ openMenu: null }); diff --git a/src/components/HelpButton.tsx b/src/components/HelpButton.tsx index 40c130271..ce387244c 100644 --- a/src/components/HelpButton.tsx +++ b/src/components/HelpButton.tsx @@ -1,7 +1,7 @@ +import { t } from "../i18n"; import { HelpIcon } from "./icons"; type HelpButtonProps = { - title?: string; name?: string; id?: string; onClick?(): void; @@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => ( className="help-icon" onClick={props.onClick} type="button" - title={`${props.title} — ?`} - aria-label={props.title} + title={`${t("helpDialog.title")} — ?`} + aria-label={t("helpDialog.title")} > {HelpIcon} diff --git a/src/components/LibraryMenuHeaderContent.tsx b/src/components/LibraryMenuHeaderContent.tsx index 43ea96899..8e6b93523 100644 --- a/src/components/LibraryMenuHeaderContent.tsx +++ b/src/components/LibraryMenuHeaderContent.tsx @@ -48,6 +48,7 @@ export const LibraryMenuHeader: React.FC<{ const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( isLibraryMenuOpenAtom, + jotaiScope, ); const renderRemoveLibAlert = useCallback(() => { const content = selectedItems.length diff --git a/src/components/main-menu/DefaultItems.tsx b/src/components/main-menu/DefaultItems.tsx index b3cc23b90..d02719a2c 100644 --- a/src/components/main-menu/DefaultItems.tsx +++ b/src/components/main-menu/DefaultItems.tsx @@ -31,6 +31,7 @@ import "./DefaultItems.scss"; import clsx from "clsx"; import { useSetAtom } from "jotai"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; +import { jotaiScope } from "../../jotai"; export const LoadScene = () => { const { t } = useI18n(); @@ -113,7 +114,10 @@ Help.displayName = "Help"; export const ClearCanvas = () => { const { t } = useI18n(); - const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom); + const setActiveConfirmDialog = useSetAtom( + activeConfirmDialogAtom, + jotaiScope, + ); const actionManager = useExcalidrawActionManager(); if (!actionManager.isActionEnabled(actionClearCanvas)) { diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 0c0337ed7..05eb630ed 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -693,7 +693,9 @@ const resizeMultipleElements = ( }; const fontSize = measureFontSizeFromWidth( boundTextElement ?? (element.orig as ExcalidrawTextElement), - getMaxContainerWidth(updatedElement), + boundTextElement + ? getMaxContainerWidth(updatedElement) + : updatedElement.width, ); if (!fontSize) { diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index 87219b298..7bc361b4d 100644 --- a/src/element/textElement.test.ts +++ b/src/element/textElement.test.ts @@ -1,7 +1,7 @@ import { BOUND_TEXT_PADDING } from "../constants"; import { API } from "../tests/helpers/api"; import { - computeContainerHeightForBoundText, + computeContainerDimensionForBoundText, getContainerCoords, getMaxContainerWidth, getMaxContainerHeight, @@ -35,10 +35,11 @@ describe("Test wrapText", () => { describe("When text doesn't contain new lines", () => { const text = "Hello whats up"; + [ { desc: "break all words when width of each word is less than container width", - width: 90, + width: 80, res: `Hello whats up`, @@ -62,7 +63,7 @@ p`, { desc: "break words as per the width", - width: 150, + width: 140, res: `Hello whats up`, }, @@ -93,7 +94,7 @@ whats up`; [ { desc: "break all words when width of each word is less than container width", - width: 90, + width: 80, res: `Hello whats up`, @@ -214,7 +215,7 @@ describe("Test measureText", () => { }); }); - describe("Test computeContainerHeightForBoundText", () => { + describe("Test computeContainerDimensionForBoundText", () => { const params = { width: 178, height: 194, @@ -225,7 +226,9 @@ describe("Test measureText", () => { type: "rectangle", ...params, }); - expect(computeContainerHeightForBoundText(element, 150)).toEqual(160); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 160, + ); }); it("should compute container height correctly for ellipse", () => { @@ -233,7 +236,9 @@ describe("Test measureText", () => { type: "ellipse", ...params, }); - expect(computeContainerHeightForBoundText(element, 150)).toEqual(226); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 226, + ); }); it("should compute container height correctly for diamond", () => { @@ -241,7 +246,9 @@ describe("Test measureText", () => { type: "diamond", ...params, }); - expect(computeContainerHeightForBoundText(element, 150)).toEqual(320); + expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( + 320, + ); }); }); diff --git a/src/element/textElement.ts b/src/element/textElement.ts index a7808cf7b..9cc055d32 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -13,11 +13,7 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; import Scene from "../scene/Scene"; import { isTextElement } from "."; -import { - isBoundToContainer, - isImageElement, - isArrowElement, -} from "./typeChecks"; +import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; import { AppState } from "../types"; import { isTextBindableContainer } from "./typeChecks"; @@ -112,9 +108,9 @@ export const redrawTextBoundingBox = ( let nextHeight = containerDims.height; if (metrics.height > maxContainerHeight) { - nextHeight = computeContainerHeightForBoundText( - container, + nextHeight = computeContainerDimensionForBoundText( metrics.height, + container.type, ); mutateElement(container, { height: nextHeight }); maxContainerHeight = getMaxContainerHeight(container); @@ -212,9 +208,9 @@ export const handleBindTextResize = ( } // increase height in case text element height exceeds if (nextHeight > maxHeight) { - containerHeight = computeContainerHeightForBoundText( - container, + containerHeight = computeContainerDimensionForBoundText( nextHeight, + container.type, ); const diff = containerHeight - containerDims.height; @@ -348,7 +344,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { const lines: Array = []; const originalLines = text.split("\n"); const spaceWidth = getLineWidth(" ", font); - const push = (str: string) => { if (str.trim()) { lines.push(str); @@ -422,7 +417,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => { const word = words[index]; currentLineWidthTillNow = getLineWidth(currentLine + word, font); - if (currentLineWidthTillNow >= maxWidth) { + if (currentLineWidthTillNow > maxWidth) { push(currentLine); currentLineWidthTillNow = 0; currentLine = ""; @@ -738,32 +733,34 @@ export const getTextBindableContainerAtPosition = ( return isTextBindableContainer(hitElement, false) ? hitElement : null; }; -export const isValidTextContainer = (element: ExcalidrawElement) => { - return ( - element.type === "rectangle" || - element.type === "ellipse" || - element.type === "diamond" || - isImageElement(element) || - isArrowElement(element) - ); -}; +const VALID_CONTAINER_TYPES = new Set([ + "rectangle", + "ellipse", + "diamond", + "image", + "arrow", +]); -export const computeContainerHeightForBoundText = ( - container: NonDeletedExcalidrawElement, - boundTextElementHeight: number, +export const isValidTextContainer = (element: ExcalidrawElement) => + VALID_CONTAINER_TYPES.has(element.type); + +export const computeContainerDimensionForBoundText = ( + dimension: number, + containerType: ExtractSetType, ) => { - if (container.type === "ellipse") { - return Math.round( - ((boundTextElementHeight + BOUND_TEXT_PADDING * 2) / Math.sqrt(2)) * 2, - ); + dimension = Math.ceil(dimension); + const padding = BOUND_TEXT_PADDING * 2; + + if (containerType === "ellipse") { + return Math.round(((dimension + padding) / Math.sqrt(2)) * 2); } - if (isArrowElement(container)) { - return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2; + if (containerType === "arrow") { + return dimension + padding * 8; } - if (container.type === "diamond") { - return 2 * (boundTextElementHeight + BOUND_TEXT_PADDING * 2); + if (containerType === "diamond") { + return 2 * (dimension + padding); } - return boundTextElementHeight + BOUND_TEXT_PADDING * 2; + return dimension + padding; }; export const getMaxContainerWidth = (container: ExcalidrawElement) => { diff --git a/src/element/textWysiwyg.test.tsx b/src/element/textWysiwyg.test.tsx index 7de71198b..25d266306 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -10,7 +10,7 @@ import { } from "../tests/test-utils"; import { queryByText } from "@testing-library/react"; -import { FONT_FAMILY } from "../constants"; +import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants"; import { ExcalidrawTextElement, ExcalidrawTextElementWithContainer, @@ -19,6 +19,7 @@ import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; import { resize } from "../tests/utils"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; + // Unmount ReactDOM from 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, + }), + ); + }); }); }); diff --git a/src/element/typeChecks.test.ts b/src/element/typeChecks.test.ts new file mode 100644 index 000000000..ab04c29b7 --- /dev/null +++ b/src/element/typeChecks.test.ts @@ -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(); + }); + }); +}); diff --git a/src/element/typeChecks.ts b/src/element/typeChecks.ts index a6b6cb2db..0f13648f9 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -139,7 +139,7 @@ export const hasBoundTextElement = ( element: ExcalidrawElement | null, ): element is MarkNonNullable => { return ( - isBindableElement(element) && + isTextBindableContainer(element) && !!element.boundElements?.some(({ type }) => type === "text") ); }; diff --git a/src/global.d.ts b/src/global.d.ts index 098081d64..336d089a8 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -166,3 +166,5 @@ declare module "image-blob-reduce" { const reduce: ImageBlobReduce.ImageBlobReduceStatic; export = reduce; } + +type ExtractSetType> = T extends Set ? U : never; diff --git a/src/locales/en.json b/src/locales/en.json index df382f092..a7d721d2e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -111,6 +111,7 @@ "increaseFontSize": "Increase font size", "unbindText": "Unbind text", "bindText": "Bind text to the container", + "createContainerFromText": "Wrap text in a container", "link": { "edit": "Edit link", "create": "Create link", diff --git a/src/packages/excalidraw/yarn.lock b/src/packages/excalidraw/yarn.lock index 09838174b..320b26a54 100644 --- a/src/packages/excalidraw/yarn.lock +++ b/src/packages/excalidraw/yarn.lock @@ -2032,9 +2032,9 @@ dns-equal@^1.0.0: integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= dns-packet@^5.2.2: - version "5.3.1" - resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.3.1.tgz#eb94413789daec0f0ebe2fcc230bdc9d7c91b43d" - integrity sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw== + version "5.4.0" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.4.0.tgz#1f88477cf9f27e78a213fb6d118ae38e759a879b" + integrity sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g== dependencies: "@leichtgewicht/ip-codec" "^2.0.1" diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 6dc2afc23..74863853a 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -14,6 +14,7 @@ import { isFreeDrawElement, isInitializedImageElement, isArrowElement, + hasBoundTextElement, } from "../element/typeChecks"; import { getDiamondPoints, @@ -42,7 +43,10 @@ import { getStroke, StrokeOptions } from "perfect-freehand"; import { getApproxLineHeight, getBoundTextElement, + getContainerCoords, getContainerElement, + getMaxContainerHeight, + getMaxContainerWidth, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -818,6 +822,21 @@ const drawElementFromCanvas = ( elementWithCanvas.canvas!.width / 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(); diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 326cde0ed..18656edd1 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -119,6 +119,15 @@ Object { "category": "element", }, }, + Object { + "contextItemLabel": "labels.createContainerFromText", + "name": "createContainerFromText", + "perform": [Function], + "predicate": [Function], + "trackEvent": Object { + "category": "element", + }, + }, Object { "PanelComponent": [Function], "contextItemLabel": "labels.ungroup", @@ -4507,6 +4516,15 @@ Object { "category": "element", }, }, + Object { + "contextItemLabel": "labels.createContainerFromText", + "name": "createContainerFromText", + "perform": [Function], + "predicate": [Function], + "trackEvent": Object { + "category": "element", + }, + }, Object { "PanelComponent": [Function], "contextItemLabel": "labels.ungroup", @@ -5048,6 +5066,15 @@ Object { "category": "element", }, }, + Object { + "contextItemLabel": "labels.createContainerFromText", + "name": "createContainerFromText", + "perform": [Function], + "predicate": [Function], + "trackEvent": Object { + "category": "element", + }, + }, Object { "PanelComponent": [Function], "contextItemLabel": "labels.ungroup", @@ -5888,6 +5915,15 @@ Object { "category": "element", }, }, + Object { + "contextItemLabel": "labels.createContainerFromText", + "name": "createContainerFromText", + "perform": [Function], + "predicate": [Function], + "trackEvent": Object { + "category": "element", + }, + }, Object { "PanelComponent": [Function], "contextItemLabel": "labels.ungroup", @@ -6225,6 +6261,15 @@ Object { "category": "element", }, }, + Object { + "contextItemLabel": "labels.createContainerFromText", + "name": "createContainerFromText", + "perform": [Function], + "predicate": [Function], + "trackEvent": Object { + "category": "element", + }, + }, Object { "PanelComponent": [Function], "contextItemLabel": "labels.ungroup", diff --git a/src/tests/shortcuts.test.tsx b/src/tests/shortcuts.test.tsx new file mode 100644 index 000000000..e0b4fcc58 --- /dev/null +++ b/src/tests/shortcuts.test.tsx @@ -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( + , + ); + + 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); + }); + }); +});