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 7849730d8..94384134d 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, measureText, redrawTextBoundingBox, @@ -13,8 +14,11 @@ import { import { hasBoundTextElement, isTextBindableContainer, + isUsingAdaptiveRadius, } from "../element/typeChecks"; import { + ExcalidrawElement, + ExcalidrawLinearElement, ExcalidrawTextContainer, ExcalidrawTextElement, } from "../element/types"; @@ -129,19 +133,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/shortcuts.ts b/src/actions/shortcuts.ts index ba66c5a75..be48c6470 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -1,5 +1,6 @@ import { isDarwin } from "../constants"; import { t } from "../i18n"; +import { SubtypeOf } from "../utility-types"; import { getShortcutKey } from "../utils"; import { ActionName } from "./types"; diff --git a/src/actions/types.ts b/src/actions/types.ts index 54bd5a26f..e46cd2ab8 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -6,6 +6,7 @@ import { ExcalidrawProps, BinaryFiles, } from "../types"; +import { MarkOptional } from "../utility-types"; export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; @@ -113,7 +114,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 61abcd701..87348a0ff 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -284,6 +284,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, @@ -2767,7 +2768,6 @@ class App extends React.Component { ); if (container) { if ( - isArrowElement(container) || hasBoundTextElement(container) || !isTransparent(container.backgroundColor) || isHittingElementNotConsideringBoundingBox(container, this.state, [ @@ -6238,6 +6238,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/data/blob.ts b/src/data/blob.ts index 473042b56..35c040ef3 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -7,6 +7,7 @@ import { CanvasError } from "../errors"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { AppState, DataURL, LibraryItem } from "../types"; +import { ValueOf } from "../utility-types"; import { bytesToHexString } from "../utils"; import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem"; import { isValidExcalidrawData, isValidLibrary } from "./json"; diff --git a/src/data/restore.ts b/src/data/restore.ts index 4434b7bc3..98df4547b 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -34,6 +34,7 @@ import { bumpVersion } from "../element/mutateElement"; import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import oc from "open-color"; +import { MarkOptional, Mutable } from "../utility-types"; type RestoredAppState = Omit< AppState, diff --git a/src/element/bounds.ts b/src/element/bounds.ts index 2eab1d93d..3245ca3fc 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -23,6 +23,7 @@ import { import { rescalePoints } from "../points"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; +import { Mutable } from "../utility-types"; // x and y position of top left corner, x and y position of bottom right corner export type Bounds = readonly [number, number, number, number]; diff --git a/src/element/collision.ts b/src/element/collision.ts index 54540ae5e..097b76d34 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -38,6 +38,7 @@ import { isTextElement } from "."; import { isTransparent } from "../utils"; import { shouldShowBoundingBox } from "./transformHandles"; import { getBoundTextElement } from "./textElement"; +import { Mutable } from "../utility-types"; const isElementDraggableFromInside = ( element: NonDeletedExcalidrawElement, diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index 5c478515d..aedc25974 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getShapeForElement } from "../renderer/renderElement"; import { DRAGGING_THRESHOLD } from "../constants"; +import { Mutable } from "../utility-types"; const editorMidPointsCache: { version: number | null; diff --git a/src/element/mutateElement.ts b/src/element/mutateElement.ts index 52038c163..1c3d66121 100644 --- a/src/element/mutateElement.ts +++ b/src/element/mutateElement.ts @@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points"; import { randomInteger } from "../random"; import { Point } from "../types"; import { getUpdatedTimestamp } from "../utils"; +import { Mutable } from "../utility-types"; type ElementUpdate = Omit< Partial, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 5a2c1b54f..41802860c 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -32,6 +32,7 @@ import { } from "./textElement"; import { VERTICAL_ALIGN } from "../constants"; import { isArrowElement } from "./typeChecks"; +import { MarkOptional, Merge, Mutable } from "../utility-types"; type ElementConstructorOpts = MarkOptional< Omit, diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index cca49427c..fde29618b 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), - getContainerMaxWidth(updatedElement), + boundTextElement + ? getContainerMaxWidth(updatedElement) + : updatedElement.width, ); if (!fontSize) { diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index b75f978f1..d95bad816 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, getContainerMaxWidth, getContainerMaxHeight, @@ -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 85a036c46..0e74e7ec8 100644 --- a/src/element/textElement.ts +++ b/src/element/textElement.ts @@ -12,11 +12,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"; @@ -27,6 +23,7 @@ import { resetOriginalContainerCache, updateOriginalContainerCache, } from "./textWysiwyg"; +import { ExtractSetType } from "../utility-types"; export const normalizeText = (text: string) => { return ( @@ -78,9 +75,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 }); updateOriginalContainerCache(container.id, nextHeight); @@ -184,9 +181,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; @@ -326,7 +323,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); @@ -400,7 +396,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 = ""; @@ -704,32 +700,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 getContainerMaxWidth = (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/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index a98bfaee2..07658f84b 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, isFirefox, isSafari } from "../constants"; +import { CLASSES } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, @@ -252,8 +252,7 @@ export const textWysiwyg = ({ if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; textElementWidth = Math.min(textElementWidth, maxWidth); - } else if (isFirefox || isSafari) { - // As firefox, Safari needs little higher dimensions on DOM + } else { textElementWidth += 0.5; } // Make sure text editor height doesn't go beyond viewport 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..164fafe68 100644 --- a/src/element/typeChecks.ts +++ b/src/element/typeChecks.ts @@ -1,5 +1,6 @@ import { ROUNDNESS } from "../constants"; import { AppState } from "../types"; +import { MarkNonNullable } from "../utility-types"; import { ExcalidrawElement, ExcalidrawTextElement, @@ -139,7 +140,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/element/types.ts b/src/element/types.ts index af78f771e..e99b7e897 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -6,6 +6,7 @@ import { THEME, VERTICAL_ALIGN, } from "../constants"; +import { MarkNonNullable, ValueOf } from "../utility-types"; export type ChartType = "bar" | "line"; export type FillStyle = "hachure" | "cross-hatch" | "solid"; diff --git a/src/excalidraw-app/data/firebase.ts b/src/excalidraw-app/data/firebase.ts index b6b262497..02e14466f 100644 --- a/src/excalidraw-app/data/firebase.ts +++ b/src/excalidraw-app/data/firebase.ts @@ -14,6 +14,7 @@ import { encryptData, decryptData } from "../../data/encryption"; import { MIME_TYPES } from "../../constants"; import { reconcileElements } from "../collab/reconciliation"; import { getSyncableElements, SyncableExcalidrawElement } from "."; +import { ResolutionType } from "../../utility-types"; // private // ----------------------------------------------------------------------------- diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 2b9101ad1..1c51637e0 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -85,6 +85,7 @@ import { useAtomWithInitialValue } from "../jotai"; import { appJotaiStore } from "./app-jotai"; import "./index.scss"; +import { ResolutionType } from "../utility-types"; polyfill(); diff --git a/src/global.d.ts b/src/global.d.ts index df7eeb37e..4a70443d4 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -50,36 +50,6 @@ interface Clipboard extends EventTarget { write(data: any[]): Promise; } -type Mutable = { - -readonly [P in keyof T]: T[P]; -}; - -type ValueOf = T[keyof T]; - -type Merge = Omit & N; - -/** utility type to assert that the second type is a subtype of the first type. - * Returns the subtype. */ -type SubtypeOf = Subtype; - -type ResolutionType any> = T extends ( - ...args: any -) => Promise - ? R - : any; - -// https://github.com/krzkaczor/ts-essentials -type MarkOptional = Omit & Partial>; - -type MarkRequired = Exclude & - Required>; - -type MarkNonNullable = { - [P in K]-?: P extends K ? NonNullable : T[P]; -} & { [P in keyof T]: T[P] }; - -type NonOptional = Exclude; - // PNG encoding/decoding // ----------------------------------------------------------------------------- type TEXtChunk = { name: "tEXt"; data: Uint8Array }; @@ -101,23 +71,6 @@ declare module "png-chunks-extract" { } // ----------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- -// type getter for interface's callable type -// src: https://stackoverflow.com/a/58658851/927631 -// ----------------------------------------------------------------------------- -type SignatureType = T extends (...args: infer R) => any ? R : never; -type CallableType any> = ( - ...args: SignatureType -) => ReturnType; -// --------------------------------------------------------------------------— - -// Type for React.forwardRef --- supply only the first generic argument T -type ForwardRef = Parameters< - CallableType> ->[1]; - -// --------------------------------------------------------------------------— - interface Blob { handle?: import("browser-fs-acces").FileSystemHandle; name?: string; diff --git a/src/history.ts b/src/history.ts index cc620cae1..d102a7ecc 100644 --- a/src/history.ts +++ b/src/history.ts @@ -2,6 +2,7 @@ import { AppState } from "./types"; import { ExcalidrawElement } from "./element/types"; import { isLinearElement } from "./element/typeChecks"; import { deepCopyElement } from "./element/newElement"; +import { Mutable } from "./utility-types"; export interface HistoryEntry { appState: ReturnType; diff --git a/src/locales/en.json b/src/locales/en.json index 31005cb4c..f5ae003f3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -110,6 +110,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/math.ts b/src/math.ts index cfa28e230..602fe976c 100644 --- a/src/math.ts +++ b/src/math.ts @@ -12,6 +12,7 @@ import { } from "./element/types"; import { getShapeForElement } from "./renderer/renderElement"; import { getCurvePathOps } from "./element/bounds"; +import { Mutable } from "./utility-types"; export const rotate = ( x1: number, 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 1b9561573..7d3bf9de7 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, @@ -41,7 +42,10 @@ import { getStroke, StrokeOptions } from "perfect-freehand"; import { getLineHeight, getBoundTextElement, + getContainerCoords, getContainerElement, + getContainerMaxHeight, + getContainerMaxWidth, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -811,6 +815,24 @@ 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 textElement = getBoundTextElement( + element, + ) as ExcalidrawTextElementWithContainer; + const coords = getContainerCoords(element); + context.strokeStyle = "#c92a2a"; + context.lineWidth = 3; + context.strokeRect( + (coords.x + renderConfig.scrollX) * window.devicePixelRatio, + (coords.y + renderConfig.scrollY) * window.devicePixelRatio, + getContainerMaxWidth(element) * window.devicePixelRatio, + getContainerMaxHeight(element, textElement) * 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/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap index 5ea0ab4b1..f84eab3a2 100644 --- a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te class="excalidraw-wysiwyg" data-type="wysiwyg" dir="auto" - style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;" + style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;" tabindex="0" wrap="off" /> diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 7f3e958ca..a0feab2f2 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -19,6 +19,7 @@ import { newFreeDrawElement, newImageElement } from "../../element/newElement"; import { Point } from "../../types"; import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; +import { Mutable } from "../../utility-types"; const readFile = util.promisify(fs.readFile); 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); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 486ec7146..e40476ead 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,7 @@ import Library from "./data/library"; import type { FileSystemHandle } from "./data/filesystem"; import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import { ContextMenuItems } from "./components/ContextMenu"; +import { Merge, ForwardRef } from "./utility-types"; export type Point = Readonly; diff --git a/src/utility-types.ts b/src/utility-types.ts new file mode 100644 index 000000000..b84eb1994 --- /dev/null +++ b/src/utility-types.ts @@ -0,0 +1,49 @@ +export type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +export type ValueOf = T[keyof T]; + +export type Merge = Omit & N; + +/** utility type to assert that the second type is a subtype of the first type. + * Returns the subtype. */ +export type SubtypeOf = Subtype; + +export type ResolutionType any> = T extends ( + ...args: any +) => Promise + ? R + : any; + +// https://github.com/krzkaczor/ts-essentials +export type MarkOptional = Omit & + Partial>; + +export type MarkRequired = Exclude & + Required>; + +export type MarkNonNullable = { + [P in K]-?: P extends K ? NonNullable : T[P]; +} & { [P in keyof T]: T[P] }; + +export type NonOptional = Exclude; + +// ----------------------------------------------------------------------------- +// type getter for interface's callable type +// src: https://stackoverflow.com/a/58658851/927631 +// ----------------------------------------------------------------------------- +export type SignatureType = T extends (...args: infer R) => any ? R : never; +export type CallableType any> = ( + ...args: SignatureType +) => ReturnType; +// --------------------------------------------------------------------------— + +// Type for React.forwardRef --- supply only the first generic argument T +export type ForwardRef = Parameters< + CallableType> +>[1]; + +export type ExtractSetType> = T extends Set + ? U + : never; diff --git a/src/utils.ts b/src/utils.ts index b2d85d4d3..61925a606 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,6 +16,7 @@ import { AppState, DataURL, LastActiveTool, Zoom } from "./types"; import { unstable_batchedUpdates } from "react-dom"; import { SHAPES } from "./shapes"; import { isEraserActive, isHandToolActive } from "./appState"; +import { ResolutionType } from "./utility-types"; let mockDateTime: string | null = null;