Explorar o código

feat: support vertical text align for bound containers (#4852)

* feat: support vertical text align for bound containers

* update icons

* use const

* fix lint

* rename to  and show when text editor active

* don't update vertical align if not center

* fix svgs

* fix y coords when vertical align bottm

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi %!s(int64=3) %!d(string=hai) anos
pai
achega
8e26d5b500

+ 114 - 39
src/actions/actionProperties.tsx

@@ -30,11 +30,15 @@ import {
   TextAlignCenterIcon,
   TextAlignLeftIcon,
   TextAlignRightIcon,
+  TextAlignTopIcon,
+  TextAlignBottomIcon,
+  TextAlignMiddleIcon,
 } from "../components/icons";
 import {
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
   FONT_FAMILY,
+  VERTICAL_ALIGN,
 } from "../constants";
 import {
   getNonDeletedElements,
@@ -58,6 +62,7 @@ import {
   ExcalidrawTextElement,
   FontFamilyValues,
   TextAlign,
+  VerticalAlign,
 } from "../element/types";
 import { getLanguage, t } from "../i18n";
 import { KEYS } from "../keys";
@@ -713,9 +718,7 @@ export const actionChangeTextAlign = register({
           if (isTextElement(oldElement)) {
             const newElement: ExcalidrawTextElement = newElementWith(
               oldElement,
-              {
-                textAlign: value,
-              },
+              { textAlign: value },
             );
             redrawTextBoundingBox(
               newElement,
@@ -736,47 +739,119 @@ export const actionChangeTextAlign = register({
       commitToHistory: true,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
-    <fieldset>
-      <legend>{t("labels.textAlign")}</legend>
-      <ButtonIconSelect<TextAlign | false>
-        group="text-align"
-        options={[
-          {
-            value: "left",
-            text: t("labels.left"),
-            icon: <TextAlignLeftIcon theme={appState.theme} />,
-          },
-          {
-            value: "center",
-            text: t("labels.center"),
-            icon: <TextAlignCenterIcon theme={appState.theme} />,
-          },
-          {
-            value: "right",
-            text: t("labels.right"),
-            icon: <TextAlignRightIcon theme={appState.theme} />,
-          },
-        ]}
-        value={getFormValue(
-          elements,
-          appState,
-          (element) => {
-            if (isTextElement(element)) {
-              return element.textAlign;
+  PanelComponent: ({ elements, appState, updateData }) => {
+    return (
+      <fieldset>
+        <legend>{t("labels.textAlign")}</legend>
+        <ButtonIconSelect<TextAlign | false>
+          group="text-align"
+          options={[
+            {
+              value: "left",
+              text: t("labels.left"),
+              icon: <TextAlignLeftIcon theme={appState.theme} />,
+            },
+            {
+              value: "center",
+              text: t("labels.center"),
+              icon: <TextAlignCenterIcon theme={appState.theme} />,
+            },
+            {
+              value: "right",
+              text: t("labels.right"),
+              icon: <TextAlignRightIcon theme={appState.theme} />,
+            },
+          ]}
+          value={getFormValue(
+            elements,
+            appState,
+            (element) => {
+              if (isTextElement(element)) {
+                return element.textAlign;
+              }
+              const boundTextElement = getBoundTextElement(element);
+              if (boundTextElement) {
+                return boundTextElement.textAlign;
+              }
+              return null;
+            },
+            appState.currentItemTextAlign,
+          )}
+          onChange={(value) => updateData(value)}
+        />
+      </fieldset>
+    );
+  },
+});
+export const actionChangeVerticalAlign = register({
+  name: "changeVerticalAlign",
+  perform: (elements, appState, value) => {
+    return {
+      elements: changeProperty(
+        elements,
+        appState,
+        (oldElement) => {
+          if (isTextElement(oldElement)) {
+            const newElement: ExcalidrawTextElement = newElementWith(
+              oldElement,
+              { verticalAlign: value },
+            );
+
+            redrawTextBoundingBox(
+              newElement,
+              getContainerElement(oldElement),
+              appState,
+            );
+            return newElement;
+          }
+
+          return oldElement;
+        },
+        true,
+      ),
+      appState: {
+        ...appState,
+      },
+      commitToHistory: true,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => {
+    return (
+      <fieldset>
+        <ButtonIconSelect<VerticalAlign | false>
+          group="text-align"
+          options={[
+            {
+              value: VERTICAL_ALIGN.TOP,
+              text: t("labels.alignTop"),
+              icon: <TextAlignTopIcon theme={appState.theme} />,
+            },
+            {
+              value: VERTICAL_ALIGN.MIDDLE,
+              text: t("labels.centerVertically"),
+              icon: <TextAlignMiddleIcon theme={appState.theme} />,
+            },
+            {
+              value: VERTICAL_ALIGN.BOTTOM,
+              text: t("labels.alignBottom"),
+              icon: <TextAlignBottomIcon theme={appState.theme} />,
+            },
+          ]}
+          value={getFormValue(elements, appState, (element) => {
+            if (isTextElement(element) && element.containerId) {
+              return element.verticalAlign;
             }
             const boundTextElement = getBoundTextElement(element);
             if (boundTextElement) {
-              return boundTextElement.textAlign;
+              return boundTextElement.verticalAlign;
             }
             return null;
-          },
-          appState.currentItemTextAlign,
-        )}
-        onChange={(value) => updateData(value)}
-      />
-    </fieldset>
-  ),
+          })}
+          onChange={(value) => updateData(value)}
+        />
+      </fieldset>
+    );
+  },
 });
 
 export const actionChangeSharpness = register({

+ 1 - 0
src/actions/index.ts

@@ -17,6 +17,7 @@ export {
   actionChangeFontSize,
   actionChangeFontFamily,
   actionChangeTextAlign,
+  actionChangeVerticalAlign,
 } from "./actionProperties";
 
 export {

+ 1 - 0
src/actions/types.ts

@@ -82,6 +82,7 @@ export type ActionName =
   | "zoomToSelection"
   | "changeFontFamily"
   | "changeTextAlign"
+  | "changeVerticalAlign"
   | "toggleFullScreen"
   | "toggleShortcuts"
   | "group"

+ 7 - 2
src/charts.ts

@@ -1,5 +1,10 @@
 import colors from "./colors";
-import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
+import {
+  DEFAULT_FONT_FAMILY,
+  DEFAULT_FONT_SIZE,
+  ENV,
+  VERTICAL_ALIGN,
+} from "./constants";
 import { newElement, newLinearElement, newTextElement } from "./element";
 import { NonDeletedExcalidrawElement } from "./element/types";
 import { randomId } from "./random";
@@ -161,7 +166,7 @@ const commonProps = {
   strokeSharpness: "sharp",
   strokeStyle: "solid",
   strokeWidth: 1,
-  verticalAlign: "middle",
+  verticalAlign: VERTICAL_ALIGN.MIDDLE,
 } as const;
 
 const getChartDimentions = (spreadsheet: Spreadsheet) => {

+ 5 - 1
src/components/Actions.tsx

@@ -19,7 +19,7 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 import { hasStrokeColor } from "../scene/comparisons";
-import { hasBoundTextElement } from "../element/typeChecks";
+import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
 
 export const SelectedShapeActions = ({
   appState,
@@ -110,6 +110,10 @@ export const SelectedShapeActions = ({
         </>
       )}
 
+      {targetElements.every(
+        (element) =>
+          hasBoundTextElement(element) || isBoundToContainer(element),
+      ) && <>{renderAction("changeVerticalAlign")}</>}
       {(canHaveArrowheads(elementType) ||
         targetElements.some((element) => canHaveArrowheads(element.type))) && (
         <>{renderAction("changeArrowhead")}</>

+ 3 - 8
src/components/App.tsx

@@ -69,6 +69,7 @@ import {
   TOUCH_CTX_MENU_TIMEOUT,
   URL_HASH_KEYS,
   URL_QUERY_KEYS,
+  VERTICAL_ALIGN,
   ZOOM_STEP,
 } from "../constants";
 import { loadFromBlob } from "../data";
@@ -2225,7 +2226,7 @@ class App extends React.Component<AppProps, AppState> {
             ? "center"
             : this.state.currentItemTextAlign,
           verticalAlign: parentCenterPosition
-            ? "middle"
+            ? VERTICAL_ALIGN.MIDDLE
             : DEFAULT_VERTICAL_ALIGN,
           containerId: container?.id ?? undefined,
           groupIds: container?.groupIds ?? [],
@@ -2233,13 +2234,7 @@ class App extends React.Component<AppProps, AppState> {
 
     this.setState({ editingElement: element });
 
-    if (existingTextElement) {
-      // if text element is no longer centered to a container, reset
-      // verticalAlign to default because it's currently internal-only
-      if (!parentCenterPosition || element.textAlign !== "center") {
-        mutateElement(element, { verticalAlign: DEFAULT_VERTICAL_ALIGN });
-      }
-    } else {
+    if (!existingTextElement) {
       this.scene.replaceAllElements([
         ...this.scene.getElementsIncludingDeleted(),
         element,

+ 34 - 0
src/components/icons.tsx

@@ -885,6 +885,40 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
   ),
 );
 
+export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
+  createIcon(
+    <path
+      d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
+      fill={iconFillColor(theme)}
+      strokeLinecap="round"
+    />,
+    { width: 448, height: 512 },
+  ),
+);
+
+export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
+  createIcon(
+    <path
+      d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
+      fill={iconFillColor(theme)}
+      strokeLinecap="round"
+    />,
+    { width: 448, height: 512 },
+  ),
+);
+
+export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
+  createIcon(
+    <path
+      transform="matrix(1,0,0,1,0,80)"
+      d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
+      fill={iconFillColor(theme)}
+      strokeLinecap="round"
+    />,
+    { width: 448, height: 512 },
+  ),
+);
+
 export const publishIcon = createIcon(
   <path
     d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"

+ 6 - 0
src/constants.ts

@@ -182,3 +182,9 @@ export const VERSIONS = {
 } as const;
 
 export const BOUND_TEXT_PADDING = 5;
+
+export const VERTICAL_ALIGN = {
+  TOP: "top",
+  MIDDLE: "middle",
+  BOTTOM: "bottom",
+};

+ 2 - 2
src/element/newElement.ts

@@ -23,7 +23,7 @@ import { adjustXYWithRotation } from "../math";
 import { getResizedElementAbsoluteCoords } from "./bounds";
 import { getContainerElement, measureText, wrapText } from "./textElement";
 import { isBoundToContainer } from "./typeChecks";
-import { BOUND_TEXT_PADDING } from "../constants";
+import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
 
 type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -175,7 +175,7 @@ const getAdjustedDimensions = (
   let y: number;
   if (
     textAlign === "center" &&
-    verticalAlign === "middle" &&
+    verticalAlign === VERTICAL_ALIGN.MIDDLE &&
     !element.containerId
   ) {
     const prevMetrics = measureText(

+ 21 - 6
src/element/textElement.ts

@@ -8,7 +8,7 @@ import {
   NonDeletedExcalidrawElement,
 } from "./types";
 import { mutateElement } from "./mutateElement";
-import { BOUND_TEXT_PADDING } from "../constants";
+import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
 import { MaybeTransformHandleType } from "./transformHandles";
 import Scene from "../scene/Scene";
 import { AppState } from "../types";
@@ -39,11 +39,19 @@ export const redrawTextBoundingBox = (
   let coordY = element.y;
   // Resize container and vertically center align the text
   if (container) {
-    coordY = container.y + container.height / 2 - metrics.height / 2;
     let nextHeight = container.height;
-    if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
-      nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
-      coordY = container.y + nextHeight / 2 - metrics.height / 2;
+
+    if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
+      coordY = container.y + BOUND_TEXT_PADDING;
+    } else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+      coordY =
+        container.y + container.height - metrics.height - BOUND_TEXT_PADDING;
+    } else {
+      coordY = container.y + container.height / 2 - metrics.height / 2;
+      if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
+        nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
+        coordY = container.y + nextHeight / 2 - metrics.height / 2;
+      }
     }
     mutateElement(container, { height: nextHeight });
   }
@@ -142,7 +150,14 @@ export const handleBindTextResize = (
         });
       }
 
-      const updatedY = element.y + containerHeight / 2 - nextHeight / 2;
+      let updatedY;
+      if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
+        updatedY = element.y + BOUND_TEXT_PADDING;
+      } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+        updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
+      } else {
+        updatedY = element.y + element.height / 2 - nextHeight / 2;
+      }
 
       mutateElement(textElement, {
         text,

+ 12 - 4
src/element/textWysiwyg.tsx

@@ -7,7 +7,7 @@ import {
 } from "../utils";
 import Scene from "../scene/Scene";
 import { isBoundToContainer, isTextElement } from "./typeChecks";
-import { CLASSES, BOUND_TEXT_PADDING } from "../constants";
+import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
 import {
   ExcalidrawElement,
   ExcalidrawTextElement,
@@ -105,6 +105,8 @@ export const textWysiwyg = ({
     const updatedElement = Scene.getScene(element)?.getElement(
       id,
     ) as ExcalidrawTextElement;
+    const { textAlign, verticalAlign } = updatedElement;
+
     const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
     if (updatedElement && isTextElement(updatedElement)) {
       let coordX = updatedElement.x;
@@ -140,7 +142,7 @@ export const textWysiwyg = ({
         maxHeight = container.height - BOUND_TEXT_PADDING * 2;
         width = maxWidth;
         // The coordinates of text box set a distance of
-        // 30px to preserve padding
+        // 5px to preserve padding
         coordX = container.x + BOUND_TEXT_PADDING;
         // autogrow container height if text exceeds
         if (height > maxHeight) {
@@ -160,11 +162,16 @@ export const textWysiwyg = ({
         // is reached
         else {
           // vertically center align the text
-          coordY = container.y + container.height / 2 - height / 2;
+          if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
+            coordY = container.y + container.height / 2 - height / 2;
+          }
+          if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+            coordY =
+              container.y + container.height - height - BOUND_TEXT_PADDING;
+          }
         }
       }
       const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
-      const { textAlign } = updatedElement;
       const initialSelectionStart = editable.selectionStart;
       const initialSelectionEnd = editable.selectionEnd;
       const initialLength = editable.value.length;
@@ -212,6 +219,7 @@ export const textWysiwyg = ({
           editorMaxHeight,
         ),
         textAlign,
+        verticalAlign,
         color: updatedElement.strokeColor,
         opacity: updatedElement.opacity / 100,
         filter: "var(--theme-filter)",

+ 4 - 2
src/element/types.ts

@@ -1,5 +1,5 @@
 import { Point } from "../types";
-import { FONT_FAMILY, THEME } from "../constants";
+import { FONT_FAMILY, THEME, VERTICAL_ALIGN } from "../constants";
 
 export type ChartType = "bar" | "line";
 export type FillStyle = "hachure" | "cross-hatch" | "solid";
@@ -12,7 +12,9 @@ export type PointerType = "mouse" | "pen" | "touch";
 export type StrokeSharpness = "round" | "sharp";
 export type StrokeStyle = "solid" | "dashed" | "dotted";
 export type TextAlign = "left" | "center" | "right";
-export type VerticalAlign = "top" | "middle";
+
+type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
+export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
 
 type _ExcalidrawElementBase = Readonly<{
   id: string;

+ 12 - 2
src/renderer/renderElement.ts

@@ -29,7 +29,13 @@ import { isPathALoop } from "../math";
 import rough from "roughjs/bin/rough";
 import { AppState, BinaryFiles, Zoom } from "../types";
 import { getDefaultAppState } from "../appState";
-import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
+import {
+  BOUND_TEXT_PADDING,
+  MAX_DECIMALS_FOR_SVG_EXPORT,
+  MIME_TYPES,
+  SVG_NS,
+  VERTICAL_ALIGN,
+} from "../constants";
 import { getStroke, StrokeOptions } from "perfect-freehand";
 import { getApproxLineHeight } from "../element/textElement";
 
@@ -264,7 +270,11 @@ const drawElementOnCanvas = (
         const lineHeight = element.containerId
           ? getApproxLineHeight(getFontString(element))
           : element.height / lines.length;
-        const verticalOffset = element.height - element.baseline;
+        let verticalOffset = element.height - element.baseline;
+        if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+          verticalOffset = BOUND_TEXT_PADDING;
+        }
+
         const horizontalOffset =
           element.textAlign === "center"
             ? element.width / 2