Преглед на файлове

feat: support selecting multiple library items via `shift` (#4306)

David Luzar преди 3 години
родител
ревизия
06db702b5d

+ 6 - 4
src/actions/actionAlign.tsx

@@ -8,13 +8,13 @@ import {
   CenterVerticallyIcon,
 } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
-import { getElementMap, getNonDeletedElements } from "../element";
+import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { KEYS } from "../keys";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { AppState } from "../types";
-import { getShortcutKey } from "../utils";
+import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
 const enableActionGroup = (
@@ -34,9 +34,11 @@ const alignSelectedElements = (
 
   const updatedElements = alignElements(selectedElements, alignment);
 
-  const updatedElementsMap = getElementMap(updatedElements);
+  const updatedElementsMap = arrayToMap(updatedElements);
 
-  return elements.map((element) => updatedElementsMap[element.id] || element);
+  return elements.map(
+    (element) => updatedElementsMap.get(element.id) || element,
+  );
 };
 
 export const actionAlignTop = register({

+ 6 - 4
src/actions/actionDistribute.tsx

@@ -4,13 +4,13 @@ import {
 } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { distributeElements, Distribution } from "../disitrubte";
-import { getElementMap, getNonDeletedElements } from "../element";
+import { getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { CODES } from "../keys";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { AppState } from "../types";
-import { getShortcutKey } from "../utils";
+import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
 const enableActionGroup = (
@@ -30,9 +30,11 @@ const distributeSelectedElements = (
 
   const updatedElements = distributeElements(selectedElements, distribution);
 
-  const updatedElementsMap = getElementMap(updatedElements);
+  const updatedElementsMap = arrayToMap(updatedElements);
 
-  return elements.map((element) => updatedElementsMap[element.id] || element);
+  return elements.map(
+    (element) => updatedElementsMap.get(element.id) || element,
+  );
 };
 
 export const distributeHorizontally = register({

+ 6 - 3
src/actions/actionFlip.ts

@@ -1,6 +1,6 @@
 import { register } from "./register";
 import { getSelectedElements } from "../scene";
-import { getElementMap, getNonDeletedElements } from "../element";
+import { getNonDeletedElements } from "../element";
 import { mutateElement } from "../element/mutateElement";
 import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
@@ -9,6 +9,7 @@ import { getTransformHandles } from "../element/transformHandles";
 import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
 import { updateBoundElements } from "../element/binding";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { arrayToMap } from "../utils";
 
 const enableActionFlipHorizontal = (
   elements: readonly ExcalidrawElement[],
@@ -83,9 +84,11 @@ const flipSelectedElements = (
     flipDirection,
   );
 
-  const updatedElementsMap = getElementMap(updatedElements);
+  const updatedElementsMap = arrayToMap(updatedElements);
 
-  return elements.map((element) => updatedElementsMap[element.id] || element);
+  return elements.map(
+    (element) => updatedElementsMap.get(element.id) || element,
+  );
 };
 
 const flipElements = (

+ 5 - 5
src/actions/actionHistory.tsx

@@ -6,9 +6,9 @@ import History, { HistoryEntry } from "../history";
 import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
 import { isWindows, KEYS } from "../keys";
-import { getElementMap } from "../element";
 import { newElementWith } from "../element/mutateElement";
 import { fixBindingsAfterDeletion } from "../element/binding";
+import { arrayToMap } from "../utils";
 
 const writeData = (
   prevElements: readonly ExcalidrawElement[],
@@ -27,17 +27,17 @@ const writeData = (
       return { commitToHistory };
     }
 
-    const prevElementMap = getElementMap(prevElements);
+    const prevElementMap = arrayToMap(prevElements);
     const nextElements = data.elements;
-    const nextElementMap = getElementMap(nextElements);
+    const nextElementMap = arrayToMap(nextElements);
 
     const deletedElements = prevElements.filter(
-      (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
+      (prevElement) => !nextElementMap.has(prevElement.id),
     );
     const elements = nextElements
       .map((nextElement) =>
         newElementWith(
-          prevElementMap[nextElement.id] || nextElement,
+          prevElementMap.get(nextElement.id) || nextElement,
           nextElement,
         ),
       )

+ 2 - 2
src/components/CheckboxItem.tsx

@@ -6,14 +6,14 @@ import "./CheckboxItem.scss";
 
 export const CheckboxItem: React.FC<{
   checked: boolean;
-  onChange: (checked: boolean) => void;
+  onChange: (checked: boolean, event: React.MouseEvent) => void;
   className?: string;
 }> = ({ children, checked, onChange, className }) => {
   return (
     <div
       className={clsx("Checkbox", className, { "is-checked": checked })}
       onClick={(event) => {
-        onChange(!checked);
+        onChange(!checked, event);
         (
           (event.currentTarget as HTMLDivElement).querySelector(
             ".Checkbox-box",

+ 42 - 3
src/components/LibraryMenu.tsx

@@ -18,6 +18,7 @@ import "./LibraryMenu.scss";
 import LibraryMenuItems from "./LibraryMenuItems";
 import { EVENT } from "../constants";
 import { KEYS } from "../keys";
+import { arrayToMap } from "../utils";
 
 const useOnClickOutside = (
   ref: RefObject<HTMLElement>,
@@ -236,6 +237,10 @@ export const LibraryMenu = ({
     ],
   );
 
+  const [lastSelectedItem, setLastSelectedItem] = useState<
+    LibraryItem["id"] | null
+  >(null);
+
   return loadingState === "preloading" ? null : (
     <Island padding={1} ref={ref} className="layer-ui__library">
       {showPublishLibraryDialog && (
@@ -271,10 +276,44 @@ export const LibraryMenu = ({
           files={files}
           id={id}
           selectedItems={selectedItems}
-          onToggle={(id) => {
-            if (!selectedItems.includes(id)) {
-              setSelectedItems([...selectedItems, id]);
+          onToggle={(id, event) => {
+            const shouldSelect = !selectedItems.includes(id);
+
+            if (shouldSelect) {
+              if (event.shiftKey && lastSelectedItem) {
+                const rangeStart = libraryItems.findIndex(
+                  (item) => item.id === lastSelectedItem,
+                );
+                const rangeEnd = libraryItems.findIndex(
+                  (item) => item.id === id,
+                );
+
+                if (rangeStart === -1 || rangeEnd === -1) {
+                  setSelectedItems([...selectedItems, id]);
+                  return;
+                }
+
+                const selectedItemsMap = arrayToMap(selectedItems);
+                const nextSelectedIds = libraryItems.reduce(
+                  (acc: LibraryItem["id"][], item, idx) => {
+                    if (
+                      (idx >= rangeStart && idx <= rangeEnd) ||
+                      selectedItemsMap.has(item.id)
+                    ) {
+                      acc.push(item.id);
+                    }
+                    return acc;
+                  },
+                  [],
+                );
+
+                setSelectedItems(nextSelectedIds);
+              } else {
+                setSelectedItems([...selectedItems, id]);
+              }
+              setLastSelectedItem(id);
             } else {
+              setLastSelectedItem(null);
               setSelectedItems(selectedItems.filter((_id) => _id !== id));
             }
           }}

+ 3 - 5
src/components/LibraryMenuItems.tsx

@@ -52,7 +52,7 @@ const LibraryMenuItems = ({
   library: Library;
   id: string;
   selectedItems: LibraryItem["id"][];
-  onToggle: (id: LibraryItem["id"]) => void;
+  onToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
   onPublish: () => void;
   resetLibrary: () => void;
 }) => {
@@ -213,10 +213,8 @@ const LibraryMenuItems = ({
           onClick={params.onClick || (() => {})}
           id={params.item?.id || null}
           selected={!!params.item?.id && selectedItems.includes(params.item.id)}
-          onToggle={() => {
-            if (params.item?.id) {
-              onToggle(params.item.id);
-            }
+          onToggle={(id, event) => {
+            onToggle(id, event);
           }}
         />
       </Stack.Col>

+ 7 - 2
src/components/LibraryUnit.scss

@@ -99,8 +99,13 @@
     margin-top: -10px;
     pointer-events: none;
   }
-  .library-unit--hover .library-unit__adder {
-    color: $oc-blue-7;
+  .library-unit:hover .library-unit__adder {
+    fill: $oc-blue-7;
+  }
+  .library-unit:active .library-unit__adder {
+    animation: none;
+    transform: scale(0.8);
+    fill: $oc-black;
   }
 
   .library-unit__active {

+ 19 - 6
src/components/LibraryUnit.tsx

@@ -8,12 +8,15 @@ import { BinaryFiles, LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import { CheckboxItem } from "./CheckboxItem";
 
-// fa-plus
 const PLUS_ICON = (
   <svg viewBox="0 0 1792 1792">
     <path
-      fill="currentColor"
-      d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
+      d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z"
+      style={{
+        stroke: "#fff",
+        strokeWidth: 140,
+      }}
+      transform="translate(0 64)"
     />
   </svg>
 );
@@ -33,7 +36,7 @@ export const LibraryUnit = ({
   isPending?: boolean;
   onClick: () => void;
   selected: boolean;
-  onToggle: (id: string) => void;
+  onToggle: (id: string, event: React.MouseEvent) => void;
 }) => {
   const ref = useRef<HTMLDivElement | null>(null);
   useEffect(() => {
@@ -84,7 +87,17 @@ export const LibraryUnit = ({
         })}
         ref={ref}
         draggable={!!elements}
-        onClick={!!elements || !!isPending ? onClick : undefined}
+        onClick={
+          !!elements || !!isPending
+            ? (event) => {
+                if (id && event.shiftKey) {
+                  onToggle(id, event);
+                } else {
+                  onClick();
+                }
+              }
+            : undefined
+        }
         onDragStart={(event) => {
           setIsHovered(false);
           event.dataTransfer.setData(
@@ -97,7 +110,7 @@ export const LibraryUnit = ({
       {id && elements && (isHovered || isMobile || selected) && (
         <CheckboxItem
           checked={selected}
-          onChange={() => onToggle(id)}
+          onChange={(checked, event) => onToggle(id, event)}
           className="library-unit__checkbox"
         />
       )}

+ 4 - 7
src/data/restore.ts

@@ -10,11 +10,7 @@ import {
   NormalizedZoomValue,
 } from "../types";
 import { ImportedDataState } from "./types";
-import {
-  getElementMap,
-  getNormalizedDimensions,
-  isInvisiblySmallElement,
-} from "../element";
+import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
 import { isLinearElementType } from "../element/typeChecks";
 import { randomId } from "../random";
 import {
@@ -27,6 +23,7 @@ import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { bumpVersion } from "../element/mutateElement";
 import { getUpdatedTimestamp } from "../utils";
+import { arrayToMap } from "../utils";
 
 type RestoredAppState = Omit<
   AppState,
@@ -206,14 +203,14 @@ export const restoreElements = (
   /** NOTE doesn't serve for reconciliation */
   localElements: readonly ExcalidrawElement[] | null | undefined,
 ): ExcalidrawElement[] => {
-  const localElementsMap = localElements ? getElementMap(localElements) : null;
+  const localElementsMap = localElements ? arrayToMap(localElements) : null;
   return (elements || []).reduce((elements, element) => {
     // filtering out selection, which is legacy, no longer kept in elements,
     // and causing issues if retained
     if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
       let migratedElement: ExcalidrawElement | null = restoreElement(element);
       if (migratedElement) {
-        const localElement = localElementsMap?.[element.id];
+        const localElement = localElementsMap?.get(element.id);
         if (localElement && localElement.version > migratedElement.version) {
           migratedElement = bumpVersion(migratedElement, localElement.version);
         }

+ 0 - 9
src/element/index.ts

@@ -59,15 +59,6 @@ export {
 } from "./sizeHelpers";
 export { showSelectedShapeActions } from "./showSelectedShapeActions";
 
-export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
-  elements.reduce(
-    (acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
-      acc[element.id] = element;
-      return acc;
-    },
-    {},
-  );
-
 export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
   elements.reduce((acc, el) => acc + el.version, 0);
 

+ 4 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- #### BREAKING CHANGE
+
+  Removed `getElementMap` util method.
+
 - Changes to [`exportToCanvas`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToCanvas) util function:
 
   - Add `maxWidthOrHeight?: number` attribute.

+ 0 - 16
src/packages/excalidraw/README_NEXT.md

@@ -865,22 +865,6 @@ import { isInvisiblySmallElement } from "@excalidraw/excalidraw-next";
 
 Returns `true` if element is invisibly small (e.g. width & height are zero).
 
-#### `getElementMap`
-
-**_Signature_**
-
-<pre>
-getElementsMap(elements:  <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a>): {[id: string]: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement</a>}
-</pre>
-
-**How to use**
-
-```js
-import { getElementsMap } from "@excalidraw/excalidraw-next";
-```
-
-This function returns an object where each element is mapped to its id.
-
 #### `loadLibraryFromBlob`
 
 ```js

+ 0 - 1
src/packages/excalidraw/index.tsx

@@ -171,7 +171,6 @@ const forwardedRefComp = forwardRef<
 export default React.memo(forwardedRefComp, areEqual);
 export {
   getSceneVersion,
-  getElementMap,
   isInvisiblySmallElement,
   getNonDeletedElements,
 } from "../../element";

+ 13 - 0
src/utils.ts

@@ -476,3 +476,16 @@ export const bytesToHexString = (bytes: Uint8Array) => {
 
 export const getUpdatedTimestamp = () =>
   process.env.NODE_ENV === "test" ? 1 : Date.now();
+
+/**
+ * Transforms array of objects containing `id` attribute,
+ * or array of ids (strings), into a Map, keyd by `id`.
+ */
+export const arrayToMap = <T extends { id: string } | string>(
+  items: readonly T[],
+) => {
+  return items.reduce((acc: Map<string, T>, element) => {
+    acc.set(typeof element === "string" ? element : element.id, element);
+    return acc;
+  }, new Map());
+};