ソースを参照

feat: support decreasing/increasing `fontSize` via keyboard (#4553)

Co-authored-by: david <dw@dw.local>
David Luzar 3 年 前
コミット
a51ed9ced6

+ 155 - 55
src/actions/actionProperties.tsx

@@ -41,9 +41,16 @@ import {
   isTextElement,
   redrawTextBoundingBox,
 } from "../element";
-import { newElementWith } from "../element/mutateElement";
-import { getBoundTextElement } from "../element/textElement";
-import { isLinearElement, isLinearElementType } from "../element/typeChecks";
+import { mutateElement, newElementWith } from "../element/mutateElement";
+import {
+  getBoundTextElement,
+  getContainerElement,
+} from "../element/textElement";
+import {
+  isBoundToContainer,
+  isLinearElement,
+  isLinearElementType,
+} from "../element/typeChecks";
 import {
   Arrowhead,
   ExcalidrawElement,
@@ -53,6 +60,7 @@ import {
   TextAlign,
 } from "../element/types";
 import { getLanguage, t } from "../i18n";
+import { KEYS } from "../keys";
 import { randomInteger } from "../random";
 import {
   canChangeSharpness,
@@ -63,10 +71,11 @@ import {
   isSomeElementSelected,
 } from "../scene";
 import { hasStrokeColor } from "../scene/comparisons";
-import Scene from "../scene/Scene";
 import { arrayToMap } from "../utils";
 import { register } from "./register";
 
+const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
+
 const changeProperty = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
@@ -108,6 +117,79 @@ const getFormValue = function <T>(
   );
 };
 
+const offsetElementAfterFontResize = (
+  prevElement: ExcalidrawTextElement,
+  nextElement: ExcalidrawTextElement,
+) => {
+  if (isBoundToContainer(nextElement)) {
+    return nextElement;
+  }
+  return mutateElement(
+    nextElement,
+    {
+      x:
+        prevElement.textAlign === "left"
+          ? prevElement.x
+          : prevElement.x +
+            (prevElement.width - nextElement.width) /
+              (prevElement.textAlign === "center" ? 2 : 1),
+      // centering vertically is non-standard, but for Excalidraw I think
+      // it makes sense
+      y: prevElement.y + (prevElement.height - nextElement.height) / 2,
+    },
+    false,
+  );
+};
+
+const changeFontSize = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  getNewFontSize: (element: ExcalidrawTextElement) => number,
+) => {
+  const newFontSizes = new Set<number>();
+
+  return {
+    elements: changeProperty(
+      elements,
+      appState,
+      (oldElement) => {
+        if (isTextElement(oldElement)) {
+          const newFontSize = getNewFontSize(oldElement);
+          newFontSizes.add(newFontSize);
+
+          let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
+            fontSize: newFontSize,
+          });
+          redrawTextBoundingBox(
+            newElement,
+            getContainerElement(oldElement),
+            appState,
+          );
+
+          newElement = offsetElementAfterFontResize(oldElement, newElement);
+
+          return newElement;
+        }
+
+        return oldElement;
+      },
+      true,
+    ),
+    appState: {
+      ...appState,
+      // update state only if we've set all select text elements to
+      // the same font size
+      currentItemFontSize:
+        newFontSizes.size === 1
+          ? [...newFontSizes][0]
+          : appState.currentItemFontSize,
+    },
+    commitToHistory: true,
+  };
+};
+
+// -----------------------------------------------------------------------------
+
 export const actionChangeStrokeColor = register({
   name: "changeStrokeColor",
   perform: (elements, appState, value) => {
@@ -438,33 +520,7 @@ export const actionChangeOpacity = register({
 export const actionChangeFontSize = register({
   name: "changeFontSize",
   perform: (elements, appState, value) => {
-    return {
-      elements: changeProperty(
-        elements,
-        appState,
-        (el) => {
-          if (isTextElement(el)) {
-            const element: ExcalidrawTextElement = newElementWith(el, {
-              fontSize: value,
-            });
-            let container = null;
-            if (el.containerId) {
-              container = Scene.getScene(el)!.getElement(el.containerId);
-            }
-            redrawTextBoundingBox(element, container, appState);
-            return element;
-          }
-
-          return el;
-        },
-        true,
-      ),
-      appState: {
-        ...appState,
-        currentItemFontSize: value,
-      },
-      commitToHistory: true,
-    };
+    return changeFontSize(elements, appState, () => value);
   },
   PanelComponent: ({ elements, appState, updateData }) => (
     <fieldset>
@@ -514,6 +570,44 @@ export const actionChangeFontSize = register({
   ),
 });
 
+export const actionDecreaseFontSize = register({
+  name: "decreaseFontSize",
+  perform: (elements, appState, value) => {
+    return changeFontSize(elements, appState, (element) =>
+      Math.round(
+        // get previous value before relative increase (doesn't work fully
+        // due to rounding and float precision issues)
+        (1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
+      ),
+    );
+  },
+  keyTest: (event) => {
+    return (
+      event[KEYS.CTRL_OR_CMD] &&
+      event.shiftKey &&
+      // KEYS.COMMA needed for MacOS
+      (event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
+    );
+  },
+});
+
+export const actionIncreaseFontSize = register({
+  name: "increaseFontSize",
+  perform: (elements, appState, value) => {
+    return changeFontSize(elements, appState, (element) =>
+      Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
+    );
+  },
+  keyTest: (event) => {
+    return (
+      event[KEYS.CTRL_OR_CMD] &&
+      event.shiftKey &&
+      // KEYS.PERIOD needed for MacOS
+      (event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
+    );
+  },
+});
+
 export const actionChangeFontFamily = register({
   name: "changeFontFamily",
   perform: (elements, appState, value) => {
@@ -521,20 +615,23 @@ export const actionChangeFontFamily = register({
       elements: changeProperty(
         elements,
         appState,
-        (el) => {
-          if (isTextElement(el)) {
-            const element: ExcalidrawTextElement = newElementWith(el, {
-              fontFamily: value,
-            });
-            let container = null;
-            if (el.containerId) {
-              container = Scene.getScene(el)!.getElement(el.containerId);
-            }
-            redrawTextBoundingBox(element, container, appState);
-            return element;
+        (oldElement) => {
+          if (isTextElement(oldElement)) {
+            const newElement: ExcalidrawTextElement = newElementWith(
+              oldElement,
+              {
+                fontFamily: value,
+              },
+            );
+            redrawTextBoundingBox(
+              newElement,
+              getContainerElement(oldElement),
+              appState,
+            );
+            return newElement;
           }
 
-          return el;
+          return oldElement;
         },
         true,
       ),
@@ -603,20 +700,23 @@ export const actionChangeTextAlign = register({
       elements: changeProperty(
         elements,
         appState,
-        (el) => {
-          if (isTextElement(el)) {
-            const element: ExcalidrawTextElement = newElementWith(el, {
-              textAlign: value,
-            });
-            let container = null;
-            if (el.containerId) {
-              container = Scene.getScene(el)!.getElement(el.containerId);
-            }
-            redrawTextBoundingBox(element, container, appState);
-            return element;
+        (oldElement) => {
+          if (isTextElement(oldElement)) {
+            const newElement: ExcalidrawTextElement = newElementWith(
+              oldElement,
+              {
+                textAlign: value,
+              },
+            );
+            redrawTextBoundingBox(
+              newElement,
+              getContainerElement(oldElement),
+              appState,
+            );
+            return newElement;
           }
 
-          return el;
+          return oldElement;
         },
         true,
       ),

+ 4 - 12
src/actions/actionStyles.ts

@@ -12,9 +12,7 @@ import {
   DEFAULT_FONT_FAMILY,
   DEFAULT_TEXT_ALIGN,
 } from "../constants";
-import Scene from "../scene/Scene";
-import { isBoundToContainer } from "../element/typeChecks";
-import { ExcalidrawTextElement } from "../element/types";
+import { getContainerElement } from "../element/textElement";
 
 // `copiedStyles` is exported only for tests.
 export let copiedStyles: string = "{}";
@@ -58,22 +56,16 @@ export const actionPasteStyles = register({
             opacity: pastedElement?.opacity,
             roughness: pastedElement?.roughness,
           });
-          if (isTextElement(newElement)) {
+          if (isTextElement(newElement) && isTextElement(element)) {
             mutateElement(newElement, {
               fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
               fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
               textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
             });
-            let container = null;
 
-            if (isBoundToContainer(element)) {
-              container = Scene.getScene(element)!.getElement(
-                element.containerId,
-              );
-            }
             redrawTextBoundingBox(
-              element as ExcalidrawTextElement,
-              container,
+              element,
+              getContainerElement(element),
               appState,
             );
           }

+ 3 - 1
src/actions/types.ts

@@ -101,7 +101,9 @@ export type ActionName =
   | "flipVertical"
   | "viewMode"
   | "exportWithDarkMode"
-  | "toggleTheme";
+  | "toggleTheme"
+  | "increaseFontSize"
+  | "decreaseFontSize";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 4 - 1
src/components/App.tsx

@@ -1649,7 +1649,10 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (
-        (isWritableElement(event.target) && event.key !== KEYS.ESCAPE) ||
+        (isWritableElement(event.target) &&
+          event.key !== KEYS.ESCAPE &&
+          // handle cmd/ctrl-modifier shortcuts even inside inputs
+          !event[KEYS.CTRL_OR_CMD]) ||
         // case: using arrows to move between buttons
         (isArrowKey(event.key) && isInputLike(event.target))
       ) {

+ 8 - 0
src/components/HelpDialog.tsx

@@ -394,6 +394,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
                   label={t("labels.showBackground")}
                   shortcuts={[getShortcutKey("G")]}
                 />
+                <Shortcut
+                  label={t("labels.decreaseFontSize")}
+                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
+                />
+                <Shortcut
+                  label={t("labels.increaseFontSize")}
+                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]}
+                />
               </ShortcutIsland>
             </Column>
           </Columns>

+ 4 - 5
src/element/newElement.ts

@@ -21,9 +21,8 @@ import { AppState } from "../types";
 import { getElementAbsoluteCoords } from ".";
 import { adjustXYWithRotation } from "../math";
 import { getResizedElementAbsoluteCoords } from "./bounds";
-import { measureText } from "./textElement";
+import { getContainerElement, measureText } from "./textElement";
 import { isBoundToContainer } from "./typeChecks";
-import Scene from "../scene/Scene";
 import { BOUND_TEXT_PADDING } from "../constants";
 
 type ElementConstructorOpts = MarkOptional<
@@ -159,8 +158,8 @@ const getAdjustedDimensions = (
   baseline: number;
 } => {
   let maxWidth = null;
-  if (element.containerId) {
-    const container = Scene.getScene(element)!.getElement(element.containerId)!;
+  const container = getContainerElement(element);
+  if (container) {
     maxWidth = container.width - BOUND_TEXT_PADDING * 2;
   }
   const {
@@ -220,7 +219,7 @@ const getAdjustedDimensions = (
   // make sure container dimensions are set properly when
   // text editor overflows beyond viewport dimensions
   if (isBoundToContainer(element)) {
-    const container = Scene.getScene(element)!.getElement(element.containerId)!;
+    const container = getContainerElement(element)!;
     let height = container.height;
     let width = container.width;
     if (nextHeight > height - BOUND_TEXT_PADDING * 2) {

+ 19 - 3
src/element/textElement.ts

@@ -416,9 +416,25 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
   }
   const boundTextElementId = getBoundTextElementId(element);
   if (boundTextElementId) {
-    return Scene.getScene(element)!.getElement(
-      boundTextElementId,
-    ) as ExcalidrawTextElementWithContainer;
+    return (
+      (Scene.getScene(element)?.getElement(
+        boundTextElementId,
+      ) as ExcalidrawTextElementWithContainer) || null
+    );
+  }
+  return null;
+};
+
+export const getContainerElement = (
+  element:
+    | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
+    | null,
+) => {
+  if (!element) {
+    return null;
+  }
+  if (element.containerId) {
+    return Scene.getScene(element)?.getElement(element.containerId) || null;
   }
   return null;
 };

+ 10 - 15
src/element/textWysiwyg.tsx

@@ -8,16 +8,13 @@ import {
 import Scene from "../scene/Scene";
 import { isBoundToContainer, isTextElement } from "./typeChecks";
 import { CLASSES, BOUND_TEXT_PADDING } from "../constants";
-import {
-  ExcalidrawBindableElement,
-  ExcalidrawElement,
-  ExcalidrawTextElement,
-} from "./types";
+import { ExcalidrawElement, ExcalidrawTextElement } from "./types";
 import { AppState } from "../types";
 import { mutateElement } from "./mutateElement";
 import {
   getApproxLineHeight,
   getBoundTextElementId,
+  getContainerElement,
   wrapText,
 } from "./textElement";
 
@@ -102,9 +99,7 @@ export const textWysiwyg = ({
     if (updatedElement && isTextElement(updatedElement)) {
       let coordX = updatedElement.x;
       let coordY = updatedElement.y;
-      const container = updatedElement?.containerId
-        ? Scene.getScene(updatedElement)!.getElement(updatedElement.containerId)
-        : null;
+      const container = getContainerElement(updatedElement);
       let maxWidth = updatedElement.width;
 
       let maxHeight = updatedElement.height;
@@ -274,9 +269,7 @@ export const textWysiwyg = ({
         let height = "auto";
 
         if (lines === 2) {
-          const container = Scene.getScene(element)!.getElement(
-            element.containerId,
-          );
+          const container = getContainerElement(element);
           const actualLineCount = wrapText(
             editable.value,
             getFontString(element),
@@ -300,13 +293,16 @@ export const textWysiwyg = ({
   }
 
   editable.onkeydown = (event) => {
-    event.stopPropagation();
+    if (!event[KEYS.CTRL_OR_CMD]) {
+      event.stopPropagation();
+    }
     if (event.key === KEYS.ESCAPE) {
       event.preventDefault();
       submittedViaKeyboard = true;
       handleSubmit();
     } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
       event.preventDefault();
+      event.stopPropagation();
       if (event.isComposing || event.keyCode === 229) {
         return;
       }
@@ -319,6 +315,7 @@ export const textWysiwyg = ({
           event.code === CODES.BRACKET_RIGHT))
     ) {
       event.preventDefault();
+      event.stopPropagation();
       if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
         outdent();
       } else {
@@ -443,9 +440,7 @@ export const textWysiwyg = ({
     }
     let wrappedText = "";
     if (isTextElement(updateElement) && updateElement?.containerId) {
-      const container = Scene.getScene(updateElement)!.getElement(
-        updateElement.containerId,
-      ) as ExcalidrawBindableElement;
+      const container = getContainerElement(updateElement);
 
       if (container) {
         wrappedText = wrapText(

+ 4 - 0
src/keys.ts

@@ -40,6 +40,10 @@ export const KEYS = {
   QUESTION_MARK: "?",
   SPACE: " ",
   TAB: "Tab",
+  CHEVRON_LEFT: "<",
+  CHEVRON_RIGHT: ">",
+  PERIOD: ".",
+  COMMA: ",",
 
   A: "a",
   D: "d",

+ 3 - 1
src/locales/en.json

@@ -102,7 +102,9 @@
     "showBackground": "Show background color picker",
     "toggleTheme": "Toggle theme",
     "personalLib": "Personal Library",
-    "excalidrawLib": "Excalidraw Library"
+    "excalidrawLib": "Excalidraw Library",
+    "decreaseFontSize": "Decrease font size",
+    "increaseFontSize": "Increase font size"
   },
   "buttons": {
     "clearReset": "Reset the canvas",