mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-10 11:35:52 +01:00
parent
5252726307
commit
61e5b66dac
@ -12,15 +12,21 @@ import { getShortcutKey } from "../utils";
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
perform: (elements, appState) => {
|
||||
const groupIdMap = new Map();
|
||||
return {
|
||||
appState,
|
||||
elements: elements.reduce(
|
||||
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = duplicateElement(element, {
|
||||
x: element.x + 10,
|
||||
y: element.y + 10,
|
||||
});
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + 10,
|
||||
y: element.y + 10,
|
||||
},
|
||||
);
|
||||
appState.selectedElementIds[newElement.id] = true;
|
||||
delete appState.selectedElementIds[element.id];
|
||||
return acc.concat([element, newElement]);
|
||||
|
119
src/actions/actionGroup.ts
Normal file
119
src/actions/actionGroup.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import nanoid from "nanoid";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
getElementsInGroup,
|
||||
addToGroup,
|
||||
removeFromSelectedGroups,
|
||||
} from "../groups";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
// if everything is already grouped into 1 group, there is nothing to do
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
if (selectedGroupIds.length === 1) {
|
||||
const selectedGroupId = selectedGroupIds[0];
|
||||
const elementIdsInGroup = new Set(
|
||||
getElementsInGroup(elements, selectedGroupId).map(
|
||||
(element) => element.id,
|
||||
),
|
||||
);
|
||||
const selectedElementIds = new Set(
|
||||
selectedElements.map((element) => element.id),
|
||||
);
|
||||
const combinedSet = new Set([
|
||||
...Array.from(elementIdsInGroup),
|
||||
...Array.from(selectedElementIds),
|
||||
]);
|
||||
if (combinedSet.size === elementIdsInGroup.size) {
|
||||
// no incremental ids in the selected ids
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
}
|
||||
const newGroupId = nanoid();
|
||||
const updatedElements = elements.map((element) => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
groupIds: addToGroup(
|
||||
element.groupIds,
|
||||
newGroupId,
|
||||
appState.editingGroupId,
|
||||
),
|
||||
});
|
||||
});
|
||||
return {
|
||||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(updatedElements),
|
||||
),
|
||||
elements: updatedElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextMenuOrder: 4,
|
||||
contextItemLabel: "labels.group",
|
||||
keyTest: (event) => {
|
||||
return (
|
||||
!event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.keyCode === KEYS.G_KEY_CODE
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
perform: (elements, appState) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
const nextElements = elements.map((element) => {
|
||||
const nextGroupIds = removeFromSelectedGroups(
|
||||
element.groupIds,
|
||||
appState.selectedGroupIds,
|
||||
);
|
||||
if (nextGroupIds.length === element.groupIds.length) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
groupIds: nextGroupIds,
|
||||
});
|
||||
});
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => {
|
||||
return (
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.keyCode === KEYS.G_KEY_CODE
|
||||
);
|
||||
},
|
||||
contextMenuOrder: 5,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
});
|
@ -1,19 +1,25 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (!element.isDeleted) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (!element.isDeleted) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
@ -44,3 +44,5 @@ export {
|
||||
actionFullScreen,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
|
||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||
|
@ -55,7 +55,9 @@ export type ActionName =
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts";
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
| "ungroup";
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
|
@ -48,6 +48,8 @@ export const getDefaultAppState = (): AppState => {
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showShortcutsDialog: false,
|
||||
zenModeEnabled: false,
|
||||
editingGroupId: null,
|
||||
selectedGroupIds: {},
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -131,6 +131,12 @@ import {
|
||||
} from "../data/localStorage";
|
||||
|
||||
import throttle from "lodash.throttle";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroupsForSelectedElements,
|
||||
isElementInGroup,
|
||||
getSelectedGroupIdForElement,
|
||||
} from "../groups";
|
||||
|
||||
/**
|
||||
* @param func handler taking at most single parameter (event).
|
||||
@ -704,9 +710,10 @@ class App extends React.Component<any, AppState> {
|
||||
|
||||
const dx = x - elementsCenterX;
|
||||
const dy = y - elementsCenterY;
|
||||
const groupIdMap = new Map();
|
||||
|
||||
const newElements = clipboardElements.map((element) =>
|
||||
duplicateElement(element, {
|
||||
duplicateElement(this.state.editingGroupId, groupIdMap, element, {
|
||||
x: element.x + dx - minX,
|
||||
y: element.y + dy - minY,
|
||||
}),
|
||||
@ -1212,7 +1219,11 @@ class App extends React.Component<any, AppState> {
|
||||
resetCursor();
|
||||
} else {
|
||||
setCursorForShape(this.state.elementType);
|
||||
this.setState({ selectedElementIds: {} });
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
}
|
||||
isHoldingSpace = false;
|
||||
}
|
||||
@ -1226,7 +1237,12 @@ class App extends React.Component<any, AppState> {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (elementType !== "selection") {
|
||||
this.setState({ elementType, selectedElementIds: {} });
|
||||
this.setState({
|
||||
elementType,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({ elementType });
|
||||
}
|
||||
@ -1337,7 +1353,11 @@ class App extends React.Component<any, AppState> {
|
||||
}),
|
||||
});
|
||||
// deselect all other elements when inserting text
|
||||
this.setState({ selectedElementIds: {} });
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
|
||||
// do an initial update to re-initialize element position since we were
|
||||
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||
@ -1459,8 +1479,6 @@ class App extends React.Component<any, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
resetCursor();
|
||||
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
@ -1468,6 +1486,40 @@ class App extends React.Component<any, AppState> {
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
|
||||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||
|
||||
if (selectedGroupIds.length > 0) {
|
||||
const elements = globalSceneState.getElements();
|
||||
const hitElement = getElementAtPosition(
|
||||
elements,
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
this.state.zoom,
|
||||
);
|
||||
|
||||
const selectedGroupId =
|
||||
hitElement &&
|
||||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||
|
||||
if (selectedGroupId) {
|
||||
this.setState((prevState) =>
|
||||
selectGroupsForSelectedElements(
|
||||
{
|
||||
...prevState,
|
||||
editingGroupId: selectedGroupId,
|
||||
selectedElementIds: { [hitElement!.id]: true },
|
||||
selectedGroupIds: {},
|
||||
},
|
||||
globalSceneState.getElements(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resetCursor();
|
||||
|
||||
this.startTextEditing({
|
||||
x: x,
|
||||
y: y,
|
||||
@ -1942,7 +1994,16 @@ class App extends React.Component<any, AppState> {
|
||||
!(hitElement && this.state.selectedElementIds[hitElement.id]) &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
this.setState({ selectedElementIds: {} });
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId:
|
||||
prevState.editingGroupId &&
|
||||
hitElement &&
|
||||
isElementInGroup(hitElement, prevState.editingGroupId)
|
||||
? prevState.editingGroupId
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// If we click on something
|
||||
@ -1952,12 +2013,32 @@ class App extends React.Component<any, AppState> {
|
||||
// otherwise, it will trigger selection based on current
|
||||
// state of the box
|
||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: true,
|
||||
},
|
||||
}));
|
||||
// if we are currently editing a group, treat all selections outside of the group
|
||||
// as exiting editing mode.
|
||||
if (
|
||||
this.state.editingGroupId &&
|
||||
!isElementInGroup(hitElement, this.state.editingGroupId)
|
||||
) {
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState((prevState) => {
|
||||
return selectGroupsForSelectedElements(
|
||||
{
|
||||
...prevState,
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: true,
|
||||
},
|
||||
},
|
||||
globalSceneState.getElements(),
|
||||
);
|
||||
});
|
||||
// TODO: this is strange...
|
||||
globalSceneState.replaceAllElements(
|
||||
globalSceneState.getElementsIncludingDeleted(),
|
||||
);
|
||||
@ -1966,7 +2047,11 @@ class App extends React.Component<any, AppState> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.setState({ selectedElementIds: {} });
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.elementType === "text") {
|
||||
@ -2218,6 +2303,7 @@ class App extends React.Component<any, AppState> {
|
||||
|
||||
const nextElements = [];
|
||||
const elementsToAppend = [];
|
||||
const groupIdMap = new Map();
|
||||
for (const element of globalSceneState.getElementsIncludingDeleted()) {
|
||||
if (
|
||||
this.state.selectedElementIds[element.id] ||
|
||||
@ -2225,7 +2311,11 @@ class App extends React.Component<any, AppState> {
|
||||
// updated yet by the time this mousemove event is fired
|
||||
(element.id === hitElement.id && hitElementWasAddedToSelection)
|
||||
) {
|
||||
const duplicatedElement = duplicateElement(element);
|
||||
const duplicatedElement = duplicateElement(
|
||||
this.state.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
);
|
||||
mutateElement(duplicatedElement, {
|
||||
x: duplicatedElement.x + (originX - lastX),
|
||||
y: duplicatedElement.y + (originY - lastY),
|
||||
@ -2316,21 +2406,31 @@ class App extends React.Component<any, AppState> {
|
||||
if (this.state.elementType === "selection") {
|
||||
const elements = globalSceneState.getElements();
|
||||
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
|
||||
this.setState({ selectedElementIds: {} });
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
}
|
||||
const elementsWithinSelection = getElementsWithinSelection(
|
||||
elements,
|
||||
draggingElement,
|
||||
);
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
...elementsWithinSelection.reduce((map, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
}));
|
||||
this.setState((prevState) =>
|
||||
selectGroupsForSelectedElements(
|
||||
{
|
||||
...prevState,
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
...elementsWithinSelection.reduce((map, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {} as any),
|
||||
},
|
||||
},
|
||||
globalSceneState.getElements(),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -2445,7 +2545,12 @@ class App extends React.Component<any, AppState> {
|
||||
// If click occurred and elements were dragged or some element
|
||||
// was added to selection (on pointerdown phase) we need to keep
|
||||
// selection unchanged
|
||||
if (hitElement && !draggingOccurred && !hitElementWasAddedToSelection) {
|
||||
if (
|
||||
getSelectedGroupIds(this.state).length === 0 &&
|
||||
hitElement &&
|
||||
!draggingOccurred &&
|
||||
!hitElementWasAddedToSelection
|
||||
) {
|
||||
if (childEvent.shiftKey) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
@ -2462,7 +2567,11 @@ class App extends React.Component<any, AppState> {
|
||||
|
||||
if (draggingElement === null) {
|
||||
// if no element is clicked, clear the selection and redraw
|
||||
this.setState({ selectedElementIds: {} });
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -318,6 +318,14 @@ export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("buttons.redo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.group")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.ungroup")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
</Columns>
|
||||
|
@ -71,7 +71,8 @@ export const restore = (
|
||||
|
||||
return {
|
||||
...element,
|
||||
// all elements must have version > 0 so getDrawingVersion() will pick up newly added elements
|
||||
// all elements must have version > 0 so getDrawingVersion() will pick
|
||||
// up newly added elements
|
||||
version: element.version || 1,
|
||||
id: element.id || randomId(),
|
||||
isDeleted: false,
|
||||
@ -84,6 +85,7 @@ export const restore = (
|
||||
? 100
|
||||
: element.opacity,
|
||||
angle: element.angle ?? 0,
|
||||
groupIds: element.groupIds || [],
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -45,7 +45,7 @@ it("clones arrow element", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(element);
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
@ -82,7 +82,7 @@ it("clones text element", () => {
|
||||
textAlign: "left",
|
||||
});
|
||||
|
||||
const copy = duplicateElement(element);
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
|
@ -5,10 +5,13 @@ import {
|
||||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
GroupId,
|
||||
} from "../element/types";
|
||||
import { measureText } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import nanoid from "nanoid";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
|
||||
type ElementConstructorOpts = {
|
||||
x: ExcalidrawGenericElement["x"];
|
||||
@ -61,6 +64,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
version: rest.version || 1,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
groupIds: [],
|
||||
});
|
||||
|
||||
export const newElement = (
|
||||
@ -148,13 +152,39 @@ export const deepCopyElement = (val: any, depth: number = 0) => {
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
* introduction of gruoping/ungrouping elements.
|
||||
* @param editingGroupId The current group being edited. The new
|
||||
* element will inherit this group and its
|
||||
* parents.
|
||||
* @param groupIdMapForOperation A Map that maps old group IDs to
|
||||
* duplicated ones. If you are duplicating
|
||||
* multiple elements at once, share this map
|
||||
* amongst all of them
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
editingGroupId: GroupId | null,
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
): TElement => {
|
||||
let copy: TElement = deepCopyElement(element);
|
||||
copy.id = randomId();
|
||||
copy.seed = randomInteger();
|
||||
copy.groupIds = getNewGroupIdsForDuplication(
|
||||
copy.groupIds,
|
||||
editingGroupId,
|
||||
(groupId) => {
|
||||
if (!groupIdMapForOperation.has(groupId)) {
|
||||
groupIdMapForOperation.set(groupId, nanoid());
|
||||
}
|
||||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
);
|
||||
if (overrides) {
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Point } from "../types";
|
||||
|
||||
export type GroupId = string;
|
||||
|
||||
type _ExcalidrawElementBase = Readonly<{
|
||||
id: string;
|
||||
x: number;
|
||||
@ -18,8 +20,12 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
version: number;
|
||||
versionNonce: number;
|
||||
isDeleted: boolean;
|
||||
groupIds: GroupId[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
export type ExcalidrawGenericElement = _ExcalidrawElementBase & {
|
||||
type: "selection" | "rectangle" | "diamond" | "ellipse";
|
||||
};
|
||||
|
130
src/groups.ts
Normal file
130
src/groups.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
|
||||
import { AppState } from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
|
||||
export function selectGroup(
|
||||
groupId: GroupId,
|
||||
appState: AppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
): AppState {
|
||||
return {
|
||||
...appState,
|
||||
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
|
||||
selectedElementIds: {
|
||||
...appState.selectedElementIds,
|
||||
...Object.fromEntries(
|
||||
elements
|
||||
.filter((element) => element.groupIds.includes(groupId))
|
||||
.map((element) => [element.id, true]),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the element's group is selected, don't render an individual
|
||||
* selection border around it.
|
||||
*/
|
||||
export function isSelectedViaGroup(
|
||||
appState: AppState,
|
||||
element: ExcalidrawElement,
|
||||
) {
|
||||
return !!element.groupIds
|
||||
.filter((groupId) => groupId !== appState.editingGroupId)
|
||||
.find((groupId) => appState.selectedGroupIds[groupId]);
|
||||
}
|
||||
|
||||
export function getSelectedGroupIds(appState: AppState): GroupId[] {
|
||||
return Object.entries(appState.selectedGroupIds)
|
||||
.filter(([groupId, isSelected]) => isSelected)
|
||||
.map(([groupId, isSelected]) => groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* When you select an element, you often want to actually select the whole group it's in, unless
|
||||
* you're currently editing that group.
|
||||
*/
|
||||
export function selectGroupsForSelectedElements(
|
||||
appState: AppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
): AppState {
|
||||
let nextAppState = { ...appState };
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
for (const selectedElement of selectedElements) {
|
||||
let groupIds = selectedElement.groupIds;
|
||||
if (appState.editingGroupId) {
|
||||
// handle the case where a group is nested within a group
|
||||
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
|
||||
if (indexOfEditingGroup > -1) {
|
||||
groupIds = groupIds.slice(0, indexOfEditingGroup);
|
||||
}
|
||||
}
|
||||
if (groupIds.length > 0) {
|
||||
const groupId = groupIds[groupIds.length - 1];
|
||||
nextAppState = selectGroup(groupId, nextAppState, elements);
|
||||
}
|
||||
}
|
||||
|
||||
return nextAppState;
|
||||
}
|
||||
|
||||
export function isElementInGroup(element: ExcalidrawElement, groupId: string) {
|
||||
return element.groupIds.includes(groupId);
|
||||
}
|
||||
|
||||
export function getElementsInGroup(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
groupId: string,
|
||||
) {
|
||||
return elements.filter((element) => isElementInGroup(element, groupId));
|
||||
}
|
||||
|
||||
export function getSelectedGroupIdForElement(
|
||||
element: ExcalidrawElement,
|
||||
selectedGroupIds: { [groupId: string]: boolean },
|
||||
) {
|
||||
return element.groupIds.find((groupId) => selectedGroupIds[groupId]);
|
||||
}
|
||||
|
||||
export function getNewGroupIdsForDuplication(
|
||||
groupIds: GroupId[],
|
||||
editingGroupId: GroupId | null,
|
||||
mapper: (groupId: GroupId) => GroupId,
|
||||
) {
|
||||
const copy = [...groupIds];
|
||||
const positionOfEditingGroupId = editingGroupId
|
||||
? groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const endIndex =
|
||||
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
|
||||
for (let i = 0; i < endIndex; i++) {
|
||||
copy[i] = mapper(copy[i]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function addToGroup(
|
||||
prevGroupIds: GroupId[],
|
||||
newGroupId: GroupId,
|
||||
editingGroupId: GroupId | null,
|
||||
) {
|
||||
// insert before the editingGroupId, or push to the end.
|
||||
const groupIds = [...prevGroupIds];
|
||||
const positionOfEditingGroupId = editingGroupId
|
||||
? groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const positionToInsert =
|
||||
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
|
||||
groupIds.splice(positionToInsert, 0, newGroupId);
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
export function removeFromSelectedGroups(
|
||||
groupIds: GroupId[],
|
||||
selectedGroupIds: { [groupId: string]: boolean },
|
||||
) {
|
||||
return groupIds.filter((groupId) => !selectedGroupIds[groupId]);
|
||||
}
|
@ -16,6 +16,7 @@ export const KEYS = {
|
||||
F_KEY_CODE: 70,
|
||||
ALT_KEY_CODE: 18,
|
||||
Z_KEY_CODE: 90,
|
||||
G_KEY_CODE: 71,
|
||||
} as const;
|
||||
|
||||
export type Key = keyof typeof KEYS;
|
||||
|
@ -59,7 +59,9 @@
|
||||
"untitled": "Untitled",
|
||||
"name": "Name",
|
||||
"yourName": "Your name",
|
||||
"madeWithExcalidraw": "Made with Excalidraw"
|
||||
"madeWithExcalidraw": "Made with Excalidraw",
|
||||
"group": "Group selection",
|
||||
"ungroup": "Ungroup selection"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
|
@ -6,6 +6,7 @@ import { FlooredNumber, AppState } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
GroupId,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@ -27,6 +28,11 @@ import { getSelectedElements } from "../scene/selection";
|
||||
|
||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||
import colors from "../colors";
|
||||
import {
|
||||
isSelectedViaGroup,
|
||||
getSelectedGroupIds,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
|
||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||
|
||||
@ -167,7 +173,10 @@ export const renderScene = (
|
||||
const selections = elements.reduce((acc, element) => {
|
||||
const selectionColors = [];
|
||||
// local user
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
if (
|
||||
appState.selectedElementIds[element.id] &&
|
||||
!isSelectedViaGroup(appState, element)
|
||||
) {
|
||||
selectionColors.push(oc.black);
|
||||
}
|
||||
// remote users
|
||||
@ -180,57 +189,96 @@ export const renderScene = (
|
||||
);
|
||||
}
|
||||
if (selectionColors.length) {
|
||||
acc.push({ element, selectionColors });
|
||||
const [
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
] = getElementAbsoluteCoords(element);
|
||||
acc.push({
|
||||
angle: element.angle,
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
selectionColors,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [] as { element: ExcalidrawElement; selectionColors: string[] }[]);
|
||||
}, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
|
||||
|
||||
selections.forEach(({ element, selectionColors }) => {
|
||||
const [
|
||||
function addSelectionForGroupId(groupId: GroupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId);
|
||||
const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(
|
||||
groupElements,
|
||||
);
|
||||
selections.push({
|
||||
angle: 0,
|
||||
elementX1,
|
||||
elementX2,
|
||||
elementY1,
|
||||
elementY2,
|
||||
selectionColors: [oc.black],
|
||||
});
|
||||
}
|
||||
|
||||
for (const groupId of getSelectedGroupIds(appState)) {
|
||||
// TODO: support multiplayer selected group IDs
|
||||
addSelectionForGroupId(groupId);
|
||||
}
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
addSelectionForGroupId(appState.editingGroupId);
|
||||
}
|
||||
|
||||
selections.forEach(
|
||||
({
|
||||
angle,
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
] = getElementAbsoluteCoords(element);
|
||||
selectionColors,
|
||||
}) => {
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
const elementHeight = elementY2 - elementY1;
|
||||
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
const elementHeight = elementY2 - elementY1;
|
||||
const initialLineDash = context.getLineDash();
|
||||
const lineWidth = context.lineWidth;
|
||||
const lineDashOffset = context.lineDashOffset;
|
||||
const strokeStyle = context.strokeStyle;
|
||||
|
||||
const initialLineDash = context.getLineDash();
|
||||
const lineWidth = context.lineWidth;
|
||||
const lineDashOffset = context.lineDashOffset;
|
||||
const strokeStyle = context.strokeStyle;
|
||||
const dashedLinePadding = 4 / sceneState.zoom;
|
||||
const dashWidth = 8 / sceneState.zoom;
|
||||
const spaceWidth = 4 / sceneState.zoom;
|
||||
|
||||
const dashedLinePadding = 4 / sceneState.zoom;
|
||||
const dashWidth = 8 / sceneState.zoom;
|
||||
const spaceWidth = 4 / sceneState.zoom;
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
|
||||
context.lineWidth = 1 / sceneState.zoom;
|
||||
|
||||
const count = selectionColors.length;
|
||||
for (var i = 0; i < count; ++i) {
|
||||
context.strokeStyle = selectionColors[i];
|
||||
context.setLineDash([
|
||||
dashWidth,
|
||||
spaceWidth + (dashWidth + spaceWidth) * (count - 1),
|
||||
]);
|
||||
context.lineDashOffset = (dashWidth + spaceWidth) * i;
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
elementX1 - dashedLinePadding,
|
||||
elementY1 - dashedLinePadding,
|
||||
elementWidth + dashedLinePadding * 2,
|
||||
elementHeight + dashedLinePadding * 2,
|
||||
elementX1 + elementWidth / 2,
|
||||
elementY1 + elementHeight / 2,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
context.lineDashOffset = lineDashOffset;
|
||||
context.strokeStyle = strokeStyle;
|
||||
context.lineWidth = lineWidth;
|
||||
context.setLineDash(initialLineDash);
|
||||
});
|
||||
const count = selectionColors.length;
|
||||
for (var i = 0; i < count; ++i) {
|
||||
context.strokeStyle = selectionColors[i];
|
||||
context.setLineDash([
|
||||
dashWidth,
|
||||
spaceWidth + (dashWidth + spaceWidth) * (count - 1),
|
||||
]);
|
||||
context.lineDashOffset = (dashWidth + spaceWidth) * i;
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
elementX1 - dashedLinePadding,
|
||||
elementY1 - dashedLinePadding,
|
||||
elementWidth + dashedLinePadding * 2,
|
||||
elementHeight + dashedLinePadding * 2,
|
||||
elementX1 + elementWidth / 2,
|
||||
elementY1 + elementHeight / 2,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
context.lineDashOffset = lineDashOffset;
|
||||
context.strokeStyle = strokeStyle;
|
||||
context.lineWidth = lineWidth;
|
||||
context.setLineDash(initialLineDash);
|
||||
},
|
||||
);
|
||||
context.translate(-sceneState.scrollX, -sceneState.scrollY);
|
||||
|
||||
const locallySelectedElements = getSelectedElements(elements, appState);
|
||||
|
@ -7,6 +7,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -43,6 +44,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -68,6 +70,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -91,6 +94,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -127,6 +131,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
|
@ -5,6 +5,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id2",
|
||||
"isDeleted": false,
|
||||
@ -28,6 +29,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -51,6 +53,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
|
@ -5,6 +5,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 110,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -46,6 +47,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 110,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -28,6 +29,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
|
@ -5,6 +5,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -39,6 +40,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -73,6 +75,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -96,6 +99,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
@ -119,6 +123,7 @@ Object {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"fillStyle": "hachure",
|
||||
"groupIds": Array [],
|
||||
"height": 50,
|
||||
"id": "id0",
|
||||
"isDeleted": false,
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
ExcalidrawElement,
|
||||
GroupId,
|
||||
} from "./element/types";
|
||||
import { SHAPES } from "./shapes";
|
||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
@ -67,6 +68,10 @@ export type AppState = {
|
||||
shouldCacheIgnoreZoom: boolean;
|
||||
showShortcutsDialog: boolean;
|
||||
zenModeEnabled: boolean;
|
||||
|
||||
// groups
|
||||
selectedGroupIds: { [groupId: string]: boolean };
|
||||
editingGroupId: GroupId | null;
|
||||
};
|
||||
|
||||
export type PointerCoords = Readonly<{
|
||||
|
Loading…
Reference in New Issue
Block a user