From 4944a739c8d80adfdf4b502758d7a0a7ebaeeca2 Mon Sep 17 00:00:00 2001 From: feng Date: Mon, 21 Apr 2025 14:38:40 +0900 Subject: [PATCH 1/2] fix: hide the alignment buttons if there is only one group selected (they do nothing if only one group is selected) --- packages/excalidraw/actions/actionAlign.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 46023d61e..ffea3e948 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { alignElements } from "@excalidraw/element/align"; +import { getMaximumGroups } from "@excalidraw/element/groups"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Alignment } from "@excalidraw/element/align"; @@ -36,8 +38,12 @@ export const alignActionsPredicate = ( app: AppClassProperties, ) => { const selectedElements = app.scene.getSelectedElements(appState); + const groups = getMaximumGroups( + selectedElements, + app.scene.getNonDeletedElementsMap(), + ); return ( - selectedElements.length > 1 && + groups.length > 1 && // TODO enable aligning frames when implemented properly !selectedElements.some((el) => isFrameLikeElement(el)) ); From bf8f0c48c3ee6ec00ed76cc00128cc2ad7cffacd Mon Sep 17 00:00:00 2001 From: feng Date: Mon, 21 Apr 2025 18:39:51 +0900 Subject: [PATCH 2/2] feat: add arrange elements action --- .../element/src/arrange-algorithms/packer.ts | 146 ++++++++++++++++++ packages/element/src/arrange.ts | 121 +++++++++++++++ packages/element/src/types.ts | 2 + packages/excalidraw/actions/actionArrange.tsx | 79 ++++++++++ packages/excalidraw/actions/index.ts | 2 + packages/excalidraw/actions/shortcuts.ts | 2 + packages/excalidraw/actions/types.ts | 1 + packages/excalidraw/appState.ts | 5 + packages/excalidraw/components/Actions.tsx | 3 + .../CommandPalette/CommandPalette.tsx | 1 + packages/excalidraw/components/HelpDialog.tsx | 4 + packages/excalidraw/components/icons.tsx | 16 ++ packages/excalidraw/locales/en.json | 1 + packages/excalidraw/types.ts | 5 + 14 files changed, 388 insertions(+) create mode 100644 packages/element/src/arrange-algorithms/packer.ts create mode 100644 packages/element/src/arrange.ts create mode 100644 packages/excalidraw/actions/actionArrange.tsx diff --git a/packages/element/src/arrange-algorithms/packer.ts b/packages/element/src/arrange-algorithms/packer.ts new file mode 100644 index 000000000..2b3bd6b8e --- /dev/null +++ b/packages/element/src/arrange-algorithms/packer.ts @@ -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; + } +} diff --git a/packages/element/src/arrange.ts b/packages/element/src/arrange.ts new file mode 100644 index 000000000..2e41c22f2 --- /dev/null +++ b/packages/element/src/arrange.ts @@ -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 }; diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 3b40135d5..dd708a5dd 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -412,3 +412,5 @@ export type NonDeletedSceneElementsMap = Map< export type ElementsMapOrArray = | readonly ExcalidrawElement[] | Readonly; + +export type ArrangeAlgorithms = "bin-packing"; // Add more here if required diff --git a/packages/excalidraw/actions/actionArrange.tsx b/packages/excalidraw/actions/actionArrange.tsx new file mode 100644 index 000000000..4f53c0f4d --- /dev/null +++ b/packages/excalidraw/actions/actionArrange.tsx @@ -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, + 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 }) => ( + + ), +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index f37747aeb..5b6579a64 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -69,6 +69,8 @@ export { distributeVertically, } from "./actionDistribute"; +export { actionArrangeElements } from "./actionArrange"; + export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; export { diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index 1a13f1703..bdc39797f 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -50,6 +50,7 @@ export type ShortcutName = | "saveToActiveFile" | "toggleShortcuts" | "wrapSelectionInFrame" + | "arrangeElements" > | "saveScene" | "imageExport" @@ -116,6 +117,7 @@ const shortcutMap: Record = { toggleShortcuts: [getShortcutKey("?")], searchMenu: [getShortcutKey("CtrlOrCmd+F")], wrapSelectionInFrame: [], + arrangeElements: [getShortcutKey("Shift+R")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c63a122e0..4ccdcb2cd 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -110,6 +110,7 @@ export type ActionName = | "alignHorizontallyCentered" | "distributeHorizontally" | "distributeVertically" + | "arrangeElements" | "flipHorizontal" | "flipVertical" | "viewMode" diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index a75745f2a..b01e1e346 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -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 }, diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 3a7df37a8..a2a9bb3d5 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -258,6 +258,9 @@ export const SelectedShapeActions = ({ {renderAction("alignBottom")} {targetElements.length > 2 && renderAction("distributeVertically")} + {/* breaks the row ˇˇ */} +
+ {renderAction("arrangeElements")}
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 8b45e3377..109f56b75 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -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, diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 60fc40372..0d97a0a3e 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -438,6 +438,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("labels.alignRight")} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]} /> + , tablerIconProps, ); + +// Generated with copilot +export const TableCellsIcon = createIcon( + + + + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 381f2b67f..d982319f0 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -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", diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index cba9fbea7..2f611812d 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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;