Add distribute actions. (#2395)

This commit is contained in:
Steve Ruiz 2020-11-23 18:16:23 +00:00 committed by GitHub
parent d3c3894108
commit 198106e297
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 278 additions and 8 deletions

View 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)}
/>
),
});

View File

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

View File

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

View File

@ -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>

View File

@ -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(

View File

@ -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
View 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 };
};

View File

@ -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,

View File

@ -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",