This commit is contained in:
Feng 2025-04-26 10:07:13 +05:30 committed by GitHub
commit e33b90a513
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 395 additions and 1 deletions

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

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

View file

@ -412,3 +412,5 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray = export type ElementsMapOrArray =
| readonly ExcalidrawElement[] | readonly ExcalidrawElement[]
| Readonly<ElementsMap>; | Readonly<ElementsMap>;
export type ArrangeAlgorithms = "bin-packing"; // Add more here if required

View file

@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
import { alignElements } from "@excalidraw/element/align"; import { alignElements } from "@excalidraw/element/align";
import { getMaximumGroups } from "@excalidraw/element/groups";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element/align"; import type { Alignment } from "@excalidraw/element/align";
@ -36,8 +38,12 @@ export const alignActionsPredicate = (
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const groups = getMaximumGroups(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
return ( return (
selectedElements.length > 1 && groups.length > 1 &&
// TODO enable aligning frames when implemented properly // TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el)) !selectedElements.some((el) => isFrameLikeElement(el))
); );

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

View file

@ -69,6 +69,8 @@ export {
distributeVertically, distributeVertically,
} from "./actionDistribute"; } from "./actionDistribute";
export { actionArrangeElements } from "./actionArrange";
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
export { export {

View file

@ -50,6 +50,7 @@ export type ShortcutName =
| "saveToActiveFile" | "saveToActiveFile"
| "toggleShortcuts" | "toggleShortcuts"
| "wrapSelectionInFrame" | "wrapSelectionInFrame"
| "arrangeElements"
> >
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [], wrapSelectionInFrame: [],
arrangeElements: [getShortcutKey("Shift+R")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

View file

@ -110,6 +110,7 @@ export type ActionName =
| "alignHorizontallyCentered" | "alignHorizontallyCentered"
| "distributeHorizontally" | "distributeHorizontally"
| "distributeVertically" | "distributeVertically"
| "arrangeElements"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "viewMode" | "viewMode"

View file

@ -24,6 +24,10 @@ export const getDefaultAppState = (): Omit<
> => { > => {
return { return {
showWelcomeScreen: false, showWelcomeScreen: false,
arrangeConfiguration: {
gap: 10,
algorithm: "bin-packing",
},
theme: THEME.LIGHT, theme: THEME.LIGHT,
collaborators: new Map(), collaborators: new Map(),
currentChartType: "bar", currentChartType: "bar",
@ -142,6 +146,7 @@ const APP_STATE_STORAGE_CONF = (<
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
config)({ config)({
showWelcomeScreen: { browser: true, export: false, server: false }, showWelcomeScreen: { browser: true, export: false, server: false },
arrangeConfiguration: { browser: true, export: false, server: false },
theme: { browser: true, export: false, server: false }, theme: { browser: true, export: false, server: false },
collaborators: { browser: false, export: false, server: false }, collaborators: { browser: false, export: false, server: false },
currentChartType: { browser: true, export: false, server: false }, currentChartType: { browser: true, export: false, server: false },

View file

@ -258,6 +258,9 @@ export const SelectedShapeActions = ({
{renderAction("alignBottom")} {renderAction("alignBottom")}
{targetElements.length > 2 && {targetElements.length > 2 &&
renderAction("distributeVertically")} renderAction("distributeVertically")}
{/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
{renderAction("arrangeElements")}
</div> </div>
</div> </div>
</fieldset> </fieldset>

View file

@ -282,6 +282,7 @@ function CommandPaletteInner({
actionManager.actions.alignRight, actionManager.actions.alignRight,
actionManager.actions.alignVerticallyCentered, actionManager.actions.alignVerticallyCentered,
actionManager.actions.alignHorizontallyCentered, actionManager.actions.alignHorizontallyCentered,
actionManager.actions.arrangeElements,
actionManager.actions.duplicateSelection, actionManager.actions.duplicateSelection,
actionManager.actions.flipHorizontal, actionManager.actions.flipHorizontal,
actionManager.actions.flipVertical, actionManager.actions.flipVertical,

View file

@ -438,6 +438,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.alignRight")} label={t("labels.alignRight")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
/> />
<Shortcut
label={t("labels.arrangeElements")}
shortcuts={[getShortcutKey("Shift+R")]}
/>
<Shortcut <Shortcut
label={t("labels.duplicateSelection")} label={t("labels.duplicateSelection")}
shortcuts={[ shortcuts={[

View file

@ -2236,3 +2236,19 @@ export const elementLinkIcon = createIcon(
</g>, </g>,
tablerIconProps, 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,
);

View file

@ -119,6 +119,7 @@
"showBackground": "Show background color picker", "showBackground": "Show background color picker",
"showFonts": "Show font picker", "showFonts": "Show font picker",
"toggleTheme": "Toggle light/dark theme", "toggleTheme": "Toggle light/dark theme",
"arrangeElements": "Arrange elements",
"theme": "Theme", "theme": "Theme",
"personalLib": "Personal Library", "personalLib": "Personal Library",
"excalidrawLib": "Excalidraw Library", "excalidrawLib": "Excalidraw Library",

View file

@ -34,6 +34,7 @@ import type {
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ArrangeAlgorithms,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@ -260,6 +261,10 @@ export interface AppState {
} | null; } | null;
showWelcomeScreen: boolean; showWelcomeScreen: boolean;
isLoading: boolean; isLoading: boolean;
arrangeConfiguration: {
algorithm: ArrangeAlgorithms;
gap: number;
};
errorMessage: React.ReactNode; errorMessage: React.ReactNode;
activeEmbeddable: { activeEmbeddable: {
element: NonDeletedExcalidrawElement; element: NonDeletedExcalidrawElement;