浏览代码

Add distribute actions. (#2395)

Steve Ruiz 4 年之前
父节点
当前提交
198106e297
共有 9 个文件被更改,包括 278 次插入8 次删除
  1. 95 0
      src/actions/actionDistribute.tsx
  2. 5 0
      src/actions/index.ts
  3. 3 1
      src/actions/types.ts
  4. 9 4
      src/components/Actions.tsx
  5. 52 0
      src/components/icons.tsx
  6. 23 2
      src/css/styles.scss
  7. 87 0
      src/disitrubte.ts
  8. 1 0
      src/keys.ts
  9. 3 1
      src/locales/en.json

+ 95 - 0
src/actions/actionDistribute.tsx

@@ -0,0 +1,95 @@
+import React from "react";
+import { KEYS } from "../keys";
+import { t } from "../i18n";
+import { register } from "./register";
+import {
+  DistributeHorizontallyIcon,
+  DistributeVerticallyIcon,
+} from "../components/icons";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
+import { getElementMap, getNonDeletedElements } from "../element";
+import { ToolButton } from "../components/ToolButton";
+import { ExcalidrawElement } from "../element/types";
+import { AppState } from "../types";
+import { distributeElements, Distribution } from "../disitrubte";
+import { getShortcutKey } from "../utils";
+
+const enableActionGroup = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
+
+const distributeSelectedElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  distribution: Distribution,
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  const updatedElements = distributeElements(selectedElements, distribution);
+
+  const updatedElementsMap = getElementMap(updatedElements);
+
+  return elements.map((element) => updatedElementsMap[element.id] || element);
+};
+
+export const distributeHorizontally = register({
+  name: "distributeHorizontally",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: distributeSelectedElements(elements, appState, {
+        space: "between",
+        axis: "x",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return event.altKey && event.keyCode === KEYS.H_KEY_CODE;
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
+        "Alt+H",
+      )}`}
+      aria-label={t("labels.distributeHorizontally")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});
+
+export const distributeVertically = register({
+  name: "distributeVertically",
+  perform: (elements, appState) => {
+    return {
+      appState,
+      elements: distributeSelectedElements(elements, appState, {
+        space: "between",
+        axis: "y",
+      }),
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => {
+    return event.altKey && event.keyCode === KEYS.V_KEY_CODE;
+  },
+  PanelComponent: ({ elements, appState, updateData }) => (
+    <ToolButton
+      hidden={!enableActionGroup(elements, appState)}
+      type="button"
+      icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
+      onClick={() => updateData(null)}
+      title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
+      aria-label={t("labels.distributeVertically")}
+      visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
+    />
+  ),
+});

+ 5 - 0
src/actions/index.ts

@@ -60,3 +60,8 @@ export {
   actionAlignVerticallyCentered,
   actionAlignHorizontallyCentered,
 } from "./actionAlign";
+
+export {
+  distributeHorizontally,
+  distributeVertically,
+} from "./actionDistribute";

+ 3 - 1
src/actions/types.ts

@@ -71,7 +71,9 @@ export type ActionName =
   | "alignLeft"
   | "alignRight"
   | "alignVerticallyCentered"
-  | "alignHorizontallyCentered";
+  | "alignHorizontallyCentered"
+  | "distributeHorizontally"
+  | "distributeVertically";
 
 export interface Action {
   name: ActionName;

+ 9 - 4
src/components/Actions.tsx

@@ -91,13 +91,18 @@ export const SelectedShapeActions = ({
             {renderAction("alignLeft")}
             {renderAction("alignHorizontallyCentered")}
             {renderAction("alignRight")}
-            {renderAction("alignTop")}
-            {renderAction("alignVerticallyCentered")}
-            {renderAction("alignBottom")}
+            {targetElements.length > 2 &&
+              renderAction("distributeHorizontally")}
+            <div className="iconRow">
+              {renderAction("alignTop")}
+              {renderAction("alignVerticallyCentered")}
+              {renderAction("alignBottom")}
+              {targetElements.length > 2 &&
+                renderAction("distributeVertically")}
+            </div>
           </div>
         </fieldset>
       )}
-
       {!isMobile && !isEditing && targetElements.length > 0 && (
         <fieldset>
           <legend>{t("labels.actions")}</legend>

+ 52 - 0
src/components/icons.tsx

@@ -295,6 +295,58 @@ export const AlignRightIcon = React.memo(
     ),
 );
 
+export const DistributeHorizontallyIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path d="M5 5V19Z" fill="black" />
+        <path
+          d="M19 5V19M5 5V19"
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeLinecap="round"
+        />
+        <path
+          d="M15 9C15.554 9 16 9.446 16 10V14C16 14.554 15.554 15 15 15H9C8.446 15 8 14.554 8 14V10C8 9.446 8.446 9 9 9H15Z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
+<svg
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  xmlns="http://www.w3.org/2000/svg"
+></svg>;
+
+export const DistributeVerticallyIcon = React.memo(
+  ({ appearance }: { appearance: "light" | "dark" }) =>
+    createIcon(
+      <>
+        <path
+          d="M5 5L19 5M5 19H19"
+          fill={iconFillColor(appearance)}
+          stroke={iconFillColor(appearance)}
+          strokeWidth="2"
+          strokeLinecap="round"
+        />
+        <path
+          d="M15 9C15.554 9 16 9.446 16 10V14C16 14.554 15.554 15 15 15H9C8.446 15 8 14.554 8 14V10C8 9.446 8.446 9 9 9H15Z"
+          fill={activeElementColor(appearance)}
+          stroke={activeElementColor(appearance)}
+          strokeWidth="2"
+        />
+      </>,
+      { width: 24 },
+    ),
+);
+
 export const CenterVerticallyIcon = React.memo(
   ({ appearance }: { appearance: "light" | "dark" }) =>
     createIcon(

+ 23 - 2
src/css/styles.scss

@@ -99,8 +99,29 @@
         pointer-events: none;
       }
 
+      .iconRow {
+        margin-top: 8px;
+      }
+
       .ToolIcon {
-        margin: 0 5px;
+        margin: 0 8px 0 0;
+
+        &:focus {
+          outline: transparent;
+          box-shadow: 0 0 0 2px var(--focus-highlight-color);
+        }
+
+        &:hover {
+          background-color: var(--button-gray-2);
+        }
+
+        &:active {
+          background-color: var(--button-gray-3);
+        }
+
+        &:disabled {
+          cursor: not-allowed;
+        }
       }
 
       .ToolIcon__icon {
@@ -371,7 +392,7 @@
   }
 
   .zIndexButton {
-    margin: 0 5px;
+    margin: 0 8px 0 0;
     padding: 5px;
     display: inline-flex;
     align-items: center;

+ 87 - 0
src/disitrubte.ts

@@ -0,0 +1,87 @@
+import { ExcalidrawElement } from "./element/types";
+import { newElementWith } from "./element/mutateElement";
+import { getCommonBounds } from "./element";
+
+interface Box {
+  minX: number;
+  minY: number;
+  maxX: number;
+  maxY: number;
+}
+
+export interface Distribution {
+  space: "between";
+  axis: "x" | "y";
+}
+
+export const distributeElements = (
+  selectedElements: ExcalidrawElement[],
+  distribution: Distribution,
+): ExcalidrawElement[] => {
+  const start = distribution.axis === "x" ? "minX" : "minY";
+  const extent = distribution.axis === "x" ? "width" : "height";
+
+  const selectionBoundingBox = getCommonBoundingBox(selectedElements);
+
+  const groups = getMaximumGroups(selectedElements)
+    .map((group) => [group, getCommonBoundingBox(group)] as const)
+    .sort((a, b) => a[1][start] - b[1][start]);
+
+  let span = 0;
+  for (const group of groups) {
+    span += group[1][extent];
+  }
+
+  const step = (selectionBoundingBox[extent] - span) / (groups.length - 1);
+  let pos = selectionBoundingBox[start];
+
+  return groups.flatMap(([group, box]) => {
+    const translation = {
+      x: 0,
+      y: 0,
+    };
+
+    if (Math.abs(pos - box[start]) >= 1e-6) {
+      translation[distribution.axis] = pos - box[start];
+    }
+
+    pos += box[extent];
+    pos += step;
+
+    return group.map((element) =>
+      newElementWith(element, {
+        x: Math.round(element.x + translation.x),
+        y: Math.round(element.y + translation.y),
+      }),
+    );
+  });
+};
+
+export const getMaximumGroups = (
+  elements: ExcalidrawElement[],
+): ExcalidrawElement[][] => {
+  const groups: Map<String, ExcalidrawElement[]> = new Map<
+    String,
+    ExcalidrawElement[]
+  >();
+
+  elements.forEach((element: ExcalidrawElement) => {
+    const groupId =
+      element.groupIds.length === 0
+        ? element.id
+        : element.groupIds[element.groupIds.length - 1];
+
+    const currentGroupMembers = groups.get(groupId) || [];
+
+    groups.set(groupId, [...currentGroupMembers, element]);
+  });
+
+  return Array.from(groups.values());
+};
+
+const getCommonBoundingBox = (
+  elements: ExcalidrawElement[],
+): Box & { width: number; height: number } => {
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+  return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
+};

+ 1 - 0
src/keys.ts

@@ -17,6 +17,7 @@ export const KEYS = {
   ALT_KEY_CODE: 18,
   Z_KEY_CODE: 90,
   GRID_KEY_CODE: 222,
+  H_KEY_CODE: 72,
   G_KEY_CODE: 71,
   C_KEY_CODE: 67,
   V_KEY_CODE: 86,

+ 3 - 1
src/locales/en.json

@@ -81,7 +81,9 @@
     "alignLeft": "Align left",
     "alignRight": "Align right",
     "centerVertically": "Center vertically",
-    "centerHorizontally": "Center horizontally"
+    "centerHorizontally": "Center horizontally",
+    "distributeHorizontally": "Distribute horizontally",
+    "distributeVertically": "Distribute vertically"
   },
   "buttons": {
     "clearReset": "Reset the canvas",