ソースを参照

feat: Support binding text to container via context menu (#4935)

* feat: Support binding text to closest container

* Bind text to selected container

* show bind action in canvas and selected container after binding

* allow binding if container has no bound text

* fix

* move logic to show/hide bind actions to contextMenuPredicate

* don't show bind action when clicking on bounding box and not elemnts
Aakansha Doshi 3 年 前
コミット
625ecc64ed

+ 134 - 0
src/actions/actionBoundText.tsx

@@ -0,0 +1,134 @@
+import { VERTICAL_ALIGN } from "../constants";
+import { getNonDeletedElements, isTextElement } from "../element";
+import { mutateElement } from "../element/mutateElement";
+import {
+  getBoundTextElement,
+  measureText,
+  redrawTextBoundingBox,
+} from "../element/textElement";
+import {
+  hasBoundTextElement,
+  isTextBindableContainer,
+} from "../element/typeChecks";
+import {
+  ExcalidrawTextContainer,
+  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",
+  contextItemPredicate: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return selectedElements.some((element) => hasBoundTextElement(element));
+  },
+  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,
+    };
+  },
+});
+
+export const actionBindText = register({
+  name: "bindText",
+  contextItemLabel: "labels.bindText",
+  contextItemPredicate: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState);
+
+    if (selectedElements.length === 2) {
+      const textElement =
+        isTextElement(selectedElements[0]) ||
+        isTextElement(selectedElements[1]);
+
+      let bindingContainer;
+      if (isTextBindableContainer(selectedElements[0])) {
+        bindingContainer = selectedElements[0];
+      } else if (isTextBindableContainer(selectedElements[1])) {
+        bindingContainer = selectedElements[1];
+      }
+      if (
+        textElement &&
+        bindingContainer &&
+        getBoundTextElement(bindingContainer) === null
+      ) {
+        return true;
+      }
+    }
+    return false;
+  },
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+
+    let textElement: ExcalidrawTextElement;
+    let container: ExcalidrawTextContainer;
+
+    if (
+      isTextElement(selectedElements[0]) &&
+      isTextBindableContainer(selectedElements[1])
+    ) {
+      textElement = selectedElements[0];
+      container = selectedElements[1];
+    } else {
+      textElement = selectedElements[1] as ExcalidrawTextElement;
+      container = selectedElements[0] as ExcalidrawTextContainer;
+    }
+    mutateElement(textElement, {
+      containerId: container.id,
+      verticalAlign: VERTICAL_ALIGN.MIDDLE,
+    });
+    mutateElement(container, {
+      boundElements: (container.boundElements || []).concat({
+        type: "text",
+        id: textElement.id,
+      }),
+    });
+    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,
+      appState: { ...appState, selectedElementIds: { [container.id]: true } },
+      commitToHistory: true,
+    };
+  },
+});

+ 4 - 20
src/actions/actionProperties.tsx

@@ -166,11 +166,7 @@ const changeFontSize = (
           let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
             fontSize: newFontSize,
           });
-          redrawTextBoundingBox(
-            newElement,
-            getContainerElement(oldElement),
-            appState,
-          );
+          redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 
           newElement = offsetElementAfterFontResize(oldElement, newElement);
 
@@ -637,11 +633,7 @@ export const actionChangeFontFamily = register({
                 fontFamily: value,
               },
             );
-            redrawTextBoundingBox(
-              newElement,
-              getContainerElement(oldElement),
-              appState,
-            );
+            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
             return newElement;
           }
 
@@ -720,11 +712,7 @@ export const actionChangeTextAlign = register({
               oldElement,
               { textAlign: value },
             );
-            redrawTextBoundingBox(
-              newElement,
-              getContainerElement(oldElement),
-              appState,
-            );
+            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
             return newElement;
           }
 
@@ -797,11 +785,7 @@ export const actionChangeVerticalAlign = register({
               { verticalAlign: value },
             );
 
-            redrawTextBoundingBox(
-              newElement,
-              getContainerElement(oldElement),
-              appState,
-            );
+            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
             return newElement;
           }
 

+ 1 - 5
src/actions/actionStyles.ts

@@ -63,11 +63,7 @@ export const actionPasteStyles = register({
               textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
             });
 
-            redrawTextBoundingBox(
-              newElement,
-              getContainerElement(newElement),
-              appState,
-            );
+            redrawTextBoundingBox(newElement, getContainerElement(newElement));
           }
           return newElement;
         }

