瀏覽代碼

More Arrowheads: dot, bar (#2486)

Co-authored-by: Jed Fox <git@jedfox.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
Steve Ruiz 4 年之前
父節點
當前提交
c742225f43

+ 126 - 64
src/actions/actionProperties.tsx

@@ -1,4 +1,5 @@
 import React from "react";
+import { getLanguage } from "../i18n";
 import {
   ExcalidrawElement,
   ExcalidrawTextElement,
@@ -16,7 +17,7 @@ import {
 } from "../scene";
 import { ButtonSelect } from "../components/ButtonSelect";
 import { ButtonIconSelect } from "../components/ButtonIconSelect";
-import { ButtonIconCycle } from "../components/ButtonIconCycle";
+import { IconPicker } from "../components/IconPicker";
 import {
   isTextElement,
   redrawTextBoundingBox,
@@ -43,7 +44,10 @@ import {
   SloppinessArchitectIcon,
   SloppinessArtistIcon,
   SloppinessCartoonistIcon,
-  ArrowArrowheadIcon,
+  ArrowheadArrowIcon,
+  ArrowheadBarIcon,
+  ArrowheadDotIcon,
+  ArrowheadNoneIcon,
 } from "../components/icons";
 import { EVENT_CHANGE, trackEvent } from "../analytics";
 import colors from "../colors";
@@ -671,66 +675,124 @@ export const actionChangeArrowhead = register({
       commitToHistory: true,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => (
-    <fieldset>
-      <legend>{t("labels.arrowheads")}</legend>
-      <div className="buttonList buttonListIcon">
-        <ButtonIconCycle
-          group="arrowhead_start"
-          options={[
-            {
-              value: null,
-              text: t("labels.arrowhead_none"),
-              icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
-            },
-            {
-              value: "arrow",
-              text: t("labels.arrowhead_arrow"),
-              icon: (
-                <ArrowArrowheadIcon
-                  appearance={appState.appearance}
-                  flip={true}
-                />
-              ),
-            },
-          ]}
-          value={getFormValue<Arrowhead | null>(
-            elements,
-            appState,
-            (element) =>
-              isLinearElement(element) && canHaveArrowheads(element.type)
-                ? element.startArrowhead
-                : appState.currentItemArrowheads.start,
-            appState.currentItemArrowheads.start,
-          )}
-          onChange={(value) => updateData({ position: "start", type: value })}
-        />
-        <ButtonIconCycle
-          group="arrowhead_end"
-          options={[
-            {
-              value: null,
-              text: t("labels.arrowhead_none"),
-              icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
-            },
-            {
-              value: "arrow",
-              text: t("labels.arrowhead_arrow"),
-              icon: <ArrowArrowheadIcon appearance={appState.appearance} />,
-            },
-          ]}
-          value={getFormValue<Arrowhead | null>(
-            elements,
-            appState,
-            (element) =>
-              isLinearElement(element) && canHaveArrowheads(element.type)
-                ? element.endArrowhead
-                : appState.currentItemArrowheads.end,
-            appState.currentItemArrowheads.end,
-          )}
-          onChange={(value) => updateData({ position: "end", type: value })}
-        />
-      </div>
-    </fieldset>
-  ),
+  PanelComponent: ({ elements, appState, updateData }) => {
+    const isRTL = getLanguage().rtl;
+
+    return (
+      <fieldset>
+        <legend>{t("labels.arrowheads")}</legend>
+        <div className="iconSelectList">
+          <IconPicker
+            label="arrowhead_start"
+            options={[
+              {
+                value: null,
+                text: t("labels.arrowhead_none"),
+                icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
+                keyBinding: "q",
+              },
+              {
+                value: "arrow",
+                text: t("labels.arrowhead_arrow"),
+                icon: (
+                  <ArrowheadArrowIcon
+                    appearance={appState.appearance}
+                    flip={!isRTL}
+                  />
+                ),
+                keyBinding: "w",
+              },
+              {
+                value: "bar",
+                text: t("labels.arrowhead_bar"),
+                icon: (
+                  <ArrowheadBarIcon
+                    appearance={appState.appearance}
+                    flip={!isRTL}
+                  />
+                ),
+                keyBinding: "e",
+              },
+              {
+                value: "dot",
+                text: t("labels.arrowhead_dot"),
+                icon: (
+                  <ArrowheadDotIcon
+                    appearance={appState.appearance}
+                    flip={!isRTL}
+                  />
+                ),
+                keyBinding: "r",
+              },
+            ]}
+            value={getFormValue<Arrowhead | null>(
+              elements,
+              appState,
+              (element) =>
+                isLinearElement(element) && canHaveArrowheads(element.type)
+                  ? element.startArrowhead
+                  : appState.currentItemArrowheads.start,
+              appState.currentItemArrowheads.start,
+            )}
+            onChange={(value) => updateData({ position: "start", type: value })}
+          />
+          <IconPicker
+            label="arrowhead_end"
+            group="arrowheads"
+            options={[
+              {
+                value: null,
+                text: t("labels.arrowhead_none"),
+                keyBinding: "q",
+                icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
+              },
+              {
+                value: "arrow",
+                text: t("labels.arrowhead_arrow"),
+                keyBinding: "w",
+                icon: (
+                  <ArrowheadArrowIcon
+                    appearance={appState.appearance}
+                    flip={isRTL}
+                  />
+                ),
+              },
+              {
+                value: "bar",
+                text: t("labels.arrowhead_bar"),
+                keyBinding: "e",
+                icon: (
+                  <ArrowheadBarIcon
+                    appearance={appState.appearance}
+                    flip={isRTL}
+                  />
+                ),
+              },
+              {
+                value: "dot",
+                text: t("labels.arrowhead_dot"),
+                keyBinding: "r",
+                icon: (
+                  <ArrowheadDotIcon
+                    appearance={appState.appearance}
+                    flip={isRTL}
+                  />
+                ),
+              },
+            ]}
+            value={getFormValue<Arrowhead | null>(
+              elements,
+              appState,
+              (element) =>
+                isLinearElement(element) && canHaveArrowheads(element.type)
+                  ? element.endArrowhead
+                  : appState.currentItemArrowheads.end,
+              appState.currentItemArrowheads.end,
+            )}
+            onChange={(value) => updateData({ position: "end", type: value })}
+          />
+        </div>
+      </fieldset>
+    );
+  },
 });

+ 137 - 0
src/components/IconPicker.scss

@@ -0,0 +1,137 @@
+@import "open-color/open-color.scss";
+
+.excalidraw {
+  .picker-container {
+    display: inline-block;
+    box-sizing: border-box;
+    margin-right: 0.25rem;
+  }
+
+  .picker {
+    background: var(--popup-background-color);
+    border: 0px solid transparentize($oc-white, 0.75);
+    box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
+    border-radius: 4px;
+    position: absolute;
+  }
+
+  .picker-container button,
+  .picker button {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &:focus {
+      outline: transparent;
+      background-color: var(--button-gray-2);
+      & svg {
+        opacity: 1;
+      }
+    }
+
+    &:hover {
+      background-color: var(--button-gray-2);
+    }
+
+    &:active {
+      background-color: var(--button-gray-3);
+    }
+
+    &:disabled {
+      cursor: not-allowed;
+    }
+
+    svg {
+      margin: 0;
+      width: 36px;
+      height: 18px;
+      opacity: 0.6;
+      pointer-events: none;
+    }
+  }
+
+  .picker button {
+    padding: 0.25rem 0.28rem 0.35rem 0.25rem;
+  }
+
+  .picker-triangle {
+    width: 0px;
+    height: 0px;
+    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: 0px 9px 10px;
+      border-color: transparent transparent transparentize($oc-black, 0.9);
+      top: -1px;
+    }
+
+    &:after {
+      content: "";
+      position: absolute;
+      border-style: solid;
+      border-width: 0px 9px 10px;
+      border-color: transparent transparent var(--popup-background-color);
+    }
+  }
+
+  .picker-content {
+    padding: 0.5rem;
+    display: grid;
+    grid-auto-flow: column;
+    grid-gap: 0.5rem;
+    border-radius: 4px;
+  }
+
+  .picker-keybinding {
+    position: absolute;
+    bottom: 2px;
+
+    :root[dir="ltr"] & {
+      right: 2px;
+    }
+
+    :root[dir="rtl"] & {
+      left: 2px;
+    }
+
+    font-size: 0.7em;
+  }
+
+  .picker-type-canvasBackground .picker-keybinding {
+    color: #aaa;
+  }
+
+  .picker-type-elementBackground .picker-keybinding {
+    color: #fff;
+  }
+
+  .picker-swatch[aria-label="transparent"] .picker-keybinding {
+    color: #aaa;
+  }
+
+  .picker-type-elementStroke .picker-keybinding {
+    color: #d4d4d4;
+  }
+
+  &.Appearance_dark {
+    .picker-type-elementBackground .picker-keybinding {
+      color: #000;
+    }
+    .picker-swatch[aria-label="transparent"] .picker-keybinding {
+      color: #000;
+    }
+  }
+}

+ 188 - 0
src/components/IconPicker.tsx

@@ -0,0 +1,188 @@
+import React from "react";
+import { Popover } from "./Popover";
+
+import "./IconPicker.scss";
+import { isArrowKey, KEYS } from "../keys";
+import { getLanguage } from "../i18n";
+
+function Picker<T>({
+  options,
+  value,
+  label,
+  onChange,
+  onClose,
+}: {
+  label: string;
+  value: T;
+  options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
+  onChange: (value: T) => void;
+  onClose: () => void;
+}) {
+  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 handleKeyDown = (event: React.KeyboardEvent) => {
+    const pressedOption = options.find(
+      (option) => option.keyBinding === event.key.toLowerCase(),
+    )!;
+
+    if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
+      // Keybinding navigation
+      const index = options.indexOf(pressedOption);
+      (rGallery!.current!.children![index] as any).focus();
+      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);
+    } else if (isArrowKey(event.key)) {
+      // Arrow navigation
+      const { activeElement } = document;
+      const isRTL = getLanguage().rtl;
+      const index = Array.prototype.indexOf.call(
+        rGallery!.current!.children,
+        activeElement,
+      );
+      if (index !== -1) {
+        const length = options.length;
+        let nextIndex = index;
+
+        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;
+          }
+        }
+
+        (rGallery.current!.children![nextIndex] as any).focus();
+      }
+      event.preventDefault();
+    } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
+      // Close on escape or enter
+      event.preventDefault();
+      onClose();
+    }
+    event.nativeEvent.stopImmediatePropagation();
+  };
+
+  return (
+    <div
+      className={`picker`}
+      role="dialog"
+      aria-modal="true"
+      aria-label={label}
+      onKeyDown={handleKeyDown}
+    >
+      <div className="picker-content" ref={rGallery}>
+        {options.map((option, i) => (
+          <button
+            className="picker-option"
+            onClick={(event) => {
+              (event.currentTarget as HTMLButtonElement).focus();
+              onChange(option.value);
+            }}
+            title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
+            aria-label={option.text || "none"}
+            aria-keyshortcuts={option.keyBinding}
+            key={option.text}
+            ref={(el) => {
+              if (el && i === 0) {
+                rFirstItem.current = el;
+              }
+              if (el && option.value === value) {
+                rActiveItem.current = el;
+              }
+            }}
+            onFocus={() => {
+              onChange(option.value);
+            }}
+          >
+            {option.icon}
+            <span className="picker-keybinding">{option.keyBinding}</span>
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+export function IconPicker<T>({
+  value,
+  label,
+  options,
+  onChange,
+  group = "",
+}: {
+  label: string;
+  value: T;
+  options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
+  onChange: (value: T) => void;
+  group?: string;
+}) {
+  const [isActive, setActive] = React.useState(false);
+  const rPickerButton = React.useRef<any>(null);
+  const isRTL = getLanguage().rtl;
+
+  return (
+    <label className={"picker-container"}>
+      <button
+        name={group}
+        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}
+                value={value}
+                label={label}
+                onChange={onChange}
+                onClose={() => {
+                  setActive(false);
+                  rPickerButton.current?.focus();
+                }}
+              />
+            </Popover>
+            <div className="picker-triangle" />
+          </>
+        ) : null}
+      </React.Suspense>
+    </label>
+  );
+}

+ 1 - 1
src/components/MobileMenu.tsx

@@ -82,7 +82,7 @@ export const MobileMenu = ({
         marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
       }}
     >
-      <Island padding={3}>
+      <Island padding={0}>
         {appState.openMenu === "canvas" ? (
           <Section className="App-mobile-menu" heading="canvasActions">
             <div className="panelColumn">

+ 60 - 2
src/components/icons.tsx

@@ -725,7 +725,23 @@ export const EdgeRoundIcon = React.memo(
     ),
 );
 
-export const ArrowArrowheadIcon = React.memo(
+export const ArrowheadNoneIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <path
+        d="M6 10H34"
+        stroke={iconFillColor(appearance)}
+        strokeWidth={2}
+        fill="none"
+      />,
+      {
+        width: 40,
+        height: 20,
+      },
+    ),
+);
+
+export const ArrowheadArrowIcon = React.memo(
   ({
     appearance,
     flip = false,
@@ -743,6 +759,48 @@ export const ArrowArrowheadIcon = React.memo(
         <path d="M34 10H6M34 10L27 5M34 10L27 15" />
         <path d="M27.5 5L34.5 10L27.5 15" />
       </g>,
-      { width: 40, height: 20, mirror: true },
+      { width: 40, height: 20 },
+    ),
+);
+
+export const ArrowheadDotIcon = React.memo(
+  ({
+    appearance,
+    flip = false,
+  }: {
+    appearance: "light" | "dark";
+    flip?: boolean;
+  }) =>
+    createIcon(
+      <g
+        stroke={iconFillColor(appearance)}
+        fill={iconFillColor(appearance)}
+        transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
+      >
+        <path d="M32 10L6 10" strokeWidth={2} />
+        <circle r="4" transform="matrix(-1 0 0 1 30 10)" />
+      </g>,
+      { width: 40, height: 20 },
+    ),
+);
+
+export const ArrowheadBarIcon = React.memo(
+  ({
+    appearance,
+    flip = false,
+  }: {
+    appearance: "light" | "dark";
+    flip?: boolean;
+  }) =>
+    createIcon(
+      <g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
+        <path
+          d="M34 10H5.99996M34 10L34 5M34 10L34 15"
+          stroke={iconFillColor(appearance)}
+          strokeWidth={2}
+          fill="none"
+        />
+      </g>,
+      { width: 40, height: 20 },
     ),
 );

+ 10 - 0
src/css/styles.scss

@@ -84,6 +84,11 @@
       padding: 0;
     }
 
+    .iconSelectList {
+      flex-wrap: wrap;
+      position: relative;
+    }
+
     .buttonList {
       flex-wrap: wrap;
 
@@ -236,6 +241,10 @@
       display: flex;
       flex-direction: column;
       pointer-events: initial;
+
+      .panelColumn {
+        padding: 8px 8px 0px 8px;
+      }
     }
   }
 
@@ -249,6 +258,7 @@
     display: flex;
     align-items: center;
     justify-content: space-between;
+    padding: 8px;
   }
 
   .App-mobile-menu {

+ 14 - 2
src/element/bounds.ts

@@ -212,7 +212,11 @@ export const getArrowheadPoints = (
   const nx = (x2 - x1) / distance;
   const ny = (y2 - y1) / distance;
 
-  const size = 30; // pixels (will differ for each arrowhead)
+  const size = {
+    arrow: 30,
+    bar: 15,
+    dot: 15,
+  }[arrowhead]; // pixels (will differ for each arrowhead)
 
   const length = element.points.reduce((total, [cx, cy], idx, points) => {
     const [px, py] = idx > 0 ? points[idx - 1] : [0, 0];
@@ -226,7 +230,15 @@ export const getArrowheadPoints = (
   const xs = x2 - nx * minSize;
   const ys = y2 - ny * minSize;
 
-  const angle = 20; // degrees
+  if (arrowhead === "dot") {
+    const r = Math.hypot(ys - y2, xs - x2);
+    return [x2, y2, r];
+  }
+
+  const angle = {
+    arrow: 20,
+    bar: 90,
+  }[arrowhead]; // degrees
 
   // Return points
   const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);

+ 1 - 1
src/element/types.ts

@@ -98,7 +98,7 @@ export type PointBinding = {
   gap: number;
 };
 
-export type Arrowhead = "arrow";
+export type Arrowhead = "arrow" | "bar" | "dot";
 
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
   Readonly<{

+ 2 - 0
src/locales/en.json

@@ -31,6 +31,8 @@
     "arrowheads": "Arrowheads",
     "arrowhead_none": "None",
     "arrowhead_arrow": "Arrow",
+    "arrowhead_bar": "Bar",
+    "arrowhead_dot": "Dot",
     "fontSize": "Font size",
     "fontFamily": "Font family",
     "onlySelected": "Only selected",

+ 11 - 0
src/renderer/renderElement.ts

@@ -356,6 +356,17 @@ const generateElementShape = (
             }
 
             // Other arrowheads here...
+            if (arrowhead === "dot") {
+              const [x, y, r] = arrowheadPoints;
+
+              return [
+                generator.circle(x, y, r, {
+                  ...options,
+                  fill: element.strokeColor,
+                  fillStyle: "solid",
+                }),
+              ];
+            }
 
             // Arrow arrowheads
             const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;