mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	feat: support unbinding bound text (#4686)
* feat: support unbinding text * fix unbound text * move the unbind option next to group action * use boundTextElement.id when unbinding * update original text so it takes same bounding box when unbind * Add spec * recompute measurements when unbinding
This commit is contained in:
		
							
								
								
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | import { getNonDeletedElements } from "../element"; | ||||||
|  | import { mutateElement } from "../element/mutateElement"; | ||||||
|  | import { getBoundTextElement, measureText } from "../element/textElement"; | ||||||
|  | import { ExcalidrawTextElement } from "../element/types"; | ||||||
|  | import { getSelectedElements } from "../scene"; | ||||||
|  | import { getFontString } from "../utils"; | ||||||
|  | import { register } from "./register"; | ||||||
|  |  | ||||||
|  | export const actionUnbindText = register({ | ||||||
|  |   name: "unbindText", | ||||||
|  |   contextItemLabel: "labels.unbindText", | ||||||
|  |   perform: (elements, appState) => { | ||||||
|  |     const selectedElements = getSelectedElements( | ||||||
|  |       getNonDeletedElements(elements), | ||||||
|  |       appState, | ||||||
|  |     ); | ||||||
|  |     selectedElements.forEach((element) => { | ||||||
|  |       const boundTextElement = getBoundTextElement(element); | ||||||
|  |       if (boundTextElement) { | ||||||
|  |         const { width, height, baseline } = measureText( | ||||||
|  |           boundTextElement.originalText, | ||||||
|  |           getFontString(boundTextElement), | ||||||
|  |         ); | ||||||
|  |         mutateElement(boundTextElement as ExcalidrawTextElement, { | ||||||
|  |           containerId: null, | ||||||
|  |           width, | ||||||
|  |           height, | ||||||
|  |           baseline, | ||||||
|  |           text: boundTextElement.originalText, | ||||||
|  |         }); | ||||||
|  |         mutateElement(element, { | ||||||
|  |           boundElements: element.boundElements?.filter( | ||||||
|  |             (ele) => ele.id !== boundTextElement.id, | ||||||
|  |           ), | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     return { | ||||||
|  |       elements, | ||||||
|  |       appState, | ||||||
|  |       commitToHistory: true, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
| @@ -80,3 +80,4 @@ export { actionToggleGridMode } from "./actionToggleGridMode"; | |||||||
| export { actionToggleZenMode } from "./actionToggleZenMode"; | export { actionToggleZenMode } from "./actionToggleZenMode"; | ||||||
|  |  | ||||||
| export { actionToggleStats } from "./actionToggleStats"; | export { actionToggleStats } from "./actionToggleStats"; | ||||||
|  | export { actionUnbindText } from "./actionUnbindText"; | ||||||
|   | |||||||
| @@ -103,7 +103,8 @@ export type ActionName = | |||||||
|   | "exportWithDarkMode" |   | "exportWithDarkMode" | ||||||
|   | "toggleTheme" |   | "toggleTheme" | ||||||
|   | "increaseFontSize" |   | "increaseFontSize" | ||||||
|   | "decreaseFontSize"; |   | "decreaseFontSize" | ||||||
|  |   | "unbindText"; | ||||||
|  |  | ||||||
| export type PanelComponentProps = { | export type PanelComponentProps = { | ||||||
|   elements: readonly ExcalidrawElement[]; |   elements: readonly ExcalidrawElement[]; | ||||||
|   | |||||||
| @@ -26,6 +26,7 @@ import { | |||||||
|   actionToggleGridMode, |   actionToggleGridMode, | ||||||
|   actionToggleStats, |   actionToggleStats, | ||||||
|   actionToggleZenMode, |   actionToggleZenMode, | ||||||
|  |   actionUnbindText, | ||||||
|   actionUngroup, |   actionUngroup, | ||||||
| } from "../actions"; | } from "../actions"; | ||||||
| import { createRedoAction, createUndoAction } from "../actions/actionHistory"; | import { createRedoAction, createUndoAction } from "../actions/actionHistory"; | ||||||
| @@ -5031,6 +5032,10 @@ class App extends React.Component<AppProps, AppState> { | |||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|     } else if (type === "element") { |     } else if (type === "element") { | ||||||
|  |       const elementsWithUnbindedText = getSelectedElements( | ||||||
|  |         elements, | ||||||
|  |         this.state, | ||||||
|  |       ).some((element) => !hasBoundTextElement(element)); | ||||||
|       if (this.state.viewModeEnabled) { |       if (this.state.viewModeEnabled) { | ||||||
|         ContextMenu.push({ |         ContextMenu.push({ | ||||||
|           options: [navigator.clipboard && actionCopy, ...options], |           options: [navigator.clipboard && actionCopy, ...options], | ||||||
| @@ -5064,6 +5069,7 @@ class App extends React.Component<AppProps, AppState> { | |||||||
|             actionPasteStyles, |             actionPasteStyles, | ||||||
|             separator, |             separator, | ||||||
|             maybeGroupAction && actionGroup, |             maybeGroupAction && actionGroup, | ||||||
|  |             !elementsWithUnbindedText && actionUnbindText, | ||||||
|             maybeUngroupAction && actionUngroup, |             maybeUngroupAction && actionUngroup, | ||||||
|             (maybeGroupAction || maybeUngroupAction) && separator, |             (maybeGroupAction || maybeUngroupAction) && separator, | ||||||
|             actionAddToLibrary, |             actionAddToLibrary, | ||||||
|   | |||||||
| @@ -175,7 +175,6 @@ export const measureText = ( | |||||||
|   container.style.whiteSpace = "pre"; |   container.style.whiteSpace = "pre"; | ||||||
|   container.style.font = font; |   container.style.font = font; | ||||||
|   container.style.minHeight = "1em"; |   container.style.minHeight = "1em"; | ||||||
|  |  | ||||||
|   if (maxWidth) { |   if (maxWidth) { | ||||||
|     const lineHeight = getApproxLineHeight(font); |     const lineHeight = getApproxLineHeight(font); | ||||||
|     container.style.width = `${String(maxWidth)}px`; |     container.style.width = `${String(maxWidth)}px`; | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import ReactDOM from "react-dom"; | import ReactDOM from "react-dom"; | ||||||
| import ExcalidrawApp from "../excalidraw-app"; | import ExcalidrawApp from "../excalidraw-app"; | ||||||
| import { render, screen } from "../tests/test-utils"; | import { GlobalTestState, render, screen } from "../tests/test-utils"; | ||||||
| import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; | import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; | ||||||
| import { CODES, KEYS } from "../keys"; | import { CODES, KEYS } from "../keys"; | ||||||
| import { fireEvent } from "../tests/test-utils"; | import { fireEvent } from "../tests/test-utils"; | ||||||
|  | import { queryByText } from "@testing-library/react"; | ||||||
|  |  | ||||||
| import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; | import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants"; | ||||||
| import { | import { | ||||||
|   ExcalidrawTextElement, |   ExcalidrawTextElement, | ||||||
| @@ -472,5 +474,47 @@ describe("textWysiwyg", () => { | |||||||
|       expect(text.height).toBe(APPROX_LINE_HEIGHT); |       expect(text.height).toBe(APPROX_LINE_HEIGHT); | ||||||
|       expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); |       expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     it("should unbind bound text when unbind action from context menu is triggred", async () => { | ||||||
|  |       expect(h.elements.length).toBe(1); | ||||||
|  |       expect(h.elements[0].id).toBe(rectangle.id); | ||||||
|  |  | ||||||
|  |       Keyboard.withModifierKeys({}, () => { | ||||||
|  |         Keyboard.keyPress(KEYS.ENTER); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       expect(h.elements.length).toBe(2); | ||||||
|  |  | ||||||
|  |       const text = h.elements[1] as ExcalidrawTextElementWithContainer; | ||||||
|  |       expect(text.containerId).toBe(rectangle.id); | ||||||
|  |  | ||||||
|  |       const editor = document.querySelector( | ||||||
|  |         ".excalidraw-textEditorContainer > textarea", | ||||||
|  |       ) as HTMLTextAreaElement; | ||||||
|  |  | ||||||
|  |       await new Promise((r) => setTimeout(r, 0)); | ||||||
|  |  | ||||||
|  |       fireEvent.change(editor, { target: { value: "Hello World!" } }); | ||||||
|  |       editor.blur(); | ||||||
|  |       expect(rectangle.boundElements).toStrictEqual([ | ||||||
|  |         { id: text.id, type: "text" }, | ||||||
|  |       ]); | ||||||
|  |       mouse.reset(); | ||||||
|  |       UI.clickTool("selection"); | ||||||
|  |       mouse.clickAt(10, 20); | ||||||
|  |       mouse.down(); | ||||||
|  |       mouse.up(); | ||||||
|  |       fireEvent.contextMenu(GlobalTestState.canvas, { | ||||||
|  |         button: 2, | ||||||
|  |         clientX: 20, | ||||||
|  |         clientY: 30, | ||||||
|  |       }); | ||||||
|  |       const contextMenu = document.querySelector(".context-menu"); | ||||||
|  |       fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!); | ||||||
|  |       expect(h.elements[0].boundElements).toEqual([]); | ||||||
|  |       expect((h.elements[1] as ExcalidrawTextElement).containerId).toEqual( | ||||||
|  |         null, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -104,7 +104,8 @@ | |||||||
|     "personalLib": "Personal Library", |     "personalLib": "Personal Library", | ||||||
|     "excalidrawLib": "Excalidraw Library", |     "excalidrawLib": "Excalidraw Library", | ||||||
|     "decreaseFontSize": "Decrease font size", |     "decreaseFontSize": "Decrease font size", | ||||||
|     "increaseFontSize": "Increase font size" |     "increaseFontSize": "Increase font size", | ||||||
|  |     "unbindText": "Unbind text" | ||||||
|   }, |   }, | ||||||
|   "buttons": { |   "buttons": { | ||||||
|     "clearReset": "Reset the canvas", |     "clearReset": "Reset the canvas", | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Aakansha Doshi
					Aakansha Doshi