1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

Group/ungroup (#1648)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Pete Hunt 2020-05-26 13:07:46 -07:00 committed by GitHub
parent 5252726307
commit 61e5b66dac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 964 additions and 86 deletions

@ -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

@ -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

@ -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<{