mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-03 20:34:40 +01:00 
			
		
		
		
	Add distribute actions. (#2395)
This commit is contained in:
		
							
								
								
									
										95
									
								
								src/actions/actionDistribute.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/actions/actionDistribute.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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)}
 | 
			
		||||
    />
 | 
			
		||||
  ),
 | 
			
		||||
});
 | 
			
		||||
@@ -60,3 +60,8 @@ export {
 | 
			
		||||
  actionAlignVerticallyCentered,
 | 
			
		||||
  actionAlignHorizontallyCentered,
 | 
			
		||||
} from "./actionAlign";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  distributeHorizontally,
 | 
			
		||||
  distributeVertically,
 | 
			
		||||
} from "./actionDistribute";
 | 
			
		||||
 
 | 
			
		||||
@@ -71,7 +71,9 @@ export type ActionName =
 | 
			
		||||
  | "alignLeft"
 | 
			
		||||
  | "alignRight"
 | 
			
		||||
  | "alignVerticallyCentered"
 | 
			
		||||
  | "alignHorizontallyCentered";
 | 
			
		||||
  | "alignHorizontallyCentered"
 | 
			
		||||
  | "distributeHorizontally"
 | 
			
		||||
  | "distributeVertically";
 | 
			
		||||
 | 
			
		||||
export interface Action {
 | 
			
		||||
  name: ActionName;
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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
									
								
								src/disitrubte.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/disitrubte.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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 };
 | 
			
		||||
};
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user