mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: add arrange elements action
This commit is contained in:
parent
4944a739c8
commit
bf8f0c48c3
14 changed files with 388 additions and 0 deletions
146
packages/element/src/arrange-algorithms/packer.ts
Normal file
146
packages/element/src/arrange-algorithms/packer.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
export interface Block {
|
||||
w: number;
|
||||
h: number;
|
||||
fit?: TreeNode | null;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
used?: boolean;
|
||||
down?: TreeNode;
|
||||
right?: TreeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Growth packer based on https://github.com/jakesgordon/bin-packing/tree/master
|
||||
*
|
||||
* Added support to adding gaps to the packing by @feng94
|
||||
*/
|
||||
export class GrowingPacker {
|
||||
root: TreeNode | null;
|
||||
gap: number;
|
||||
|
||||
constructor(gap = 0) {
|
||||
this.root = null;
|
||||
this.gap = gap;
|
||||
}
|
||||
|
||||
fit(blocks: Block[]): void {
|
||||
const len = blocks.length;
|
||||
const w = len > 0 ? blocks[0].w : 0;
|
||||
const h = len > 0 ? blocks[0].h : 0;
|
||||
this.root = { x: 0, y: 0, w, h };
|
||||
|
||||
for (let n = 0; n < len; n++) {
|
||||
const block = blocks[n];
|
||||
const TreeNode = this.findNode(this.root, block.w, block.h);
|
||||
if (TreeNode) {
|
||||
block.fit = this.splitNode(TreeNode, block.w, block.h);
|
||||
} else {
|
||||
block.fit = this.growNode(block.w, block.h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findNode(
|
||||
root: TreeNode | null,
|
||||
w: number,
|
||||
h: number,
|
||||
): TreeNode | null {
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
if (root.used) {
|
||||
return (
|
||||
this.findNode(root.right || null, w, h) ||
|
||||
this.findNode(root.down || null, w, h)
|
||||
);
|
||||
} else if (w <= root.w && h <= root.h) {
|
||||
return root;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private splitNode(TreeNode: TreeNode, w: number, h: number): TreeNode {
|
||||
TreeNode.used = true;
|
||||
TreeNode.down = {
|
||||
x: TreeNode.x,
|
||||
y: TreeNode.y + h + this.gap,
|
||||
w: TreeNode.w,
|
||||
h: TreeNode.h - h - this.gap,
|
||||
};
|
||||
TreeNode.right = {
|
||||
x: TreeNode.x + w + this.gap,
|
||||
y: TreeNode.y,
|
||||
w: TreeNode.w - w - this.gap,
|
||||
h,
|
||||
};
|
||||
return TreeNode;
|
||||
}
|
||||
|
||||
private growNode(w: number, h: number): TreeNode | null {
|
||||
if (!this.root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canGrowDown = w <= this.root.w;
|
||||
const canGrowRight = h <= this.root.h;
|
||||
|
||||
const shouldGrowRight =
|
||||
canGrowRight && this.root.h >= this.root.w + w + this.gap;
|
||||
const shouldGrowDown =
|
||||
canGrowDown && this.root.w >= this.root.h + h + this.gap;
|
||||
|
||||
if (shouldGrowRight) {
|
||||
return this.growRight(w, h);
|
||||
} else if (shouldGrowDown) {
|
||||
return this.growDown(w, h);
|
||||
} else if (canGrowRight) {
|
||||
return this.growRight(w, h);
|
||||
} else if (canGrowDown) {
|
||||
return this.growDown(w, h);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private growRight(w: number, h: number): TreeNode | null {
|
||||
if (!this.root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.root = {
|
||||
used: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: this.root.w + w + this.gap,
|
||||
h: this.root.h,
|
||||
down: this.root,
|
||||
right: { x: this.root.w + this.gap, y: 0, w, h: this.root.h },
|
||||
};
|
||||
|
||||
const TreeNode = this.findNode(this.root, w, h);
|
||||
return TreeNode ? this.splitNode(TreeNode, w, h) : null;
|
||||
}
|
||||
|
||||
private growDown(w: number, h: number): TreeNode | null {
|
||||
if (!this.root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.root = {
|
||||
used: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: this.root.w,
|
||||
h: this.root.h + h + this.gap,
|
||||
down: { x: 0, y: this.root.h + this.gap, w: this.root.w, h },
|
||||
right: this.root,
|
||||
};
|
||||
|
||||
const TreeNode = this.findNode(this.root, w, h);
|
||||
return TreeNode ? this.splitNode(TreeNode, w, h) : null;
|
||||
}
|
||||
}
|
121
packages/element/src/arrange.ts
Normal file
121
packages/element/src/arrange.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { getCommonBoundingBox } from "./bounds";
|
||||
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import { mutateElement } from "./mutateElement";
|
||||
|
||||
import { GrowingPacker, type Block } from "./arrange-algorithms/packer";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ArrangeAlgorithms,
|
||||
} from "./types";
|
||||
|
||||
interface Group {
|
||||
group: ExcalidrawElement[];
|
||||
boundingBox: BoundingBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all elements relative to the group position
|
||||
*/
|
||||
const mutateGroup = (
|
||||
group: ExcalidrawElement[],
|
||||
update: { x: number; y: number },
|
||||
) => {
|
||||
// Determine the delta of the group position, vs the new update position
|
||||
const groupBoundingBox = getCommonBoundingBox(group);
|
||||
const deltaX = update.x - groupBoundingBox.minX;
|
||||
const deltaY = update.y - groupBoundingBox.minY;
|
||||
// Update the elements in the group
|
||||
|
||||
group.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: element.x + deltaX,
|
||||
y: element.y + deltaY,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const arrangeElementsBinaryTreePacking = (
|
||||
groups: Group[],
|
||||
gap: number,
|
||||
): ExcalidrawElement[] => {
|
||||
const flattendGroups = groups.flatMap((g) => g.group);
|
||||
const commonBoundingBox = getCommonBoundingBox(flattendGroups);
|
||||
const origin = {
|
||||
x: commonBoundingBox.minX,
|
||||
y: commonBoundingBox.minY,
|
||||
};
|
||||
|
||||
const groupBlocks: (Block & {
|
||||
group: ExcalidrawElement[];
|
||||
})[] = groups
|
||||
// sort gropus by maxSide, highest to lowest
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Math.max(b.boundingBox.width, b.boundingBox.height) -
|
||||
Math.max(a.boundingBox.width, a.boundingBox.height),
|
||||
)
|
||||
.map((g) => ({
|
||||
w: g.boundingBox.width,
|
||||
h: g.boundingBox.height,
|
||||
group: g.group,
|
||||
}));
|
||||
|
||||
const packer = new GrowingPacker(gap);
|
||||
packer.fit(groupBlocks);
|
||||
|
||||
const groupsAdded = [];
|
||||
for (let n = 0; n < groupBlocks.length; n++) {
|
||||
const block = groupBlocks[n];
|
||||
if (block.fit) {
|
||||
// Add to elements translation
|
||||
groupsAdded.push(block);
|
||||
// DrawRectangle(block.fit.x, block.fit.y, block.w, block.h);
|
||||
}
|
||||
}
|
||||
// For each groupsAdded, we need to actually perform the translation
|
||||
// and update the elements
|
||||
groupsAdded.forEach((group) => {
|
||||
mutateGroup(group.group, {
|
||||
x: origin.x + (group.fit?.x ?? 0),
|
||||
y: origin.y + (group.fit?.y ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
return groupsAdded.flatMap((group) => group.group);
|
||||
};
|
||||
|
||||
const arrangeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
algorithm: ArrangeAlgorithms,
|
||||
gap: number,
|
||||
): ExcalidrawElement[] => {
|
||||
// Determine the groups that we would be rearranging, as we don't want to be
|
||||
// making any manipulations within groups
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
);
|
||||
const groupBoundingBoxes = groups.map((group) => ({
|
||||
group,
|
||||
boundingBox: getCommonBoundingBox(group),
|
||||
}));
|
||||
|
||||
switch (algorithm) {
|
||||
case "bin-packing":
|
||||
return arrangeElementsBinaryTreePacking(groupBoundingBoxes, gap);
|
||||
default:
|
||||
console.warn(
|
||||
`Unimplemented algorithm [${algorithm}] - using bin-packing`,
|
||||
);
|
||||
return arrangeElementsBinaryTreePacking(groupBoundingBoxes, gap);
|
||||
}
|
||||
};
|
||||
|
||||
export { arrangeElements };
|
|
@ -412,3 +412,5 @@ export type NonDeletedSceneElementsMap = Map<
|
|||
export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
||||
|
||||
export type ArrangeAlgorithms = "bin-packing"; // Add more here if required
|
||||
|
|
79
packages/excalidraw/actions/actionArrange.tsx
Normal file
79
packages/excalidraw/actions/actionArrange.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { arrayToMap, getShortcutKey, KEYS, matchKey } from "@excalidraw/common";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
|
||||
import { arrangeElements } from "@excalidraw/element/arrange";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { TableCellsIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import { alignActionsPredicate } from "./actionAlign";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const arrangeSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const updatedElements = arrangeElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState.arrangeConfiguration.algorithm,
|
||||
appState.arrangeConfiguration.gap,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
// Note that this is basically the same as alignActions so the conditions
|
||||
// to use this action are the same
|
||||
export const arrangeElementsPredicate = alignActionsPredicate;
|
||||
|
||||
/**
|
||||
* Arranges selected elements in to be positioned nicely next to each
|
||||
* other.
|
||||
*
|
||||
* Takes into account the current state's gap setting or selected algorithm
|
||||
*/
|
||||
export const actionArrangeElements = register({
|
||||
name: "arrangeElements",
|
||||
label: "labels.arrangeElements",
|
||||
keywords: ["arrange", "rearrange", "spread"],
|
||||
icon: TableCellsIcon,
|
||||
trackEvent: { category: "element" },
|
||||
viewMode: false,
|
||||
predicate: (_elements, appState, _appProps, app) =>
|
||||
arrangeElementsPredicate(appState, app),
|
||||
perform: (elements, appState, _value, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: arrangeSelectedElements(elements, appState, app),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && matchKey(event, KEYS.R),
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="arrangeButton"
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.arrangeElements")} — ${getShortcutKey("Shift+R")}`}
|
||||
>
|
||||
{TableCellsIcon}
|
||||
</button>
|
||||
),
|
||||
});
|
|
@ -69,6 +69,8 @@ export {
|
|||
distributeVertically,
|
||||
} from "./actionDistribute";
|
||||
|
||||
export { actionArrangeElements } from "./actionArrange";
|
||||
|
||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||
|
||||
export {
|
||||
|
|
|
@ -50,6 +50,7 @@ export type ShortcutName =
|
|||
| "saveToActiveFile"
|
||||
| "toggleShortcuts"
|
||||
| "wrapSelectionInFrame"
|
||||
| "arrangeElements"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
|
@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
wrapSelectionInFrame: [],
|
||||
arrangeElements: [getShortcutKey("Shift+R")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
|
|
@ -110,6 +110,7 @@ export type ActionName =
|
|||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically"
|
||||
| "arrangeElements"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
|
|
|
@ -24,6 +24,10 @@ export const getDefaultAppState = (): Omit<
|
|||
> => {
|
||||
return {
|
||||
showWelcomeScreen: false,
|
||||
arrangeConfiguration: {
|
||||
gap: 10,
|
||||
algorithm: "bin-packing",
|
||||
},
|
||||
theme: THEME.LIGHT,
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
|
@ -142,6 +146,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||
config)({
|
||||
showWelcomeScreen: { browser: true, export: false, server: false },
|
||||
arrangeConfiguration: { browser: true, export: false, server: false },
|
||||
theme: { browser: true, export: false, server: false },
|
||||
collaborators: { browser: false, export: false, server: false },
|
||||
currentChartType: { browser: true, export: false, server: false },
|
||||
|
|
|
@ -258,6 +258,9 @@ export const SelectedShapeActions = ({
|
|||
{renderAction("alignBottom")}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeVertically")}
|
||||
{/* breaks the row ˇˇ */}
|
||||
<div style={{ flexBasis: "100%", height: 0 }} />
|
||||
{renderAction("arrangeElements")}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
@ -282,6 +282,7 @@ function CommandPaletteInner({
|
|||
actionManager.actions.alignRight,
|
||||
actionManager.actions.alignVerticallyCentered,
|
||||
actionManager.actions.alignHorizontallyCentered,
|
||||
actionManager.actions.arrangeElements,
|
||||
actionManager.actions.duplicateSelection,
|
||||
actionManager.actions.flipHorizontal,
|
||||
actionManager.actions.flipVertical,
|
||||
|
|
|
@ -438,6 +438,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
label={t("labels.alignRight")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.arrangeElements")}
|
||||
shortcuts={[getShortcutKey("Shift+R")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.duplicateSelection")}
|
||||
shortcuts={[
|
||||
|
|
|
@ -2236,3 +2236,19 @@ export const elementLinkIcon = createIcon(
|
|||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// Generated with copilot
|
||||
export const TableCellsIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<rect x="4" y="4" width="4" height="4" rx="1" />
|
||||
<rect x="10" y="4" width="4" height="4" rx="1" />
|
||||
<rect x="16" y="4" width="4" height="4" rx="1" />
|
||||
<rect x="4" y="10" width="4" height="4" rx="1" />
|
||||
<rect x="10" y="10" width="4" height="4" rx="1" />
|
||||
<rect x="16" y="10" width="4" height="4" rx="1" />
|
||||
<rect x="4" y="16" width="4" height="4" rx="1" />
|
||||
<rect x="10" y="16" width="4" height="4" rx="1" />
|
||||
<rect x="16" y="16" width="4" height="4" rx="1" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
|
|
@ -119,6 +119,7 @@
|
|||
"showBackground": "Show background color picker",
|
||||
"showFonts": "Show font picker",
|
||||
"toggleTheme": "Toggle light/dark theme",
|
||||
"arrangeElements": "Arrange elements",
|
||||
"theme": "Theme",
|
||||
"personalLib": "Personal Library",
|
||||
"excalidrawLib": "Excalidraw Library",
|
||||
|
|
|
@ -34,6 +34,7 @@ import type {
|
|||
ExcalidrawIframeLikeElement,
|
||||
OrderedExcalidrawElement,
|
||||
ExcalidrawNonSelectionElement,
|
||||
ArrangeAlgorithms,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
|
@ -260,6 +261,10 @@ export interface AppState {
|
|||
} | null;
|
||||
showWelcomeScreen: boolean;
|
||||
isLoading: boolean;
|
||||
arrangeConfiguration: {
|
||||
algorithm: ArrangeAlgorithms;
|
||||
gap: number;
|
||||
};
|
||||
errorMessage: React.ReactNode;
|
||||
activeEmbeddable: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue