Add distribute actions. (#2395)
This commit is contained in:
parent
d3c3894108
commit
198106e297
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,
|
actionAlignVerticallyCentered,
|
||||||
actionAlignHorizontallyCentered,
|
actionAlignHorizontallyCentered,
|
||||||
} from "./actionAlign";
|
} from "./actionAlign";
|
||||||
|
|
||||||
|
export {
|
||||||
|
distributeHorizontally,
|
||||||
|
distributeVertically,
|
||||||
|
} from "./actionDistribute";
|
||||||
|
@ -71,7 +71,9 @@ export type ActionName =
|
|||||||
| "alignLeft"
|
| "alignLeft"
|
||||||
| "alignRight"
|
| "alignRight"
|
||||||
| "alignVerticallyCentered"
|
| "alignVerticallyCentered"
|
||||||
| "alignHorizontallyCentered";
|
| "alignHorizontallyCentered"
|
||||||
|
| "distributeHorizontally"
|
||||||
|
| "distributeVertically";
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
|
@ -91,13 +91,18 @@ export const SelectedShapeActions = ({
|
|||||||
{renderAction("alignLeft")}
|
{renderAction("alignLeft")}
|
||||||
{renderAction("alignHorizontallyCentered")}
|
{renderAction("alignHorizontallyCentered")}
|
||||||
{renderAction("alignRight")}
|
{renderAction("alignRight")}
|
||||||
{renderAction("alignTop")}
|
{targetElements.length > 2 &&
|
||||||
{renderAction("alignVerticallyCentered")}
|
renderAction("distributeHorizontally")}
|
||||||
{renderAction("alignBottom")}
|
<div className="iconRow">
|
||||||
|
{renderAction("alignTop")}
|
||||||
|
{renderAction("alignVerticallyCentered")}
|
||||||
|
{renderAction("alignBottom")}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeVertically")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && !isEditing && targetElements.length > 0 && (
|
{!isMobile && !isEditing && targetElements.length > 0 && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.actions")}</legend>
|
<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(
|
export const CenterVerticallyIcon = React.memo(
|
||||||
({ appearance }: { appearance: "light" | "dark" }) =>
|
({ appearance }: { appearance: "light" | "dark" }) =>
|
||||||
createIcon(
|
createIcon(
|
||||||
|
@ -99,8 +99,29 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iconRow {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon {
|
.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 {
|
.ToolIcon__icon {
|
||||||
@ -371,7 +392,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.zIndexButton {
|
.zIndexButton {
|
||||||
margin: 0 5px;
|
margin: 0 8px 0 0;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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,
|
ALT_KEY_CODE: 18,
|
||||||
Z_KEY_CODE: 90,
|
Z_KEY_CODE: 90,
|
||||||
GRID_KEY_CODE: 222,
|
GRID_KEY_CODE: 222,
|
||||||
|
H_KEY_CODE: 72,
|
||||||
G_KEY_CODE: 71,
|
G_KEY_CODE: 71,
|
||||||
C_KEY_CODE: 67,
|
C_KEY_CODE: 67,
|
||||||
V_KEY_CODE: 86,
|
V_KEY_CODE: 86,
|
||||||
|
@ -81,7 +81,9 @@
|
|||||||
"alignLeft": "Align left",
|
"alignLeft": "Align left",
|
||||||
"alignRight": "Align right",
|
"alignRight": "Align right",
|
||||||
"centerVertically": "Center vertically",
|
"centerVertically": "Center vertically",
|
||||||
"centerHorizontally": "Center horizontally"
|
"centerHorizontally": "Center horizontally",
|
||||||
|
"distributeHorizontally": "Distribute horizontally",
|
||||||
|
"distributeVertically": "Distribute vertically"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Reset the canvas",
|
"clearReset": "Reset the canvas",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user