소스 검색

fix zindex to account for group boundaries (#2065)

David Luzar 4 년 전
부모
커밋
d07099aadd

+ 50 - 21
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<ExcalidrawElement>, 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(

+ 4 - 61
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,
     };

+ 1 - 1
src/element/newElement.ts

@@ -284,7 +284,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
   overrides?: Partial<TElement>,
 ): 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,

+ 9 - 2
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)

+ 1 - 1
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,

+ 18 - 18
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,

+ 907 - 26
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<AppState>;
   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(<App />);
+  });
 
+  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, ["D", "A", "B", "C"]],
+      ],
+    });
+
+    assertZindex({
+      elements: [
+        { id: "A", isSelected: true },
+        { id: "B", isSelected: true },
+        { id: "C", isSelected: true },
+      ],
+      operations: [
         // noop
-        [actionSendBackward, ["B", "C", "D", "A"]],
+        [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(<App />);
+    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(<App />);
-
     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(<App />);
-
     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",
+    ]);
+  });
 });

+ 36 - 0
src/utils.ts

@@ -254,3 +254,39 @@ export const muteFSAbortError = (error?: Error) => {
   }
   throw error;
 };
+
+export const findIndex = <T>(
+  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 = <T>(
+  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;
+};

+ 0 - 101
src/zindex.test.ts

@@ -1,101 +0,0 @@
-import { moveOneLeft, moveOneRight, moveAllLeft, moveAllRight } from "./zindex";
-
-const expectMove = <T>(
-  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"],
-  );
-});

+ 255 - 179
src/zindex.ts

@@ -1,202 +1,278 @@
-const swap = <T>(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";
 
-export const moveOneLeft = <T>(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) {
-      return;
+/**
+ * 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 = [];
     }
-    swap(elements, index - 1, index);
-  });
+  }
+  return selectedIndices;
+};
 
-  return elements;
+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[][]);
 };
 
-export const moveOneRight = <T>(elements: T[], indicesToMove: number[]) => {
-  const reversedIndicesToMove = indicesToMove.sort(
-    (a: number, b: number) => b - a,
-  );
-  let isSorted = true;
-
-  // 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;
+/**
+ * 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;
     }
-    swap(elements, index + 1, index);
-  });
-  return elements;
+    // 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;
 };
 
-// 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 = <T>(elements: T[], indicesToMove: number[]) => {
-  indicesToMove.sort((a: number, b: number) => a - b);
-
-  // 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) {
+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;
     }
 
-    // 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];
-    }
-  });
+    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);
 
-  // The final step
-  leftMostElements.forEach((element, i) => {
-    elements[i] = element;
+    elements =
+      direction === "left"
+        ? [
+            ...leadingElements,
+            ...targetElements,
+            ...displacedElements,
+            ...trailingElements,
+          ]
+        : [
+            ...leadingElements,
+            ...displacedElements,
+            ...targetElements,
+            ...trailingElements,
+          ];
   });
 
   return elements;
 };
 
-// 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 = <T>(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;
+const shiftElementsToEnd = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  direction: "left" | "right",
+) => {
+  const indicesToMove = getIndicesToMove(elements, appState);
+  const targetElements: ExcalidrawElement[] = [];
+  const displacedElements: ExcalidrawElement[] = [];
+
+  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;
     }
 
-    // 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];
+    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;
     }
-  });
 
-  // The final step
-  rightMostElements.forEach((element, i) => {
-    elements[elements.length - i - 1] = element;
-  });
+    leadingIndex = indicesToMove[0];
+  }
 
-  return elements;
+  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,
+      ];
+};
+
+// public API
+// -----------------------------------------------------------------------------
+
+export const moveOneLeft = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  return shiftElements(appState, elements.slice(), "left");
+};
+
+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");
 };