Переглянути джерело

feat: Allow non-WYSIWYG measurements and wrapping for text elements.

Daniel J. Geiger 2 роки тому
батько
коміт
7098013671

+ 6 - 6
src/actions/actionBoundText.tsx

@@ -5,7 +5,7 @@ import {
   computeBoundTextPosition,
   computeBoundTextPosition,
   computeContainerDimensionForBoundText,
   computeContainerDimensionForBoundText,
   getBoundTextElement,
   getBoundTextElement,
-  measureText,
+  measureTextElement,
   redrawTextBoundingBox,
   redrawTextBoundingBox,
 } from "../element/textElement";
 } from "../element/textElement";
 import {
 import {
@@ -25,7 +25,6 @@ import {
 } from "../element/types";
 } from "../element/types";
 import { getSelectedElements } from "../scene";
 import { getSelectedElements } from "../scene";
 import { AppState } from "../types";
 import { AppState } from "../types";
-import { getFontString } from "../utils";
 import { register } from "./register";
 import { register } from "./register";
 
 
 export const actionUnbindText = register({
 export const actionUnbindText = register({
@@ -45,10 +44,11 @@ export const actionUnbindText = register({
     selectedElements.forEach((element) => {
     selectedElements.forEach((element) => {
       const boundTextElement = getBoundTextElement(element);
       const boundTextElement = getBoundTextElement(element);
       if (boundTextElement) {
       if (boundTextElement) {
-        const { width, height, baseline } = measureText(
-          boundTextElement.originalText,
-          getFontString(boundTextElement),
-          boundTextElement.lineHeight,
+        const { width, height, baseline } = measureTextElement(
+          boundTextElement,
+          {
+            text: boundTextElement.originalText,
+          },
         );
         );
         const originalContainerHeight = getOriginalContainerHeightFromCache(
         const originalContainerHeight = getOriginalContainerHeightFromCache(
           element.id,
           element.id,

+ 8 - 8
src/data/restore.ts

@@ -31,14 +31,14 @@ import {
 import { getDefaultAppState } from "../appState";
 import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { bumpVersion } from "../element/mutateElement";
 import { bumpVersion } from "../element/mutateElement";
-import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
+import { getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { arrayToMap } from "../utils";
 import { arrayToMap } from "../utils";
 import oc from "open-color";
 import oc from "open-color";
 import { MarkOptional, Mutable } from "../utility-types";
 import { MarkOptional, Mutable } from "../utility-types";
 import {
 import {
   detectLineHeight,
   detectLineHeight,
   getDefaultLineHeight,
   getDefaultLineHeight,
-  measureBaseline,
+  measureTextElement,
 } from "../element/textElement";
 } from "../element/textElement";
 
 
 type RestoredAppState = Omit<
 type RestoredAppState = Omit<
@@ -80,7 +80,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
 };
 };
 
 
 const restoreElementWithProperties = <
 const restoreElementWithProperties = <
-  T extends Required<Omit<ExcalidrawElement, "customData">> & {
+  T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
+    subtype?: ExcalidrawElement["subtype"];
     customData?: ExcalidrawElement["customData"];
     customData?: ExcalidrawElement["customData"];
     /** @deprecated */
     /** @deprecated */
     boundElementIds?: readonly ExcalidrawElement["id"][];
     boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -143,6 +144,9 @@ const restoreElementWithProperties = <
     locked: element.locked ?? false,
     locked: element.locked ?? false,
   };
   };
 
 
+  if ("subtype" in element) {
+    base.subtype = element.subtype;
+  }
   if ("customData" in element) {
   if ("customData" in element) {
     base.customData = element.customData;
     base.customData = element.customData;
   }
   }
@@ -188,11 +192,7 @@ const restoreElement = (
           : // no element height likely means programmatic use, so default
           : // no element height likely means programmatic use, so default
             // to a fixed line height
             // to a fixed line height
             getDefaultLineHeight(element.fontFamily));
             getDefaultLineHeight(element.fontFamily));
-      const baseline = measureBaseline(
-        element.text,
-        getFontString(element),
-        lineHeight,
-      );
+      const baseline = measureTextElement(element, { text }).baseline;
       element = restoreElementWithProperties(element, {
       element = restoreElementWithProperties(element, {
         fontSize,
         fontSize,
         fontFamily,
         fontFamily,

+ 18 - 14
src/element/newElement.ts

@@ -13,7 +13,7 @@ import {
   FontFamilyValues,
   FontFamilyValues,
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
 } from "../element/types";
 } from "../element/types";
-import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
+import { getUpdatedTimestamp, isTestEnv } from "../utils";
 import { randomInteger, randomId } from "../random";
 import { randomInteger, randomId } from "../random";
 import { mutateElement, newElementWith } from "./mutateElement";
 import { mutateElement, newElementWith } from "./mutateElement";
 import { getNewGroupIdsForDuplication } from "../groups";
 import { getNewGroupIdsForDuplication } from "../groups";
@@ -25,9 +25,9 @@ import {
   getBoundTextElementOffset,
   getBoundTextElementOffset,
   getContainerDims,
   getContainerDims,
   getContainerElement,
   getContainerElement,
-  measureText,
+  measureTextElement,
   normalizeText,
   normalizeText,
-  wrapText,
+  wrapTextElement,
   getMaxContainerWidth,
   getMaxContainerWidth,
   getDefaultLineHeight,
   getDefaultLineHeight,
 } from "./textElement";
 } from "./textElement";
@@ -46,6 +46,8 @@ type ElementConstructorOpts = MarkOptional<
   | "version"
   | "version"
   | "versionNonce"
   | "versionNonce"
   | "link"
   | "link"
+  | "subtype"
+  | "customData"
 >;
 >;
 
 
 const _newElementBase = <T extends ExcalidrawElement>(
 const _newElementBase = <T extends ExcalidrawElement>(
@@ -143,7 +145,13 @@ export const newTextElement = (
 ): NonDeleted<ExcalidrawTextElement> => {
 ): NonDeleted<ExcalidrawTextElement> => {
   const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
   const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
   const text = normalizeText(opts.text);
   const text = normalizeText(opts.text);
-  const metrics = measureText(text, getFontString(opts), lineHeight);
+  const metrics = measureTextElement(
+    { ...opts, lineHeight },
+    {
+      text,
+      customData: opts.customData,
+    },
+  );
   const offsets = getTextElementPositionOffsets(opts, metrics);
   const offsets = getTextElementPositionOffsets(opts, metrics);
 
 
   const textElement = newElementWith(
   const textElement = newElementWith(
@@ -184,7 +192,9 @@ const getAdjustedDimensions = (
     width: nextWidth,
     width: nextWidth,
     height: nextHeight,
     height: nextHeight,
     baseline: nextBaseline,
     baseline: nextBaseline,
-  } = measureText(nextText, getFontString(element), element.lineHeight);
+  } = measureTextElement(element, {
+    text: nextText,
+  });
   const { textAlign, verticalAlign } = element;
   const { textAlign, verticalAlign } = element;
   let x: number;
   let x: number;
   let y: number;
   let y: number;
@@ -193,11 +203,7 @@ const getAdjustedDimensions = (
     verticalAlign === VERTICAL_ALIGN.MIDDLE &&
     verticalAlign === VERTICAL_ALIGN.MIDDLE &&
     !element.containerId
     !element.containerId
   ) {
   ) {
-    const prevMetrics = measureText(
-      element.text,
-      getFontString(element),
-      element.lineHeight,
-    );
+    const prevMetrics = measureTextElement(element);
     const offsets = getTextElementPositionOffsets(element, {
     const offsets = getTextElementPositionOffsets(element, {
       width: nextWidth - prevMetrics.width,
       width: nextWidth - prevMetrics.width,
       height: nextHeight - prevMetrics.height,
       height: nextHeight - prevMetrics.height,
@@ -274,11 +280,9 @@ export const refreshTextDimensions = (
   }
   }
   const container = getContainerElement(textElement);
   const container = getContainerElement(textElement);
   if (container) {
   if (container) {
-    text = wrapText(
+    text = wrapTextElement(textElement, getMaxContainerWidth(container), {
       text,
       text,
-      getFontString(textElement),
-      getMaxContainerWidth(container),
-    );
+    });
   }
   }
   const dimensions = getAdjustedDimensions(textElement, text);
   const dimensions = getAdjustedDimensions(textElement, text);
   return { text, ...dimensions };
   return { text, ...dimensions };

+ 39 - 20
src/element/textElement.ts

@@ -1,3 +1,4 @@
+import { getSubtypeMethods, SubtypeMethods } from "../subtypes";
 import { getFontString, arrayToMap, isTestEnv } from "../utils";
 import { getFontString, arrayToMap, isTestEnv } from "../utils";
 import {
 import {
   ExcalidrawElement,
   ExcalidrawElement,
@@ -34,6 +35,30 @@ import {
 } from "./textWysiwyg";
 } from "./textWysiwyg";
 import { ExtractSetType } from "../utility-types";
 import { ExtractSetType } from "../utility-types";
 
 
+export const measureTextElement = function (element, next) {
+  const map = getSubtypeMethods(element.subtype);
+  if (map?.measureText) {
+    return map.measureText(element, next);
+  }
+
+  const fontSize = next?.fontSize ?? element.fontSize;
+  const font = getFontString({ fontSize, fontFamily: element.fontFamily });
+  const text = next?.text ?? element.text;
+  return measureText(text, font, element.lineHeight);
+} as SubtypeMethods["measureText"];
+
+export const wrapTextElement = function (element, containerWidth, next) {
+  const map = getSubtypeMethods(element.subtype);
+  if (map?.wrapText) {
+    return map.wrapText(element, containerWidth, next);
+  }
+
+  const fontSize = next?.fontSize ?? element.fontSize;
+  const font = getFontString({ fontSize, fontFamily: element.fontFamily });
+  const text = next?.text ?? element.originalText;
+  return wrapText(text, font, containerWidth);
+} as SubtypeMethods["wrapText"];
+
 export const normalizeText = (text: string) => {
 export const normalizeText = (text: string) => {
   return (
   return (
     text
     text
@@ -66,22 +91,24 @@ export const redrawTextBoundingBox = (
 
 
   if (container) {
   if (container) {
     maxWidth = getMaxContainerWidth(container);
     maxWidth = getMaxContainerWidth(container);
-    boundTextUpdates.text = wrapText(
-      textElement.originalText,
-      getFontString(textElement),
-      maxWidth,
-    );
+    boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
   }
   }
-  const metrics = measureText(
-    boundTextUpdates.text,
-    getFontString(textElement),
-    textElement.lineHeight,
-  );
+  const metrics = measureTextElement(textElement, {
+    text: boundTextUpdates.text,
+  });
 
 
   boundTextUpdates.width = metrics.width;
   boundTextUpdates.width = metrics.width;
   boundTextUpdates.height = metrics.height;
   boundTextUpdates.height = metrics.height;
   boundTextUpdates.baseline = metrics.baseline;
   boundTextUpdates.baseline = metrics.baseline;
 
 
+  // Maintain coordX for non left-aligned text in case the width has changed
+  if (!container) {
+    if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
+      boundTextUpdates.x += textElement.width - metrics.width;
+    } else if (textElement.textAlign === TEXT_ALIGN.CENTER) {
+      boundTextUpdates.x += textElement.width / 2 - metrics.width / 2;
+    }
+  }
   if (container) {
   if (container) {
     if (isArrowElement(container)) {
     if (isArrowElement(container)) {
       const centerX = textElement.x + textElement.width / 2;
       const centerX = textElement.x + textElement.width / 2;
@@ -189,17 +216,9 @@ export const handleBindTextResize = (
     let nextBaseLine = textElement.baseline;
     let nextBaseLine = textElement.baseline;
     if (transformHandleType !== "n" && transformHandleType !== "s") {
     if (transformHandleType !== "n" && transformHandleType !== "s") {
       if (text) {
       if (text) {
-        text = wrapText(
-          textElement.originalText,
-          getFontString(textElement),
-          maxWidth,
-        );
+        text = wrapTextElement(textElement, maxWidth);
       }
       }
-      const metrics = measureText(
-        text,
-        getFontString(textElement),
-        textElement.lineHeight,
-      );
+      const metrics = measureTextElement(textElement, { text });
       nextHeight = metrics.height;
       nextHeight = metrics.height;
       nextWidth = metrics.width;
       nextWidth = metrics.width;
       nextBaseLine = metrics.baseline;
       nextBaseLine = metrics.baseline;

+ 58 - 8
src/element/textWysiwyg.tsx

@@ -47,6 +47,7 @@ import { LinearElementEditor } from "./linearElementEditor";
 import { parseClipboard } from "../clipboard";
 import { parseClipboard } from "../clipboard";
 
 
 const getTransform = (
 const getTransform = (
+  offsetX: number,
   width: number,
   width: number,
   height: number,
   height: number,
   angle: number,
   angle: number,
@@ -64,7 +65,7 @@ const getTransform = (
   if (height > maxHeight && zoom.value !== 1) {
   if (height > maxHeight && zoom.value !== 1) {
     translateY = (maxHeight * (zoom.value - 1)) / 2;
     translateY = (maxHeight * (zoom.value - 1)) / 2;
   }
   }
-  return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
+  return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg) translate(${offsetX}px, 0px)`;
 };
 };
 
 
 const originalContainerCache: {
 const originalContainerCache: {
@@ -158,13 +159,30 @@ export const textWysiwyg = ({
       const container = getContainerElement(updatedTextElement);
       const container = getContainerElement(updatedTextElement);
       let maxWidth = updatedTextElement.width;
       let maxWidth = updatedTextElement.width;
 
 
-      let maxHeight = updatedTextElement.height;
-      let textElementWidth = updatedTextElement.width;
+      // Editing metrics
+      const eMetrics = measureText(
+        container && updatedTextElement.containerId
+          ? wrapText(
+              updatedTextElement.originalText,
+              getFontString(updatedTextElement),
+              getMaxContainerWidth(container),
+            )
+          : updatedTextElement.originalText,
+        getFontString(updatedTextElement),
+        updatedTextElement.lineHeight,
+      );
+
+      let maxHeight = eMetrics.height;
+      let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
       // Set to element height by default since that's
       // Set to element height by default since that's
       // what is going to be used for unbounded text
       // what is going to be used for unbounded text
-      let textElementHeight = updatedTextElement.height;
+      let textElementHeight = Math.max(updatedTextElement.height, maxHeight);
 
 
       if (container && updatedTextElement.containerId) {
       if (container && updatedTextElement.containerId) {
+        textElementHeight = Math.min(
+          getMaxContainerHeight(container),
+          textElementHeight,
+        );
         if (isArrowElement(container)) {
         if (isArrowElement(container)) {
           const boundTextCoords =
           const boundTextCoords =
             LinearElementEditor.getBoundTextElementPosition(
             LinearElementEditor.getBoundTextElementPosition(
@@ -173,6 +191,8 @@ export const textWysiwyg = ({
             );
             );
           coordX = boundTextCoords.x;
           coordX = boundTextCoords.x;
           coordY = boundTextCoords.y;
           coordY = boundTextCoords.y;
+        } else {
+          coordX = Math.max(coordX, getContainerCoords(container).x);
         }
         }
         const propertiesUpdated = textPropertiesUpdated(
         const propertiesUpdated = textPropertiesUpdated(
           updatedTextElement,
           updatedTextElement,
@@ -186,7 +206,18 @@ export const textWysiwyg = ({
         }
         }
         if (propertiesUpdated) {
         if (propertiesUpdated) {
           // update height of the editor after properties updated
           // update height of the editor after properties updated
-          textElementHeight = updatedTextElement.height;
+          const font = getFontString(updatedTextElement);
+          textElementHeight =
+            updatedTextElement.lineHeight *
+            wrapText(
+              updatedTextElement.originalText,
+              font,
+              getMaxContainerWidth(container),
+            ).split("\n").length;
+          textElementHeight = Math.max(
+            textElementHeight,
+            updatedTextElement.height,
+          );
         }
         }
 
 
         let originalContainerData;
         let originalContainerData;
@@ -266,12 +297,29 @@ export const textWysiwyg = ({
         editable.selectionEnd = editable.value.length - diff;
         editable.selectionEnd = editable.value.length - diff;
       }
       }
 
 
+      let transformWidth = updatedTextElement.width;
       if (!container) {
       if (!container) {
         maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
         maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
         textElementWidth = Math.min(textElementWidth, maxWidth);
         textElementWidth = Math.min(textElementWidth, maxWidth);
       } else {
       } else {
         textElementWidth += 0.5;
         textElementWidth += 0.5;
+        transformWidth += 0.5;
       }
       }
+      // Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
+      const offWidth = container
+        ? Math.min(
+            0,
+            updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
+          )
+        : Math.min(maxWidth, updatedTextElement.width) -
+          Math.min(maxWidth, eMetrics.width);
+      const offsetX =
+        textAlign === "right"
+          ? offWidth
+          : textAlign === "center"
+          ? offWidth / 2
+          : 0;
+      const { width: w, height: h } = updatedTextElement;
 
 
       let lineHeight = updatedTextElement.lineHeight;
       let lineHeight = updatedTextElement.lineHeight;
 
 
@@ -290,13 +338,15 @@ export const textWysiwyg = ({
         font: getFontString(updatedTextElement),
         font: getFontString(updatedTextElement),
         // must be defined *after* font ¯\_(ツ)_/¯
         // must be defined *after* font ¯\_(ツ)_/¯
         lineHeight,
         lineHeight,
-        width: `${textElementWidth}px`,
+        width: `${Math.min(textElementWidth, maxWidth)}px`,
         height: `${textElementHeight}px`,
         height: `${textElementHeight}px`,
         left: `${viewportX}px`,
         left: `${viewportX}px`,
         top: `${viewportY}px`,
         top: `${viewportY}px`,
+        transformOrigin: `${w / 2}px ${h / 2}px`,
         transform: getTransform(
         transform: getTransform(
-          textElementWidth,
-          textElementHeight,
+          offsetX,
+          transformWidth,
+          updatedTextElement.height,
           getTextElementAngle(updatedTextElement),
           getTextElementAngle(updatedTextElement),
           appState,
           appState,
           maxWidth,
           maxWidth,

+ 2 - 0
src/element/types.ts

@@ -1,3 +1,4 @@
+import { Subtype } from "../subtypes";
 import { Point } from "../types";
 import { Point } from "../types";
 import {
 import {
   FONT_FAMILY,
   FONT_FAMILY,
@@ -64,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{
   updated: number;
   updated: number;
   link: string | null;
   link: string | null;
   locked: boolean;
   locked: boolean;
+  subtype?: Subtype;
   customData?: Record<string, any>;
   customData?: Record<string, any>;
 }>;
 }>;
 
 

+ 52 - 0
src/subtypes.ts

@@ -0,0 +1,52 @@
+import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types";
+
+// Subtype Names
+export type Subtype = string;
+
+// Subtype Methods
+export type SubtypeMethods = {
+  measureText: (
+    element: Pick<
+      ExcalidrawTextElement,
+      | "subtype"
+      | "customData"
+      | "fontSize"
+      | "fontFamily"
+      | "text"
+      | "lineHeight"
+    >,
+    next?: {
+      fontSize?: number;
+      text?: string;
+      customData?: ExcalidrawElement["customData"];
+    },
+  ) => { width: number; height: number; baseline: number };
+  wrapText: (
+    element: Pick<
+      ExcalidrawTextElement,
+      | "subtype"
+      | "customData"
+      | "fontSize"
+      | "fontFamily"
+      | "originalText"
+      | "lineHeight"
+    >,
+    containerWidth: number,
+    next?: {
+      fontSize?: number;
+      text?: string;
+      customData?: ExcalidrawElement["customData"];
+    },
+  ) => string;
+};
+
+type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
+const methodMaps = [] as Array<MethodMap>;
+
+// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
+export const getSubtypeMethods = (
+  subtype: Subtype | undefined,
+): Partial<SubtypeMethods> | undefined => {
+  const map = methodMaps.find((method) => method.subtype === subtype);
+  return map?.methods;
+};

+ 1 - 1
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"
   class="excalidraw-wysiwyg"
   data-type="wysiwyg"
   data-type="wysiwyg"
   dir="auto"
   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: 10.5px; height: 25px; left: 35px; top: 7.5px; 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: -7.5px; font: Emoji 20px 20px; line-height: 1.25; 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: 25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
   tabindex="0"
   tabindex="0"
   wrap="off"
   wrap="off"
 />
 />