mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-29 01:44:25 +01:00 
			
		
		
		
	| @@ -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, { | ||||
|             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,12 +1,16 @@ | ||||
| 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: selectGroupsForSelectedElements( | ||||
|         { | ||||
|           ...appState, | ||||
|           editingGroupId: null, | ||||
|           selectedElementIds: elements.reduce((map, element) => { | ||||
|             if (!element.isDeleted) { | ||||
|               map[element.id] = true; | ||||
| @@ -14,6 +18,8 @@ export const actionSelectAll = register({ | ||||
|             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) => ({ | ||||
|             // 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,13 +2406,20 @@ 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) => ({ | ||||
|         this.setState((prevState) => | ||||
|           selectGroupsForSelectedElements( | ||||
|             { | ||||
|               ...prevState, | ||||
|               selectedElementIds: { | ||||
|                 ...prevState.selectedElementIds, | ||||
|                 ...elementsWithinSelection.reduce((map, element) => { | ||||
| @@ -2330,7 +2427,10 @@ class App extends React.Component<any, AppState> { | ||||
|                   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,19 +189,57 @@ export const renderScene = ( | ||||
|         ); | ||||
|       } | ||||
|       if (selectionColors.length) { | ||||
|         acc.push({ element, selectionColors }); | ||||
|       } | ||||
|       return acc; | ||||
|     }, [] as { element: ExcalidrawElement; selectionColors: string[] }[]); | ||||
|  | ||||
|     selections.forEach(({ element, selectionColors }) => { | ||||
|         const [ | ||||
|           elementX1, | ||||
|           elementY1, | ||||
|           elementX2, | ||||
|           elementY2, | ||||
|         ] = getElementAbsoluteCoords(element); | ||||
|         acc.push({ | ||||
|           angle: element.angle, | ||||
|           elementX1, | ||||
|           elementY1, | ||||
|           elementX2, | ||||
|           elementY2, | ||||
|           selectionColors, | ||||
|         }); | ||||
|       } | ||||
|       return acc; | ||||
|     }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]); | ||||
|  | ||||
|     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, | ||||
|         selectionColors, | ||||
|       }) => { | ||||
|         const elementWidth = elementX2 - elementX1; | ||||
|         const elementHeight = elementY2 - elementY1; | ||||
|  | ||||
| @@ -223,14 +270,15 @@ export const renderScene = ( | ||||
|             elementHeight + dashedLinePadding * 2, | ||||
|             elementX1 + elementWidth / 2, | ||||
|             elementY1 + elementHeight / 2, | ||||
|           element.angle, | ||||
|             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<{ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Pete Hunt
					Pete Hunt