+ 0 - 44
src/actions/actionUnbindText.tsx

@@ -1,44 +0,0 @@
-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,
-    };
-  },
-});

+ 1 - 1
src/actions/index.ts

@@ -81,5 +81,5 @@ export { actionToggleGridMode } from "./actionToggleGridMode";
 export { actionToggleZenMode } from "./actionToggleZenMode";
 
 export { actionToggleStats } from "./actionToggleStats";
-export { actionUnbindText } from "./actionUnbindText";
+export { actionUnbindText, actionBindText } from "./actionBoundText";
 export { actionLink } from "../element/Hyperlink";

+ 2 - 1
src/actions/types.ts

@@ -107,7 +107,8 @@ export type ActionName =
   | "decreaseFontSize"
   | "unbindText"
   | "hyperlink"
-  | "eraser";
+  | "eraser"
+  | "bindText";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 13 - 5
src/components/App.tsx

@@ -27,6 +27,7 @@ import {
   actionToggleStats,
   actionToggleZenMode,
   actionUnbindText,
+  actionBindText,
   actionUngroup,
   actionLink,
 } from "../actions";
@@ -5391,6 +5392,16 @@ class App extends React.Component<AppProps, AppState> {
       this.actionManager.getAppState(),
     );
 
+    const mayBeAllowUnbinding = actionUnbindText.contextItemPredicate(
+      this.actionManager.getElementsIncludingDeleted(),
+      this.actionManager.getAppState(),
+    );
+
+    const mayBeAllowBinding = actionBindText.contextItemPredicate(
+      this.actionManager.getElementsIncludingDeleted(),
+      this.actionManager.getAppState(),
+    );
+
     const separator = "separator";
 
     const elements = this.scene.getElements();
@@ -5467,10 +5478,6 @@ class App extends React.Component<AppProps, AppState> {
         });
       }
     } else if (type === "element") {
-      const elementsWithUnbindedText = getSelectedElements(
-        elements,
-        this.state,
-      ).some((element) => !hasBoundTextElement(element));
       if (this.state.viewModeEnabled) {
         ContextMenu.push({
           options: [navigator.clipboard && actionCopy, ...options],
@@ -5504,7 +5511,8 @@ class App extends React.Component<AppProps, AppState> {
             actionPasteStyles,
             separator,
             maybeGroupAction && actionGroup,
-            !elementsWithUnbindedText && actionUnbindText,
+            mayBeAllowUnbinding && actionUnbindText,
+            mayBeAllowBinding && actionBindText,
             maybeUngroupAction && actionUngroup,
             (maybeGroupAction || maybeUngroupAction) && separator,
             actionAddToLibrary,

+ 3 - 5
src/element/textElement.ts

@@ -10,13 +10,11 @@ import { mutateElement } from "./mutateElement";
 import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
 import { MaybeTransformHandleType } from "./transformHandles";
 import Scene from "../scene/Scene";
-import { AppState } from "../types";
 import { isTextElement } from ".";
 
 export const redrawTextBoundingBox = (
   element: ExcalidrawTextElement,
   container: ExcalidrawElement | null,
-  appState: AppState,
 ) => {
   const maxWidth = container
     ? container.width - BOUND_TEXT_PADDING * 2
@@ -35,12 +33,12 @@ export const redrawTextBoundingBox = (
     getFontString(element),
     maxWidth,
   );
-
   let coordY = element.y;
+  let coordX = element.x;
   // Resize container and vertically center align the text
   if (container) {
     let nextHeight = container.height;
-
+    coordX = container.x + BOUND_TEXT_PADDING;
     if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
       coordY = container.y + BOUND_TEXT_PADDING;
     } else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
@@ -55,12 +53,12 @@ export const redrawTextBoundingBox = (
     }
     mutateElement(container, { height: nextHeight });
   }
-
   mutateElement(element, {
     width: metrics.width,
     height: metrics.height,
     baseline: metrics.baseline,
     y: coordY,
+    x: coordX,
     text,
   });
 };

+ 1 - 0
src/locales/en.json

@@ -107,6 +107,7 @@
     "decreaseFontSize": "Decrease font size",
     "increaseFontSize": "Increase font size",
     "unbindText": "Unbind text",
+    "bindText": "Bind text to the container",
     "link": {
       "edit": "Edit link",
       "create": "Create link",