From 0f06fa3851c87f6e2206bcf0a184fabe379f2671 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 3 Mar 2023 17:40:42 +0530 Subject: [PATCH] feat: create bound container from text (#6301) * feat: create container from text * fix lint and spec * fix * round off dims * ceil * review fixes * fix * Add specs * fix * fix z-index and type * consider group * consider linear bindings * lint --- src/actions/actionBoundText.tsx | 166 ++++++++++++++++-- src/actions/types.ts | 3 +- src/components/App.tsx | 2 + src/element/textElement.test.ts | 23 ++- src/element/textElement.ts | 61 +++---- src/element/textWysiwyg.test.tsx | 74 ++++++++ src/global.d.ts | 2 + src/locales/en.json | 1 + .../__snapshots__/contextmenu.test.tsx.snap | 45 +++++ 9 files changed, 324 insertions(+), 53 deletions(-) diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 7849730d..c3ebc920 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -1,7 +1,13 @@ -import { VERTICAL_ALIGN } from "../constants"; -import { getNonDeletedElements, isTextElement } from "../element"; +import { + BOUND_TEXT_PADDING, + ROUNDNESS, + TEXT_ALIGN, + VERTICAL_ALIGN, +} from "../constants"; +import { getNonDeletedElements, isTextElement, newElement } from "../element"; import { mutateElement } from "../element/mutateElement"; import { + computeContainerDimensionForBoundText, getBoundTextElement, measureText, redrawTextBoundingBox, @@ -13,8 +19,11 @@ import { import { hasBoundTextElement, isTextBindableContainer, + isUsingAdaptiveRadius, } from "../element/typeChecks"; import { + ExcalidrawElement, + ExcalidrawLinearElement, ExcalidrawTextContainer, ExcalidrawTextElement, } from "../element/types"; @@ -129,19 +138,152 @@ 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, + textAlign: TEXT_ALIGN.CENTER, + 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 54bd5a26..baa37eaa 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -113,7 +113,8 @@ export type ActionName = | "toggleLock" | "toggleLinearEditor" | "toggleEraserTool" - | "toggleHandTool"; + | "toggleHandTool" + | "createContainerFromText"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/src/components/App.tsx b/src/components/App.tsx index 24da7d85..a8240f12 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, @@ -6237,6 +6238,7 @@ class App extends React.Component { actionGroup, actionUnbindText, actionBindText, + actionCreateContainerFromText, actionUngroup, CONTEXT_MENU_SEPARATOR, actionAddToLibrary, diff --git a/src/element/textElement.test.ts b/src/element/textElement.test.ts index 87219b29..7bc361b4 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 4d9fa5eb..a4c49479 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"; @@ -84,9 +80,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); @@ -188,9 +184,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; @@ -324,7 +320,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); @@ -398,7 +393,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 = ""; @@ -714,32 +709,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 7de71198..48138ea0 100644 --- a/src/element/textWysiwyg.test.tsx +++ b/src/element/textWysiwyg.test.tsx @@ -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,78 @@ 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.blur(); + expect(h.elements[1].width).toBe(600); + expect(h.elements[1].height).toBe(24); + expect((h.elements[1] as ExcalidrawTextElement).text).toBe( + "Excalidraw is an opensource virtual collaborative whiteboard", + ); + + API.setSelectedElements([h.elements[1]]); + + 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).text).toBe( + "Excalidraw is an opensource virtual collaborative whiteboard", + ); + }); }); }); diff --git a/src/global.d.ts b/src/global.d.ts index df7eeb37..4ccd8f3f 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -165,3 +165,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 31005cb4..f5ae003f 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/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 326cde0e..18656edd 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",