Browse Source

feat: add crowfoot to arrowheads (#8942)

* crowfoot many

* crowfoot one

* one or many

* add icons for crowfoot

* add crowfoot icons

* adjust arrowhead selection popover

* make options collapsible

* swap triangle and bar

* switch to radix popover

* put triangle outline in the first row

* align shadow with new design spec

* remove unused flag

* swap order

* tweak labels

* handle shift+tab

---------

Co-authored-by: dwelle <[email protected]>
Co-authored-by: Jakub Królak <[email protected]>
Ryan Di 7 months ago
parent
commit
d33e42e3a1

+ 40 - 29
packages/excalidraw/actions/actionProperties.tsx

@@ -53,6 +53,9 @@ import {
   sharpArrowIcon,
   roundArrowIcon,
   elbowArrowIcon,
+  ArrowheadCrowfootIcon,
+  ArrowheadCrowfootOneIcon,
+  ArrowheadCrowfootOneOrManyIcon,
 } from "../components/icons";
 import {
   ARROW_TYPE,
@@ -1406,58 +1409,64 @@ const getArrowheadOptions = (flip: boolean) => {
       icon: <ArrowheadArrowIcon flip={flip} />,
     },
     {
-      value: "bar",
-      text: t("labels.arrowhead_bar"),
+      value: "triangle",
+      text: t("labels.arrowhead_triangle"),
+      icon: <ArrowheadTriangleIcon flip={flip} />,
       keyBinding: "e",
-      icon: <ArrowheadBarIcon flip={flip} />,
     },
     {
-      value: "dot",
-      text: t("labels.arrowhead_circle"),
-      keyBinding: null,
-      icon: <ArrowheadCircleIcon flip={flip} />,
-      showInPicker: false,
+      value: "triangle_outline",
+      text: t("labels.arrowhead_triangle_outline"),
+      icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
+      keyBinding: "r",
     },
     {
       value: "circle",
       text: t("labels.arrowhead_circle"),
-      keyBinding: "r",
+      keyBinding: "a",
       icon: <ArrowheadCircleIcon flip={flip} />,
-      showInPicker: false,
     },
     {
       value: "circle_outline",
       text: t("labels.arrowhead_circle_outline"),
-      keyBinding: null,
+      keyBinding: "s",
       icon: <ArrowheadCircleOutlineIcon flip={flip} />,
-      showInPicker: false,
-    },
-    {
-      value: "triangle",
-      text: t("labels.arrowhead_triangle"),
-      icon: <ArrowheadTriangleIcon flip={flip} />,
-      keyBinding: "t",
-    },
-    {
-      value: "triangle_outline",
-      text: t("labels.arrowhead_triangle_outline"),
-      icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
-      keyBinding: null,
-      showInPicker: false,
     },
     {
       value: "diamond",
       text: t("labels.arrowhead_diamond"),
       icon: <ArrowheadDiamondIcon flip={flip} />,
-      keyBinding: null,
-      showInPicker: false,
+      keyBinding: "d",
     },
     {
       value: "diamond_outline",
       text: t("labels.arrowhead_diamond_outline"),
       icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
-      keyBinding: null,
-      showInPicker: false,
+      keyBinding: "f",
+    },
+    {
+      value: "bar",
+      text: t("labels.arrowhead_bar"),
+      keyBinding: "z",
+      icon: <ArrowheadBarIcon flip={flip} />,
+    },
+    {
+      value: "crowfoot_one",
+      text: t("labels.arrowhead_crowfoot_one"),
+      icon: <ArrowheadCrowfootOneIcon flip={flip} />,
+      keyBinding: "c",
+    },
+    {
+      value: "crowfoot_many",
+      text: t("labels.arrowhead_crowfoot_many"),
+      icon: <ArrowheadCrowfootIcon flip={flip} />,
+      keyBinding: "x",
+    },
+    {
+      value: "crowfoot_one_or_many",
+      text: t("labels.arrowhead_crowfoot_one_or_many"),
+      icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
+      keyBinding: "v",
     },
   ] as const;
 };
@@ -1521,6 +1530,7 @@ export const actionChangeArrowhead = register({
               appState.currentItemStartArrowhead,
             )}
             onChange={(value) => updateData({ position: "start", type: value })}
+            numberOfOptionsToAlwaysShow={4}
           />
           <IconPicker
             label="arrowhead_end"
