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 =
|
export type ElementsMapOrArray =
|
||||||
| readonly ExcalidrawElement[]
|
| readonly ExcalidrawElement[]
|
||||||
| Readonly<ElementsMap>;
|
| 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,
|
distributeVertically,
|
||||||
} from "./actionDistribute";
|
} from "./actionDistribute";
|
||||||
|
|
||||||
|
export { actionArrangeElements } from "./actionArrange";
|
||||||
|
|
||||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -110,6 +110,7 @@ export type ActionName =
|
||||||
| "alignHorizontallyCentered"
|
| "alignHorizontallyCentered"
|
||||||
| "distributeHorizontally"
|
| "distributeHorizontally"
|
||||||
| "distributeVertically"
|
| "distributeVertically"
|
||||||
|
| "arrangeElements"
|
||||||
| "flipHorizontal"
|
| "flipHorizontal"
|
||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "viewMode"
|
| "viewMode"
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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={[
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue