diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index 718bdbb7..e0c8810c 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -10,7 +10,11 @@ import { t } from "../i18n"; import { getShortcutKey } from "../utils"; import { LinearElementEditor } from "../element/linearElementEditor"; import { mutateElement } from "../element/mutateElement"; -import { selectGroupsForSelectedElements } from "../groups"; +import { + selectGroupsForSelectedElements, + getSelectedGroupForElement, + getElementsInGroup, +} from "../groups"; import { AppState } from "../types"; import { fixBindingsAfterDuplication } from "../element/binding"; import { ActionResult } from "./types"; @@ -82,28 +86,53 @@ const duplicateElements = ( const newElements: ExcalidrawElement[] = []; const oldElements: ExcalidrawElement[] = []; const oldIdToDuplicatedId = new Map(); - const finalElements = elements.reduce( - (acc: Array, element: ExcalidrawElement) => { - if (appState.selectedElementIds[element.id]) { - const newElement = duplicateElement( - appState.editingGroupId, - groupIdMap, - element, - { - x: element.x + 10, - y: element.y + 10, - }, - ); - oldIdToDuplicatedId.set(element.id, newElement.id); - oldElements.push(element); - newElements.push(newElement); - return acc.concat([element, newElement]); + + const duplicateAndOffsetElement = (element: ExcalidrawElement) => { + const newElement = duplicateElement( + appState.editingGroupId, + groupIdMap, + element, + { + x: element.x + 10, + y: element.y + 10, + }, + ); + oldIdToDuplicatedId.set(element.id, newElement.id); + oldElements.push(element); + newElements.push(newElement); + return newElement; + }; + + const finalElements: ExcalidrawElement[] = []; + + let i = 0; + while (i < elements.length) { + const element = elements[i]; + if (appState.selectedElementIds[element.id]) { + if (element.groupIds.length) { + const groupId = getSelectedGroupForElement(appState, element); + // if group selected, duplicate it atomically + if (groupId) { + const groupElements = getElementsInGroup(elements, groupId); + finalElements.push( + ...groupElements, + ...groupElements.map((element) => + duplicateAndOffsetElement(element), + ), + ); + i = i + groupElements.length; + continue; + } } - return acc.concat(element); - }, - [], - ); + finalElements.push(element, duplicateAndOffsetElement(element)); + } else { + finalElements.push(element); + } + i++; + } + fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId); + return { elements: finalElements, appState: selectGroupsForSelectedElements( diff --git a/src/actions/actionZindex.tsx b/src/actions/actionZindex.tsx index b9d25b46..abaf0807 100644 --- a/src/actions/actionZindex.tsx +++ b/src/actions/actionZindex.tsx @@ -15,69 +15,12 @@ import { SendToBackIcon, BringForwardIcon, } from "../components/icons"; -import { ExcalidrawElement } from "../element/types"; -import { AppState } from "../types"; - -const getElementIndices = ( - direction: "left" | "right", - elements: readonly ExcalidrawElement[], - appState: AppState, -) => { - const selectedIndices: number[] = []; - let deletedIndicesCache: number[] = []; - - const cb = (element: ExcalidrawElement, index: number) => { - if (element.isDeleted) { - // we want to build an array of deleted elements that are preceeding - // a selected element so that we move them together - deletedIndicesCache.push(index); - } else { - if (appState.selectedElementIds[element.id]) { - selectedIndices.push(...deletedIndicesCache, index); - } - // always empty cache of deleted elements after either pushing a group - // of selected/deleted elements, of after encountering non-deleted elem - deletedIndicesCache = []; - } - }; - - // sending back → select contiguous deleted elements that are to the left of - // selected element(s) - if (direction === "left") { - let i = -1; - const len = elements.length; - while (++i < len) { - cb(elements[i], i); - } - // moving to front → loop from right to left so that we don't need to - // backtrack when gathering deleted elements - } else { - let i = elements.length; - while (--i > -1) { - cb(elements[i], i); - } - } - // sort in case we were gathering indexes from right to left - return selectedIndices.sort(); -}; - -const moveElements = ( - func: typeof moveOneLeft, - elements: readonly ExcalidrawElement[], - appState: AppState, -) => { - const _elements = elements.slice(); - const direction = - func === moveOneLeft || func === moveAllLeft ? "left" : "right"; - const indices = getElementIndices(direction, _elements, appState); - return func(_elements, indices); -}; export const actionSendBackward = register({ name: "sendBackward", perform: (elements, appState) => { return { - elements: moveElements(moveOneLeft, elements, appState), + elements: moveOneLeft(elements, appState), appState, commitToHistory: true, }; @@ -102,7 +45,7 @@ export const actionBringForward = register({ name: "bringForward", perform: (elements, appState) => { return { - elements: moveElements(moveOneRight, elements, appState), + elements: moveOneRight(elements, appState), appState, commitToHistory: true, }; @@ -127,7 +70,7 @@ export const actionSendToBack = register({ name: "sendToBack", perform: (elements, appState) => { return { - elements: moveElements(moveAllLeft, elements, appState), + elements: moveAllLeft(elements, appState), appState, commitToHistory: true, }; @@ -160,7 +103,7 @@ export const actionBringToFront = register({ name: "bringToFront", perform: (elements, appState) => { return { - elements: moveElements(moveAllRight, elements, appState), + elements: moveAllRight(elements, appState), appState, commitToHistory: true, }; diff --git a/src/element/newElement.ts b/src/element/newElement.ts index c1c2ff57..b28266b7 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -284,7 +284,7 @@ export const duplicateElement = >( overrides?: Partial, ): TElement => { let copy: TElement = deepCopyElement(element); - copy.id = randomId(); + copy.id = process.env.NODE_ENV === "test" ? `${copy.id}_copy` : randomId(); copy.seed = randomInteger(); copy.groupIds = getNewGroupIdsForDuplication( copy.groupIds, diff --git a/src/groups.ts b/src/groups.ts index 2b0627d5..589e3241 100644 --- a/src/groups.ts +++ b/src/groups.ts @@ -45,10 +45,17 @@ export function isSelectedViaGroup( appState: AppState, element: ExcalidrawElement, ) { - return !!element.groupIds + return getSelectedGroupForElement(appState, element) != null; +} + +export const getSelectedGroupForElement = ( + 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) diff --git a/src/tests/__snapshots__/move.test.tsx.snap b/src/tests/__snapshots__/move.test.tsx.snap index 71e0ddd6..611f07d5 100644 --- a/src/tests/__snapshots__/move.test.tsx.snap +++ b/src/tests/__snapshots__/move.test.tsx.snap @@ -8,7 +8,7 @@ Object { "fillStyle": "hachure", "groupIds": Array [], "height": 50, - "id": "id2", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 02961207..47dd3cc6 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -2366,7 +2366,7 @@ Object { "fillStyle": "hachure", "groupIds": Array [], "height": 10, - "id": "id2", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -2480,7 +2480,7 @@ Object { "fillStyle": "hachure", "groupIds": Array [], "height": 10, - "id": "id2", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -11227,10 +11227,10 @@ Object { "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ - "id7", + "id6", ], "height": 10, - "id": "id6", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -11255,10 +11255,10 @@ Object { "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ - "id7", + "id6", ], "height": 10, - "id": "id8", + "id": "id1_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -11283,10 +11283,10 @@ Object { "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ - "id7", + "id6", ], "height": 10, - "id": "id9", + "id": "id2_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -11692,10 +11692,10 @@ Object { "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ - "id7", + "id6", ], "height": 10, - "id": "id6", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -11717,10 +11717,10 @@ Object { "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ - "id7", + "id6", ], "height": 10, - "id": "id8", + "id": "id1_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -11742,10 +11742,10 @@ Object { "boundElementIds": null, "fillStyle": "hachure", "groupIds": Array [ - "id7", + "id6", ], "height": 10, - "id": "id9", + "id": "id2_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -22477,7 +22477,7 @@ Object { "scrollY": 0, "scrolledOutside": false, "selectedElementIds": Object { - "id1": true, + "id0_copy": true, }, "selectedGroupIds": Object {}, "selectionElement": null, @@ -22528,7 +22528,7 @@ Object { "fillStyle": "hachure", "groupIds": Array [], "height": 20, - "id": "id1", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, @@ -22603,7 +22603,7 @@ Object { "editingLinearElement": null, "name": "Untitled-201933152653", "selectedElementIds": Object { - "id1": true, + "id0_copy": true, }, "viewBackgroundColor": "#ffffff", }, @@ -22638,7 +22638,7 @@ Object { "fillStyle": "hachure", "groupIds": Array [], "height": 20, - "id": "id1", + "id": "id0_copy", "isDeleted": false, "opacity": 100, "roughness": 1, diff --git a/src/tests/zindex.test.tsx b/src/tests/zindex.test.tsx index 58029111..1d84e6f2 100644 --- a/src/tests/zindex.test.tsx +++ b/src/tests/zindex.test.tsx @@ -8,7 +8,9 @@ import { actionBringForward, actionBringToFront, actionSendToBack, + actionDuplicateSelection, } from "../actions"; +import { AppState } from "../types"; import { API } from "./helpers/api"; // Unmount ReactDOM from root @@ -22,20 +24,49 @@ beforeEach(() => { const { h } = window; const populateElements = ( - elements: { id: string; isDeleted?: boolean; isSelected?: boolean }[], + elements: { + id: string; + isDeleted?: boolean; + isSelected?: boolean; + groupIds?: string[]; + y?: number; + x?: number; + width?: number; + height?: number; + }[], ) => { const selectedElementIds: any = {}; - h.elements = elements.map(({ id, isDeleted = false, isSelected = false }) => { - const element = API.createElement({ type: "rectangle", id, isDeleted }); - if (isSelected) { - selectedElementIds[element.id] = true; - } - return element; - }); + h.elements = elements.map( + ({ + id, + isDeleted = false, + isSelected = false, + groupIds = [], + y = 100, + x = 100, + width = 100, + height = 100, + }) => { + const element = API.createElement({ + type: "rectangle", + id, + isDeleted, + x, + y, + width, + height, + }); + // @ts-ignore + element.groupIds = groupIds; + if (isSelected) { + selectedElementIds[element.id] = true; + } + return element; + }, + ); h.setState({ - ...h.state, selectedElementIds, }); @@ -50,12 +81,24 @@ type Actions = const assertZindex = ({ elements, + appState, operations, }: { - elements: { id: string; isDeleted?: true; isSelected?: true }[]; + elements: { + id: string; + isDeleted?: true; + isSelected?: true; + groupIds?: string[]; + }[]; + appState?: Partial; operations: [Actions, string[]][]; }) => { const selectedElementIds = populateElements(elements); + + h.setState({ + editingGroupId: appState?.editingGroupId || null, + }); + operations.forEach(([action, expected]) => { h.app.actionManager.executeAction(action); expect(h.elements.map((element) => element.id)).toEqual(expected); @@ -64,9 +107,11 @@ const assertZindex = ({ }; describe("z-index manipulation", () => { - it("send back", () => { + beforeEach(() => { render(); + }); + it("send back", () => { assertZindex({ elements: [ { id: "A" }, @@ -75,9 +120,21 @@ describe("z-index manipulation", () => { { id: "D", isSelected: true }, ], operations: [ - [actionSendBackward, ["B", "C", "D", "A"]], + [actionSendBackward, ["D", "A", "B", "C"]], // noop - [actionSendBackward, ["B", "C", "D", "A"]], + [actionSendBackward, ["D", "A", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + ], + operations: [ + // noop + [actionSendBackward, ["A", "B", "C"]], ], }); @@ -88,7 +145,7 @@ describe("z-index manipulation", () => { { id: "C", isDeleted: true }, { id: "D", isSelected: true }, ], - operations: [[actionSendBackward, ["A", "C", "D", "B"]]], + operations: [[actionSendBackward, ["A", "D", "B", "C"]]], }); assertZindex({ @@ -98,8 +155,13 @@ describe("z-index manipulation", () => { { id: "C", isDeleted: true }, { id: "D", isSelected: true }, { id: "E", isSelected: true }, + { id: "F" }, + ], + operations: [ + [actionSendBackward, ["D", "E", "A", "B", "C", "F"]], + // noop + [actionSendBackward, ["D", "E", "A", "B", "C", "F"]], ], - operations: [[actionSendBackward, ["B", "C", "D", "E", "A"]]], }); assertZindex({ @@ -113,14 +175,242 @@ describe("z-index manipulation", () => { { id: "G", isSelected: true }, ], operations: [ - [actionSendBackward, ["A", "C", "D", "E", "B", "G", "F"]], - [actionSendBackward, ["C", "D", "E", "A", "G", "B", "F"]], + [actionSendBackward, ["A", "E", "B", "C", "D", "G", "F"]], + [actionSendBackward, ["E", "A", "G", "B", "C", "D", "F"]], + [actionSendBackward, ["E", "G", "A", "B", "C", "D", "F"]], + // noop + [actionSendBackward, ["E", "G", "A", "B", "C", "D", "F"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B" }, + { id: "C", isDeleted: true }, + { id: "D", isSelected: true }, + { id: "E", isDeleted: true }, + { id: "F", isSelected: true }, + { id: "G" }, + ], + operations: [ + [actionSendBackward, ["A", "D", "E", "F", "B", "C", "G"]], + [actionSendBackward, ["D", "E", "F", "A", "B", "C", "G"]], + // noop + [actionSendBackward, ["D", "E", "F", "A", "B", "C", "G"]], + ], + }); + + // grouped elements should be atomic + // ------------------------------------------------------------------------- + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g1"] }, + { id: "D", isDeleted: true }, + { id: "E", isDeleted: true }, + { id: "F", isSelected: true }, + ], + operations: [ + [actionSendBackward, ["A", "F", "B", "C", "D", "E"]], + [actionSendBackward, ["F", "A", "B", "C", "D", "E"]], + // noop + [actionSendBackward, ["F", "A", "B", "C", "D", "E"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g2", "g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g1"] }, + { id: "E", isDeleted: true }, + { id: "F", isSelected: true }, + ], + operations: [ + [actionSendBackward, ["A", "F", "B", "C", "D", "E"]], + [actionSendBackward, ["F", "A", "B", "C", "D", "E"]], + // noop + [actionSendBackward, ["F", "A", "B", "C", "D", "E"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g2", "g1"] }, + { id: "E", isDeleted: true }, + { id: "F", isSelected: true }, + ], + operations: [ + [actionSendBackward, ["A", "F", "B", "C", "D", "E"]], + [actionSendBackward, ["F", "A", "B", "C", "D", "E"]], + // noop + [actionSendBackward, ["F", "A", "B", "C", "D", "E"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B1", groupIds: ["g1"] }, + { id: "C1", groupIds: ["g1"] }, + { id: "D2", groupIds: ["g2"], isSelected: true }, + { id: "E2", groupIds: ["g2"], isSelected: true }, + ], + appState: { + editingGroupId: null, + }, + operations: [[actionSendBackward, ["A", "D2", "E2", "B1", "C1"]]], + }); + + // in-group siblings + // ------------------------------------------------------------------------- + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g2", "g1"], isSelected: true }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionSendBackward, ["A", "B", "D", "C"]], + // noop (prevented) + [actionSendBackward, ["A", "B", "D", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g2", "g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g1"], isSelected: true }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionSendBackward, ["A", "D", "B", "C"]], + // noop (prevented) + [actionSendBackward, ["A", "D", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g2", "g1"], isSelected: true }, + { id: "D", groupIds: ["g2", "g1"], isDeleted: true }, + { id: "E", groupIds: ["g2", "g1"], isSelected: true }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionSendBackward, ["A", "C", "D", "E", "B"]], + // noop (prevented) + [actionSendBackward, ["A", "C", "D", "E", "B"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g2", "g1"] }, + { id: "E", groupIds: ["g3", "g1"], isSelected: true }, + { id: "F", groupIds: ["g3", "g1"], isSelected: true }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionSendBackward, ["A", "B", "E", "F", "C", "D"]], + [actionSendBackward, ["A", "E", "F", "B", "C", "D"]], + // noop (prevented) + [actionSendBackward, ["A", "E", "F", "B", "C", "D"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g1"] }, + { id: "B", groupIds: ["g2"] }, + { id: "C", groupIds: ["g1"] }, + { id: "D", groupIds: ["g2"], isSelected: true }, + { id: "E", groupIds: ["g2"], isSelected: true }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionSendBackward, ["A", "D", "E", "B", "C"]], + // noop + [actionSendBackward, ["A", "D", "E", "B", "C"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g1"] }, + { id: "B", groupIds: ["g2"] }, + { id: "C", groupIds: ["g1"] }, + { id: "D", groupIds: ["g2"], isSelected: true }, + { id: "F" }, + { id: "G", groupIds: ["g2"], isSelected: true }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionSendBackward, ["A", "D", "G", "B", "C", "F"]], + // noop + [actionSendBackward, ["A", "D", "G", "B", "C", "F"]], ], }); }); it("bring forward", () => { - render(); + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + { id: "D", isDeleted: true }, + { id: "E" }, + ], + operations: [ + [actionBringForward, ["A", "D", "E", "B", "C"]], + // noop + [actionBringForward, ["A", "D", "E", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + ], + operations: [ + // noop + [actionBringForward, ["A", "B", "C"]], + ], + }); assertZindex({ elements: [ @@ -133,17 +423,165 @@ describe("z-index manipulation", () => { { id: "G" }, ], operations: [ - [actionBringForward, ["D", "A", "B", "C", "G", "E", "F"]], - [actionBringForward, ["D", "G", "A", "B", "C", "E", "F"]], + [actionBringForward, ["B", "C", "D", "A", "F", "G", "E"]], + [actionBringForward, ["B", "C", "D", "F", "G", "A", "E"]], + // noop + [actionBringForward, ["B", "C", "D", "F", "G", "A", "E"]], + ], + }); + + // grouped elements should be atomic + // ------------------------------------------------------------------------- + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isDeleted: true }, + { id: "C", isDeleted: true }, + { id: "D", groupIds: ["g1"] }, + { id: "E", groupIds: ["g1"] }, + { id: "F" }, + ], + operations: [ + [actionBringForward, ["B", "C", "D", "E", "A", "F"]], + [actionBringForward, ["B", "C", "D", "E", "F", "A"]], + // noop + [actionBringForward, ["B", "C", "D", "E", "F", "A"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", isSelected: true }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g2", "g1"] }, + { id: "E", groupIds: ["g1"] }, + { id: "F" }, + ], + operations: [ + [actionBringForward, ["A", "C", "D", "E", "B", "F"]], + [actionBringForward, ["A", "C", "D", "E", "F", "B"]], + // noop + [actionBringForward, ["A", "C", "D", "E", "F", "B"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", isSelected: true }, + { id: "C", groupIds: ["g1"] }, + { id: "D", groupIds: ["g2", "g1"] }, + { id: "E", groupIds: ["g2", "g1"] }, + { id: "F" }, + ], + operations: [ + [actionBringForward, ["A", "C", "D", "E", "B", "F"]], + [actionBringForward, ["A", "C", "D", "E", "F", "B"]], + // noop + [actionBringForward, ["A", "C", "D", "E", "F", "B"]], + ], + }); + + // in-group siblings + // ------------------------------------------------------------------------- + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g2", "g1"], isSelected: true }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g1"] }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionBringForward, ["A", "C", "B", "D"]], + // noop (prevented) + [actionBringForward, ["A", "C", "B", "D"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", groupIds: ["g1"], isSelected: true }, + { id: "B", groupIds: ["g2", "g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D" }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionBringForward, ["B", "C", "A", "D"]], + // noop (prevented) + [actionBringForward, ["B", "C", "A", "D"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", groupIds: ["g2", "g1"], isSelected: true }, + { id: "B", groupIds: ["g2", "g1"], isSelected: true }, + { id: "C", groupIds: ["g1"] }, + { id: "D" }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionBringForward, ["C", "A", "B", "D"]], + // noop (prevented) + [actionBringForward, ["C", "A", "B", "D"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g2"], isSelected: true }, + { id: "B", groupIds: ["g2"], isSelected: true }, + { id: "C", groupIds: ["g1"] }, + { id: "D", groupIds: ["g2"] }, + { id: "E", groupIds: ["g1"] }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionBringForward, ["C", "D", "A", "B", "E"]], + // noop + [actionBringForward, ["C", "D", "A", "B", "E"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g2"], isSelected: true }, + { id: "B" }, + { id: "C", groupIds: ["g2"], isSelected: true }, + { id: "D", groupIds: ["g1"] }, + { id: "E", groupIds: ["g2"] }, + { id: "F", groupIds: ["g1"] }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionBringForward, ["B", "D", "E", "A", "C", "F"]], + // noop + [actionBringForward, ["B", "D", "E", "A", "C", "F"]], ], }); }); it("bring to front", () => { - render(); - assertZindex({ elements: [ + { id: "0" }, { id: "A", isSelected: true }, { id: "B", isDeleted: true }, { id: "C", isDeleted: true }, @@ -152,13 +590,130 @@ describe("z-index manipulation", () => { { id: "F", isDeleted: true }, { id: "G" }, ], - operations: [[actionBringToFront, ["D", "G", "A", "B", "C", "E", "F"]]], + operations: [ + [actionBringToFront, ["0", "B", "C", "D", "F", "G", "A", "E"]], + // noop + [actionBringToFront, ["0", "B", "C", "D", "F", "G", "A", "E"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + ], + operations: [ + // noop + [actionBringToFront, ["A", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + ], + operations: [ + // noop + [actionBringToFront, ["A", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + { id: "C" }, + ], + operations: [ + [actionBringToFront, ["C", "A", "B"]], + // noop + [actionBringToFront, ["C", "A", "B"]], + ], + }); + + // in-group sorting + // ------------------------------------------------------------------------- + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g1"], isSelected: true }, + { id: "D", groupIds: ["g1"] }, + { id: "E", groupIds: ["g1"], isSelected: true }, + { id: "F", groupIds: ["g2", "g1"] }, + { id: "G", groupIds: ["g2", "g1"] }, + { id: "H", groupIds: ["g3", "g1"] }, + { id: "I", groupIds: ["g3", "g1"] }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionBringToFront, ["A", "B", "D", "F", "G", "H", "I", "C", "E"]], + // noop (prevented) + [actionBringToFront, ["A", "B", "D", "F", "G", "H", "I", "C", "E"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g2", "g1"], isSelected: true }, + { id: "D", groupIds: ["g2", "g1"] }, + { id: "C", groupIds: ["g1"] }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionBringToFront, ["A", "D", "B", "C"]], + // noop (prevented) + [actionBringToFront, ["A", "D", "B", "C"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g2", "g3"], isSelected: true }, + { id: "B", groupIds: ["g1", "g3"] }, + { id: "C", groupIds: ["g2", "g3"] }, + { id: "D", groupIds: ["g1", "g3"] }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionBringToFront, ["B", "C", "A", "D"]], + // noop + [actionBringToFront, ["B", "C", "A", "D"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g2"], isSelected: true }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g2"] }, + { id: "D", groupIds: ["g1"] }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionBringToFront, ["B", "C", "A", "D"]], + // noop + [actionBringToFront, ["B", "C", "A", "D"]], + ], }); }); it("send to back", () => { - render(); - assertZindex({ elements: [ { id: "A" }, @@ -168,8 +723,334 @@ describe("z-index manipulation", () => { { id: "E", isSelected: true }, { id: "F", isDeleted: true }, { id: "G" }, + { id: "H", isSelected: true }, + { id: "I" }, + ], + operations: [ + [actionSendToBack, ["E", "H", "A", "B", "C", "D", "F", "G", "I"]], + // noop + [actionSendToBack, ["E", "H", "A", "B", "C", "D", "F", "G", "I"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + ], + operations: [ + // noop + [actionSendToBack, ["A", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + { id: "C" }, + ], + operations: [ + // noop + [actionSendToBack, ["A", "B", "C"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", isSelected: true }, + { id: "C", isSelected: true }, + ], + operations: [ + [actionSendToBack, ["B", "C", "A"]], + // noop + [actionSendToBack, ["B", "C", "A"]], + ], + }); + + // in-group sorting + // ------------------------------------------------------------------------- + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g2", "g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g3", "g1"] }, + { id: "E", groupIds: ["g3", "g1"] }, + { id: "F", groupIds: ["g1"], isSelected: true }, + { id: "G", groupIds: ["g1"] }, + { id: "H", groupIds: ["g1"], isSelected: true }, + { id: "I", groupIds: ["g1"] }, + ], + appState: { + editingGroupId: "g1", + }, + operations: [ + [actionSendToBack, ["A", "F", "H", "B", "C", "D", "E", "G", "I"]], + // noop (prevented) + [actionSendToBack, ["A", "F", "H", "B", "C", "D", "E", "G", "I"]], + ], + }); + + assertZindex({ + elements: [ + { id: "A" }, + { id: "B", groupIds: ["g1"] }, + { id: "C", groupIds: ["g2", "g1"] }, + { id: "D", groupIds: ["g2", "g1"], isSelected: true }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionSendToBack, ["A", "B", "D", "C"]], + // noop (prevented) + [actionSendToBack, ["A", "B", "D", "C"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g1", "g3"] }, + { id: "B", groupIds: ["g2", "g3"] }, + { id: "C", groupIds: ["g1", "g3"] }, + { id: "D", groupIds: ["g2", "g3"], isSelected: true }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionSendToBack, ["A", "D", "B", "C"]], + // noop + [actionSendToBack, ["A", "D", "B", "C"]], + ], + }); + + // invalid z-indexes across groups (legacy) → allow to sort to next sibling + assertZindex({ + elements: [ + { id: "A", groupIds: ["g1"] }, + { id: "B", groupIds: ["g2"] }, + { id: "C", groupIds: ["g1"] }, + { id: "D", groupIds: ["g2"], isSelected: true }, + ], + appState: { + editingGroupId: "g2", + }, + operations: [ + [actionSendToBack, ["A", "D", "B", "C"]], + // noop + [actionSendToBack, ["A", "D", "B", "C"]], ], - operations: [[actionSendToBack, ["D", "E", "A", "B", "C", "F", "G"]]], }); }); + + it("duplicating elements should retain zindex integrity", () => { + populateElements([ + { id: "A", isSelected: true }, + { id: "B", isSelected: true }, + ]); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements).toMatchObject([ + { id: "A" }, + { id: "A_copy" }, + { id: "B" }, + { id: "B_copy" }, + ]); + + populateElements([ + { id: "A", groupIds: ["g1"], isSelected: true }, + { id: "B", groupIds: ["g1"], isSelected: true }, + ]); + h.setState({ + selectedGroupIds: { g1: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements).toMatchObject([ + { id: "A" }, + { id: "B" }, + { + id: "A_copy", + + groupIds: [expect.stringMatching(/.{3,}/)], + }, + { + id: "B_copy", + + groupIds: [expect.stringMatching(/.{3,}/)], + }, + ]); + + populateElements([ + { id: "A", groupIds: ["g1"], isSelected: true }, + { id: "B", groupIds: ["g1"], isSelected: true }, + { id: "C" }, + ]); + h.setState({ + selectedGroupIds: { g1: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements).toMatchObject([ + { id: "A" }, + { id: "B" }, + { + id: "A_copy", + + groupIds: [expect.stringMatching(/.{3,}/)], + }, + { + id: "B_copy", + + groupIds: [expect.stringMatching(/.{3,}/)], + }, + { id: "C" }, + ]); + + populateElements([ + { id: "A", groupIds: ["g1"], isSelected: true }, + { id: "B", groupIds: ["g1"], isSelected: true }, + { id: "C", isSelected: true }, + ]); + h.setState({ + selectedGroupIds: { g1: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "B", + "A_copy", + "B_copy", + "C", + "C_copy", + ]); + + populateElements([ + { id: "A", groupIds: ["g1"], isSelected: true }, + { id: "B", groupIds: ["g1"], isSelected: true }, + { id: "C", groupIds: ["g2"], isSelected: true }, + { id: "D", groupIds: ["g2"], isSelected: true }, + ]); + h.setState({ + selectedGroupIds: { g1: true, g2: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "B", + "A_copy", + "B_copy", + "C", + "D", + "C_copy", + "D_copy", + ]); + + populateElements([ + { id: "A", groupIds: ["g1", "g2"], isSelected: true }, + { id: "B", groupIds: ["g1", "g2"], isSelected: true }, + { id: "C", groupIds: ["g2"], isSelected: true }, + ]); + h.setState({ + selectedGroupIds: { g1: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "B", + "A_copy", + "B_copy", + "C", + "C_copy", + ]); + + populateElements([ + { id: "A", groupIds: ["g1", "g2"], isSelected: true }, + { id: "B", groupIds: ["g1", "g2"], isSelected: true }, + { id: "C", groupIds: ["g2"], isSelected: true }, + ]); + h.setState({ + selectedGroupIds: { g2: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "B", + "C", + "A_copy", + "B_copy", + "C_copy", + ]); + + populateElements([ + { id: "A", groupIds: ["g1", "g2"], isSelected: true }, + { id: "B", groupIds: ["g1", "g2"], isSelected: true }, + { id: "C", groupIds: ["g2"], isSelected: true }, + { id: "D", groupIds: ["g3", "g4"], isSelected: true }, + { id: "E", groupIds: ["g3", "g4"], isSelected: true }, + { id: "F", groupIds: ["g4"], isSelected: true }, + ]); + h.setState({ + selectedGroupIds: { g2: true, g4: true }, + }); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "B", + "C", + "A_copy", + "B_copy", + "C_copy", + "D", + "E", + "F", + "D_copy", + "E_copy", + "F_copy", + ]); + + populateElements([ + { id: "A", groupIds: ["g1", "g2"], isSelected: true }, + { id: "B", groupIds: ["g1", "g2"] }, + { id: "C", groupIds: ["g2"] }, + ]); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "A_copy", + "B", + "C", + ]); + + populateElements([ + { id: "A", groupIds: ["g1", "g2"] }, + { id: "B", groupIds: ["g1", "g2"], isSelected: true }, + { id: "C", groupIds: ["g2"] }, + ]); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "B", + "B_copy", + "C", + ]); + + populateElements([ + { id: "A", groupIds: ["g1", "g2"], isSelected: true }, + { id: "B", groupIds: ["g1", "g2"], isSelected: true }, + { id: "C", groupIds: ["g2"], isSelected: true }, + ]); + h.app.actionManager.executeAction(actionDuplicateSelection); + expect(h.elements.map((element) => element.id)).toEqual([ + "A", + "A_copy", + "B", + "B_copy", + "C", + "C_copy", + ]); + }); }); diff --git a/src/utils.ts b/src/utils.ts index 3ad98b5b..142aede4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -254,3 +254,39 @@ export const muteFSAbortError = (error?: Error) => { } throw error; }; + +export const findIndex = ( + array: readonly T[], + cb: (element: T, index: number, array: readonly T[]) => boolean, + fromIndex: number = 0, +) => { + if (fromIndex < 0) { + fromIndex = array.length + fromIndex; + } + fromIndex = Math.min(array.length, Math.max(fromIndex, 0)); + let i = fromIndex - 1; + while (++i < array.length) { + if (cb(array[i], i, array)) { + return i; + } + } + return -1; +}; + +export const findLastIndex = ( + array: readonly T[], + cb: (element: T, index: number, array: readonly T[]) => boolean, + fromIndex: number = array.length - 1, +) => { + if (fromIndex < 0) { + fromIndex = array.length + fromIndex; + } + fromIndex = Math.min(array.length - 1, Math.max(fromIndex, 0)); + let i = fromIndex + 1; + while (--i > -1) { + if (cb(array[i], i, array)) { + return i; + } + } + return -1; +}; diff --git a/src/zindex.test.ts b/src/zindex.test.ts deleted file mode 100644 index df80fcf9..00000000 --- a/src/zindex.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { moveOneLeft, moveOneRight, moveAllLeft, moveAllRight } from "./zindex"; - -const expectMove = ( - fn: (elements: T[], indicesToMove: number[]) => void, - elems: T[], - indices: number[], - equal: T[], -) => { - fn(elems, indices); - expect(elems).toEqual(equal); -}; - -it("should moveOneLeft", () => { - expectMove(moveOneLeft, ["a", "b", "c", "d"], [1, 2], ["b", "c", "a", "d"]); - expectMove(moveOneLeft, ["a", "b", "c", "d"], [0], ["a", "b", "c", "d"]); - expectMove( - moveOneLeft, - ["a", "b", "c", "d"], - [0, 1, 2, 3], - ["a", "b", "c", "d"], - ); - expectMove(moveOneLeft, ["a", "b", "c", "d"], [1, 3], ["b", "a", "d", "c"]); -}); - -it("should moveOneRight", () => { - expectMove(moveOneRight, ["a", "b", "c", "d"], [1, 2], ["a", "d", "b", "c"]); - expectMove(moveOneRight, ["a", "b", "c", "d"], [3], ["a", "b", "c", "d"]); - expectMove( - moveOneRight, - ["a", "b", "c", "d"], - [0, 1, 2, 3], - ["a", "b", "c", "d"], - ); - expectMove(moveOneRight, ["a", "b", "c", "d"], [0, 2], ["b", "a", "d", "c"]); -}); - -it("should moveAllLeft", () => { - expectMove( - moveAllLeft, - ["a", "b", "c", "d", "e", "f", "g"], - [2, 5], - ["c", "f", "a", "b", "d", "e", "g"], - ); - expectMove( - moveAllLeft, - ["a", "b", "c", "d", "e", "f", "g"], - [5], - ["f", "a", "b", "c", "d", "e", "g"], - ); - expectMove( - moveAllLeft, - ["a", "b", "c", "d", "e", "f", "g"], - [0, 1, 2, 3, 4, 5, 6], - ["a", "b", "c", "d", "e", "f", "g"], - ); - expectMove( - moveAllLeft, - ["a", "b", "c", "d", "e", "f", "g"], - [0, 1, 2], - ["a", "b", "c", "d", "e", "f", "g"], - ); - expectMove( - moveAllLeft, - ["a", "b", "c", "d", "e", "f", "g"], - [4, 5, 6], - ["e", "f", "g", "a", "b", "c", "d"], - ); -}); - -it("should moveAllRight", () => { - expectMove( - moveAllRight, - ["a", "b", "c", "d", "e", "f", "g"], - [2, 5], - ["a", "b", "d", "e", "g", "c", "f"], - ); - expectMove( - moveAllRight, - ["a", "b", "c", "d", "e", "f", "g"], - [5], - ["a", "b", "c", "d", "e", "g", "f"], - ); - expectMove( - moveAllRight, - ["a", "b", "c", "d", "e", "f", "g"], - [0, 1, 2, 3, 4, 5, 6], - ["a", "b", "c", "d", "e", "f", "g"], - ); - expectMove( - moveAllRight, - ["a", "b", "c", "d", "e", "f", "g"], - [0, 1, 2], - ["d", "e", "f", "g", "a", "b", "c"], - ); - expectMove( - moveAllRight, - ["a", "b", "c", "d", "e", "f", "g"], - [4, 5, 6], - ["a", "b", "c", "d", "e", "f", "g"], - ); -}); diff --git a/src/zindex.ts b/src/zindex.ts index ebc9dcf2..2676f123 100644 --- a/src/zindex.ts +++ b/src/zindex.ts @@ -1,202 +1,278 @@ -const swap = (elements: T[], indexA: number, indexB: number) => { - const element = elements[indexA]; - elements[indexA] = elements[indexB]; - elements[indexB] = element; +import { AppState } from "./types"; +import { ExcalidrawElement } from "./element/types"; +import { getElementsInGroup } from "./groups"; +import { findLastIndex, findIndex } from "./utils"; + +/** + * Returns indices of elements to move based on selected elements. + * Includes contiguous deleted elements that are between two selected elements, + * e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)] + */ +const getIndicesToMove = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + let selectedIndices: number[] = []; + let deletedIndices: number[] = []; + let includeDeletedIndex = null; + let i = -1; + while (++i < elements.length) { + if (appState.selectedElementIds[elements[i].id]) { + if (deletedIndices.length) { + selectedIndices = selectedIndices.concat(deletedIndices); + deletedIndices = []; + } + selectedIndices.push(i); + includeDeletedIndex = i + 1; + } else if (elements[i].isDeleted && includeDeletedIndex === i) { + includeDeletedIndex = i + 1; + deletedIndices.push(i); + } else { + deletedIndices = []; + } + } + return selectedIndices; }; -export const moveOneLeft = (elements: T[], indicesToMove: number[]) => { - indicesToMove.sort((a: number, b: number) => a - b); - let isSorted = true; - // We go from left to right to avoid overriding the wrong elements - indicesToMove.forEach((index, i) => { - // We don't want to bubble the first elements that are sorted as they are - // already in their correct position - isSorted = isSorted && index === i; - if (isSorted) { +const toContiguousGroups = (array: number[]) => { + let cursor = 0; + return array.reduce((acc, value, index) => { + if (index > 0 && array[index - 1] !== value - 1) { + cursor = ++cursor; + } + (acc[cursor] || (acc[cursor] = [])).push(value); + return acc; + }, [] as number[][]); +}; + +/** + * Returns next candidate index that's available to be moved to. Currently that + * is a non-deleted element, and not inside a group (unless we're editing it). + */ +const getTargetIndex = ( + appState: AppState, + elements: ExcalidrawElement[], + boundaryIndex: number, + direction: "left" | "right", +) => { + const sourceElement = elements[boundaryIndex]; + + const indexFilter = (element: ExcalidrawElement) => { + if (element.isDeleted) { + return false; + } + // if we're editing group, find closest sibling irrespective of whether + // there's a different-group element between them (for legacy reasons) + if (appState.editingGroupId) { + return element.groupIds.includes(appState.editingGroupId); + } + return true; + }; + + const candidateIndex = + direction === "left" + ? findLastIndex(elements, indexFilter, Math.max(0, boundaryIndex - 1)) + : findIndex(elements, indexFilter, boundaryIndex + 1); + + const nextElement = elements[candidateIndex]; + + if (!nextElement) { + return -1; + } + + if (appState.editingGroupId) { + if ( + // candidate element is a sibling in current editing group → return + sourceElement?.groupIds.join("") === nextElement?.groupIds.join("") + ) { + return candidateIndex; + } else if (!nextElement?.groupIds.includes(appState.editingGroupId)) { + // candidate element is outside current editing group → prevent + return -1; + } + } + + if (!nextElement.groupIds.length) { + return candidateIndex; + } + + const siblingGroupId = appState.editingGroupId + ? nextElement.groupIds[ + nextElement.groupIds.indexOf(appState.editingGroupId) - 1 + ] + : nextElement.groupIds[nextElement.groupIds.length - 1]; + + const elementsInSiblingGroup = getElementsInGroup(elements, siblingGroupId); + + if (elementsInSiblingGroup.length) { + // assumes getElementsInGroup() returned elements are sorted + // by zIndex (ascending) + return direction === "left" + ? elements.indexOf(elementsInSiblingGroup[0]) + : elements.indexOf( + elementsInSiblingGroup[elementsInSiblingGroup.length - 1], + ); + } + + return candidateIndex; +}; + +const shiftElements = ( + appState: AppState, + elements: ExcalidrawElement[], + direction: "left" | "right", +) => { + const indicesToMove = getIndicesToMove(elements, appState); + let groupedIndices = toContiguousGroups(indicesToMove); + + if (direction === "right") { + groupedIndices = groupedIndices.reverse(); + } + + groupedIndices.forEach((indices, i) => { + const leadingIndex = indices[0]; + const trailingIndex = indices[indices.length - 1]; + const boundaryIndex = direction === "left" ? leadingIndex : trailingIndex; + + const targetIndex = getTargetIndex( + appState, + elements, + boundaryIndex, + direction, + ); + + if (targetIndex === -1 || boundaryIndex === targetIndex) { return; } - swap(elements, index - 1, index); + + const leadingElements = + direction === "left" + ? elements.slice(0, targetIndex) + : elements.slice(0, leadingIndex); + const targetElements = elements.slice(leadingIndex, trailingIndex + 1); + const displacedElements = + direction === "left" + ? elements.slice(targetIndex, leadingIndex) + : elements.slice(trailingIndex + 1, targetIndex + 1); + const trailingElements = + direction === "left" + ? elements.slice(trailingIndex + 1) + : elements.slice(targetIndex + 1); + + elements = + direction === "left" + ? [ + ...leadingElements, + ...targetElements, + ...displacedElements, + ...trailingElements, + ] + : [ + ...leadingElements, + ...displacedElements, + ...targetElements, + ...trailingElements, + ]; }); return elements; }; -export const moveOneRight = (elements: T[], indicesToMove: number[]) => { - const reversedIndicesToMove = indicesToMove.sort( - (a: number, b: number) => b - a, - ); - let isSorted = true; +const shiftElementsToEnd = ( + elements: readonly ExcalidrawElement[], + appState: AppState, + direction: "left" | "right", +) => { + const indicesToMove = getIndicesToMove(elements, appState); + const targetElements: ExcalidrawElement[] = []; + const displacedElements: ExcalidrawElement[] = []; - // We go from right to left to avoid overriding the wrong elements - reversedIndicesToMove.forEach((index, i) => { - // We don't want to bubble the first elements that are sorted as they are - // already in their correct position - isSorted = isSorted && index === elements.length - i - 1; - if (isSorted) { - return; + let leadingIndex, trailingIndex; + if (direction === "left") { + if (appState.editingGroupId) { + const groupElements = getElementsInGroup( + elements, + appState.editingGroupId, + ); + if (!groupElements.length) { + return elements; + } + leadingIndex = elements.indexOf(groupElements[0]); + } else { + leadingIndex = 0; } - swap(elements, index + 1, index); - }); - return elements; + + trailingIndex = indicesToMove[indicesToMove.length - 1]; + } else { + if (appState.editingGroupId) { + const groupElements = getElementsInGroup( + elements, + appState.editingGroupId, + ); + if (!groupElements.length) { + return elements; + } + trailingIndex = elements.indexOf(groupElements[groupElements.length - 1]); + } else { + trailingIndex = elements.length - 1; + } + + leadingIndex = indicesToMove[0]; + } + + for (let index = leadingIndex; index < trailingIndex + 1; index++) { + if (indicesToMove.includes(index)) { + targetElements.push(elements[index]); + } else { + displacedElements.push(elements[index]); + } + } + + const leadingElements = elements.slice(0, leadingIndex); + const trailingElements = elements.slice(trailingIndex + 1); + + return direction === "left" + ? [ + ...leadingElements, + ...targetElements, + ...displacedElements, + ...trailingElements, + ] + : [ + ...leadingElements, + ...displacedElements, + ...targetElements, + ...trailingElements, + ]; }; -// Let's go through an example -// | | -// [a, b, c, d, e, f, g] -// --> -// [c, f, a, b, d, e, g] -// -// We are going to override all the elements we want to move, so we keep them in an array -// that we will restore at the end. -// [c, f] -// -// From now on, we'll never read those values from the array anymore -// |1 |0 -// [a, b, _, d, e, _, g] -// -// The idea is that we want to shift all the elements between the marker 0 and 1 -// by one slot to the right. -// -// |1 |0 -// [a, b, _, d, e, _, g] -// -> -> -// -// which gives us -// -// |1 |0 -// [a, b, _, _, d, e, g] -// -// Now, we need to move all the elements from marker 1 to the beginning by two (not one) -// slots to the right, which gives us -// -// |1 |0 -// [a, b, _, _, d, e, g] -// ---|--^ ^ -// ------| -// -// which gives us -// -// |1 |0 -// [_, _, a, b, d, e, g] -// -// At this point, we can fill back the leftmost elements with the array we saved at -// the beginning -// -// |1 |0 -// [c, f, a, b, d, e, g] -// -// And we are done! -export const moveAllLeft = (elements: T[], indicesToMove: number[]) => { - indicesToMove.sort((a: number, b: number) => a - b); +// public API +// ----------------------------------------------------------------------------- - // Copy the elements to move - const leftMostElements = indicesToMove.map((index) => elements[index]); - - const reversedIndicesToMove = indicesToMove - // We go from right to left to avoid overriding elements. - .reverse() - // We add 0 for the final marker - .concat([0]); - - reversedIndicesToMove.forEach((index, i) => { - // We skip the first one as it is not paired with anything else - if (i === 0) { - return; - } - - // We go from the next marker to the right (i - 1) to the current one (index) - for (let pos = reversedIndicesToMove[i - 1] - 1; pos >= index; --pos) { - // We move by 1 the first time, 2 the second... So we can use the index i in the array - elements[pos + i] = elements[pos]; - } - }); - - // The final step - leftMostElements.forEach((element, i) => { - elements[i] = element; - }); - - return elements; +export const moveOneLeft = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + return shiftElements(appState, elements.slice(), "left"); }; -// Let's go through an example -// | | -// [a, b, c, d, e, f, g] -// --> -// [a, b, d, e, g, c, f] -// -// We are going to override all the elements we want to move, so we keep them in an array -// that we will restore at the end. -// [c, f] -// -// From now on, we'll never read those values from the array anymore -// |0 |1 -// [a, b, _, d, e, _, g] -// -// The idea is that we want to shift all the elements between the marker 0 and 1 -// by one slot to the left. -// -// |0 |1 -// [a, b, _, d, e, _, g] -// <- <- -// -// which gives us -// -// |0 |1 -// [a, b, d, e, _, _, g] -// -// Now, we need to move all the elements from marker 1 to the end by two (not one) -// slots to the left, which gives us -// -// |0 |1 -// [a, b, d, e, _, _, g] -// ^------ -// -// which gives us -// -// |0 |1 -// [a, b, d, e, g, _, _] -// -// At this point, we can fill back the rightmost elements with the array we saved at -// the beginning -// -// |0 |1 -// [a, b, d, e, g, c, f] -// -// And we are done! -export const moveAllRight = (elements: T[], indicesToMove: number[]) => { - const reversedIndicesToMove = indicesToMove.sort( - (a: number, b: number) => b - a, - ); - - // Copy the elements to move - const rightMostElements = reversedIndicesToMove.map( - (index) => elements[index], - ); - - indicesToMove = reversedIndicesToMove - // We go from left to right to avoid overriding elements. - .reverse() - // We last element index for the final marker - .concat([elements.length]); - - indicesToMove.forEach((index, i) => { - // We skip the first one as it is not paired with anything else - if (i === 0) { - return; - } - - // We go from the next marker to the left (i - 1) to the current one (index) - for (let pos = indicesToMove[i - 1] + 1; pos < index; ++pos) { - // We move by 1 the first time, 2 the second... So we can use the index i in the array - elements[pos - i] = elements[pos]; - } - }); - - // The final step - rightMostElements.forEach((element, i) => { - elements[elements.length - i - 1] = element; - }); - - return elements; +export const moveOneRight = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + return shiftElements(appState, elements.slice(), "right"); +}; + +export const moveAllLeft = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + return shiftElementsToEnd(elements, appState, "left"); +}; + +export const moveAllRight = ( + elements: readonly ExcalidrawElement[], + appState: AppState, +) => { + return shiftElementsToEnd(elements, appState, "right"); };