@@ -1537,6 +1547,7 @@ export const actionChangeArrowhead = register({
               appState.currentItemEndArrowhead,
             )}
             onChange={(value) => updateData({ position: "end", type: value })}
+            numberOfOptionsToAlwaysShow={4}
           />
         </div>
       </fieldset>

+ 11 - 45
packages/excalidraw/components/IconPicker.scss

@@ -1,19 +1,16 @@
 @import "../css/variables.module.scss";
 
 .excalidraw {
-  .picker-container {
-    display: inline-block;
-    box-sizing: border-box;
-    margin-right: 0.25rem;
-  }
-
   .picker {
+    padding: 0.5rem;
     background: var(--popup-bg-color);
     border: 0 solid transparentize($oc-white, 0.75);
-    // ˇˇ yeah, i dunno, open to suggestions here :D
-    box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
+    box-shadow: var(--shadow-island);
     border-radius: 4px;
     position: absolute;
+    :root[dir="rtl"] & {
+      padding: 0.4rem;
+    }
   }
 
   .picker-container button,
@@ -55,47 +52,16 @@
     padding: 0.25rem 0.28rem 0.35rem 0.25rem;
   }
 
-  .picker-triangle {
-    width: 0;
-    height: 0;
-    position: relative;
-    top: -10px;
-    :root[dir="ltr"] & {
-      left: 12px;
-    }
-
-    :root[dir="rtl"] & {
-      right: 12px;
-    }
-    z-index: 10;
-
-    &:before {
-      content: "";
-      position: absolute;
-      border-style: solid;
-      border-width: 0 9px 10px;
-      border-color: transparent transparent transparentize($oc-black, 0.9);
-      top: -1px;
-    }
-
-    &:after {
-      content: "";
-      position: absolute;
-      border-style: solid;
-      border-width: 0 9px 10px;
-      border-color: transparent transparent var(--popup-bg-color);
-    }
-  }
-
   .picker-content {
-    padding: 0.5rem;
     display: grid;
-    grid-template-columns: repeat(3, auto);
+    grid-template-columns: repeat(4, auto);
     grid-gap: 0.5rem;
     border-radius: 4px;
-    :root[dir="rtl"] & {
-      padding: 0.4rem;
-    }
+  }
+
+  .picker-collapsible {
+    font-size: 0.75rem;
+    padding: 0.5rem 0;
   }
 
   .picker-keybinding {

+ 131 - 98
packages/excalidraw/components/IconPicker.tsx

@@ -1,10 +1,23 @@
-import React from "react";
-import { Popover } from "./Popover";
+import React, { useEffect } from "react";
+import * as Popover from "@radix-ui/react-popover";
 
 import "./IconPicker.scss";
 import { isArrowKey, KEYS } from "../keys";
-import { getLanguage } from "../i18n";
+import { getLanguage, t } from "../i18n";
 import clsx from "clsx";
+import Collapsible from "./Stats/Collapsible";
+import { atom, useAtom } from "jotai";
+import { jotaiScope } from "../jotai";
+import { useDevice } from "..";
+
+const moreOptionsAtom = atom(false);
+
+type Option<T> = {
+  value: T;
+  text: string;
+  icon: JSX.Element;
+  keyBinding: string | null;
+};
 
 function Picker<T>({
   options,
@@ -12,30 +25,16 @@ function Picker<T>({
   label,
   onChange,
   onClose,
+  numberOfOptionsToAlwaysShow = options.length,
 }: {
   label: string;
   value: T;
-  options: {
-    value: T;
-    text: string;
-    icon: JSX.Element;
-    keyBinding: string | null;
-  }[];
+  options: readonly Option<T>[];
   onChange: (value: T) => void;
   onClose: () => void;
+  numberOfOptionsToAlwaysShow?: number;
 }) {
-  const rFirstItem = React.useRef<HTMLButtonElement>();
-  const rActiveItem = React.useRef<HTMLButtonElement>();
-  const rGallery = React.useRef<HTMLDivElement>(null);
-
-  React.useEffect(() => {
-    // After the component is first mounted focus on first input
-    if (rActiveItem.current) {
-      rActiveItem.current.focus();
-    } else if (rGallery.current) {
-      rGallery.current.focus();
-    }
-  }, []);
+  const device = useDevice();
 
   const handleKeyDown = (event: React.KeyboardEvent) => {
     const pressedOption = options.find(
@@ -44,28 +43,19 @@ function Picker<T>({
 
     if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
       // Keybinding navigation
-      const index = options.indexOf(pressedOption);
-      (rGallery!.current!.children![index] as any).focus();
+      onChange(pressedOption.value);
+
       event.preventDefault();
     } else if (event.key === KEYS.TAB) {
-      // Tab navigation cycle through options. If the user tabs
-      // away from the picker, close the picker. We need to use
-      // a timeout here to let the stack clear before checking.
-      setTimeout(() => {
-        const active = rActiveItem.current;
-        const docActive = document.activeElement;
-        if (active !== docActive) {
-          onClose();
-        }
-      }, 0);
+      const index = options.findIndex((option) => option.value === value);
+      const nextIndex = event.shiftKey
+        ? (options.length + index - 1) % options.length
+        : (index + 1) % options.length;
+      onChange(options[nextIndex].value);
     } else if (isArrowKey(event.key)) {
       // Arrow navigation
-      const { activeElement } = document;
       const isRTL = getLanguage().rtl;
-      const index = Array.prototype.indexOf.call(
-        rGallery!.current!.children,
-        activeElement,
-      );
+      const index = options.findIndex((option) => option.value === value);
       if (index !== -1) {
         const length = options.length;
         let nextIndex = index;
@@ -73,19 +63,26 @@ function Picker<T>({
         switch (event.key) {
           // Select the next option
           case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
-          case KEYS.ARROW_DOWN: {
             nextIndex = (index + 1) % length;
             break;
-          }
           // Select the previous option
           case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
-          case KEYS.ARROW_UP: {
             nextIndex = (length + index - 1) % length;
             break;
+          // Go the next row
+          case KEYS.ARROW_DOWN: {
+            nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
+            break;
+          }
+          // Go the previous row
+          case KEYS.ARROW_UP: {
+            nextIndex =
+              (length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
+            break;
           }
         }
 
-        (rGallery.current!.children![nextIndex] as any).focus();
+        onChange(options[nextIndex].value);
       }
       event.preventDefault();
     } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -97,15 +94,29 @@ function Picker<T>({
     event.stopPropagation();
   };
 
-  return (
-    <div
-      className={`picker`}
-      role="dialog"
-      aria-modal="true"
-      aria-label={label}
-      onKeyDown={handleKeyDown}
-    >
-      <div className="picker-content" ref={rGallery}>
+  const [showMoreOptions, setShowMoreOptions] = useAtom(
+    moreOptionsAtom,
+    jotaiScope,
+  );
+
+  const alwaysVisibleOptions = React.useMemo(
+    () => options.slice(0, numberOfOptionsToAlwaysShow),
+    [options, numberOfOptionsToAlwaysShow],
+  );
+  const moreOptions = React.useMemo(
+    () => options.slice(numberOfOptionsToAlwaysShow),
+    [options, numberOfOptionsToAlwaysShow],
+  );
+
+  useEffect(() => {
+    if (!alwaysVisibleOptions.some((option) => option.value === value)) {
+      setShowMoreOptions(true);
+    }
+  }, [value, alwaysVisibleOptions, setShowMoreOptions]);
+
+  const renderOptions = (options: Option<T>[]) => {
+    return (
+      <div className="picker-content">
         {options.map((option, i) => (
           <button
             type="button"
@@ -113,7 +124,6 @@ function Picker<T>({
               active: value === option.value,
             })}
             onClick={(event) => {
-              (event.currentTarget as HTMLButtonElement).focus();
               onChange(option.value);
             }}
             title={`${option.text} ${
@@ -122,17 +132,14 @@ function Picker<T>({
             aria-label={option.text || "none"}
             aria-keyshortcuts={option.keyBinding || undefined}
             key={option.text}
-            ref={(el) => {
-              if (el && i === 0) {
-                rFirstItem.current = el;
-              }
-              if (el && option.value === value) {
-                rActiveItem.current = el;
+            ref={(ref) => {
+              if (value === option.value) {
+                // Use a timeout here to render focus properly
+                setTimeout(() => {
+                  ref?.focus();
+                }, 0);
               }
             }}
-            onFocus={() => {
-              onChange(option.value);
-            }}
           >
             {option.icon}
             {option.keyBinding && (
@@ -141,7 +148,43 @@ function Picker<T>({
           </button>
         ))}
       </div>
-    </div>
+    );
+  };
+
+  return (
+    <Popover.Content
+      side={
+        device.editor.isMobile && !device.viewport.isLandscape
+          ? "top"
+          : "bottom"
+      }
+      align="start"
+      sideOffset={12}
+      style={{ zIndex: "var(--zIndex-popup)" }}
+      onKeyDown={handleKeyDown}
+    >
+      <div
+        className={`picker`}
+        role="dialog"
+        aria-modal="true"
+        aria-label={label}
+      >
+        {renderOptions(alwaysVisibleOptions)}
+
+        {moreOptions.length > 0 && (
+          <Collapsible
+            label={t("labels.more_options")}
+            open={showMoreOptions}
+            openTrigger={() => {
+              setShowMoreOptions((value) => !value);
+            }}
+            className="picker-collapsible"
+          >
+            {renderOptions(moreOptions)}
+          </Collapsible>
+        )}
+      </div>
+    </Popover.Content>
   );
 }
 
@@ -151,6 +194,7 @@ export function IconPicker<T>({
   options,
   onChange,
   group = "",
+  numberOfOptionsToAlwaysShow,
 }: {
   label: string;
   value: T;
@@ -159,51 +203,40 @@ export function IconPicker<T>({
     text: string;
     icon: JSX.Element;
     keyBinding: string | null;
-    showInPicker?: boolean;
   }[];
   onChange: (value: T) => void;
+  numberOfOptionsToAlwaysShow?: number;
   group?: string;
 }) {
   const [isActive, setActive] = React.useState(false);
   const rPickerButton = React.useRef<any>(null);
-  const isRTL = getLanguage().rtl;
 
   return (
     <div>
-      <button
-        name={group}
-        type="button"
-        className={isActive ? "active" : ""}
-        aria-label={label}
-        onClick={() => setActive(!isActive)}
-        ref={rPickerButton}
-      >
-        {options.find((option) => option.value === value)?.icon}
-      </button>
-      <React.Suspense fallback="">
-        {isActive ? (
-          <>
-            <Popover
-              onCloseRequest={(event) =>
-                event.target !== rPickerButton.current && setActive(false)
-              }
-              {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
-            >
-              <Picker
-                options={options.filter((opt) => opt.showInPicker !== false)}
-                value={value}
-                label={label}
-                onChange={onChange}
-                onClose={() => {
-                  setActive(false);
-                  rPickerButton.current?.focus();
-                }}
-              />
-            </Popover>
-            <div className="picker-triangle" />
-          </>
-        ) : null}
-      </React.Suspense>
+      <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
+        <Popover.Trigger
+          name={group}
+          type="button"
+          aria-label={label}
+          onClick={() => setActive(!isActive)}
+          ref={rPickerButton}
+          className={isActive ? "active" : ""}
+        >
+          {options.find((option) => option.value === value)?.icon}
+        </Popover.Trigger>
+        {isActive && (
+          <Picker
+            options={options}
+            value={value}
+            label={label}
+            onChange={onChange}
+            onClose={() => {
+              setActive(false);
+            }}
+            numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
+          />
+        )}
+      </Popover.Root>
     </div>
   );
 }

+ 3 - 0
packages/excalidraw/components/Stats/Collapsible.tsx

@@ -9,6 +9,7 @@ interface CollapsibleProps {
   open: boolean;
   openTrigger: () => void;
   children: React.ReactNode;
+  className?: string;
 }
 
 const Collapsible = ({
@@ -16,6 +17,7 @@ const Collapsible = ({
   open,
   openTrigger,
   children,
+  className,
 }: CollapsibleProps) => {
   return (
     <>
@@ -26,6 +28,7 @@ const Collapsible = ({
           justifyContent: "space-between",
           alignItems: "center",
         }}
+        className={className}
         onClick={openTrigger}
       >
         {label}

+ 48 - 0
packages/excalidraw/components/icons.tsx

@@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
     ),
 );
 
+export const ArrowheadCrowfootIcon = React.memo(
+  ({ flip = false }: { flip?: boolean }) =>
+    createIcon(
+      <g
+        stroke="currentColor"
+        fill="none"
+        transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
+        strokeLinejoin="round"
+        strokeWidth={2}
+      >
+        <path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
+      </g>,
+      { width: 40, height: 20 },
+    ),
+);
+
+export const ArrowheadCrowfootOneIcon = React.memo(
+  ({ flip = false }: { flip?: boolean }) =>
+    createIcon(
+      <g
+        stroke="currentColor"
+        fill="none"
+        transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
+        strokeLinejoin="round"
+        strokeWidth={2}
+      >
+        <path d="M34,10 H6 M15,10 L15,15 L15,5" />
+      </g>,
+      { width: 40, height: 20 },
+    ),
+);
+
+export const ArrowheadCrowfootOneOrManyIcon = React.memo(
+  ({ flip = false }: { flip?: boolean }) =>
+    createIcon(
+      <g
+        stroke="currentColor"
+        fill="none"
+        transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
+        strokeLinejoin="round"
+        strokeWidth={2}
+      >
+        <path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
+      </g>,
+      { width: 40, height: 20 },
+    ),
+);
+
 export const FontSizeSmallIcon = createIcon(
   <>
     <g clipPath="url(#a)">

+ 19 - 0
packages/excalidraw/element/bounds.ts

@@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
     case "diamond":
     case "diamond_outline":
       return 12;
+    case "crowfoot_many":
+    case "crowfoot_one":
+    case "crowfoot_one_or_many":
+      return 20;
     default:
       return 15;
   }
@@ -669,6 +673,21 @@ export const getArrowheadPoints = (
 
   const angle = getArrowheadAngle(arrowhead);
 
+  if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
+    // swap (xs, ys) with (x2, y2)
+    const [x3, y3] = pointRotateRads(
+      pointFrom(x2, y2),
+      pointFrom(xs, ys),
+      degreesToRadians(-angle as Degrees),
+    );
+    const [x4, y4] = pointRotateRads(
+      pointFrom(x2, y2),
+      pointFrom(xs, ys),
+      degreesToRadians(angle),
+    );
+    return [xs, ys, x3, y3, x4, y4];
+  }
+
   // Return points
   const [x3, y3] = pointRotateRads(
     pointFrom(xs, ys),

+ 4 - 1
packages/excalidraw/element/types.ts

@@ -303,7 +303,10 @@ export type Arrowhead =
   | "triangle"
   | "triangle_outline"
   | "diamond"
-  | "diamond_outline";
+  | "diamond_outline"
+  | "crowfoot_one"
+  | "crowfoot_many"
+  | "crowfoot_one_or_many";
 
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
   Readonly<{

+ 4 - 0
packages/excalidraw/locales/en.json

@@ -46,6 +46,10 @@
     "arrowhead_triangle_outline": "Triangle (outline)",
     "arrowhead_diamond": "Diamond",
     "arrowhead_diamond_outline": "Diamond (outline)",
+    "arrowhead_crowfoot_many": "Crow's foot (many)",
+    "arrowhead_crowfoot_one": "Crow's foot (one)",
+    "arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
+    "more_options": "More options",
     "arrowtypes": "Arrow type",
     "arrowtype_sharp": "Sharp arrow",
     "arrowtype_round": "Curved arrow",

+ 23 - 0
packages/excalidraw/scene/Shape.ts

@@ -177,6 +177,19 @@ const getArrowheadShapes = (
     return [];
   }
 
+  const generateCrowfootOne = (
+    arrowheadPoints: number[] | null,
+    options: Options,
+  ) => {
+    if (arrowheadPoints === null) {
+      return [];
+    }
+
+    const [, , x3, y3, x4, y4] = arrowheadPoints;
+
+    return [generator.line(x3, y3, x4, y4, options)];
+  };
+
   switch (arrowhead) {
     case "dot":
     case "circle":
@@ -255,8 +268,12 @@ const getArrowheadShapes = (
         ),
       ];
     }
+    case "crowfoot_one":
+      return generateCrowfootOne(arrowheadPoints, options);
     case "bar":
     case "arrow":
+    case "crowfoot_many":
+    case "crowfoot_one_or_many":
     default: {
       const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
 
@@ -272,6 +289,12 @@ const getArrowheadShapes = (
       return [
         generator.line(x3, y3, x2, y2, options),
         generator.line(x4, y4, x2, y2, options),
+        ...(arrowhead === "crowfoot_one_or_many"
+          ? generateCrowfootOne(
+              getArrowheadPoints(element, shape, position, "crowfoot_one"),
+              options,
+            )
+          : []),
       ];
     }
   }