From 06db702b5d4c38ce42ce6455335e05752cd3af77 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 26 Nov 2021 12:46:23 +0100 Subject: [PATCH] feat: support selecting multiple library items via `shift` (#4306) --- src/actions/actionAlign.tsx | 10 +++--- src/actions/actionDistribute.tsx | 10 +++--- src/actions/actionFlip.ts | 9 ++++-- src/actions/actionHistory.tsx | 10 +++--- src/components/CheckboxItem.tsx | 4 +-- src/components/LibraryMenu.tsx | 45 ++++++++++++++++++++++++-- src/components/LibraryMenuItems.tsx | 8 ++--- src/components/LibraryUnit.scss | 9 ++++-- src/components/LibraryUnit.tsx | 25 ++++++++++---- src/data/restore.ts | 11 +++---- src/element/index.ts | 9 ------ src/packages/excalidraw/CHANGELOG.md | 4 +++ src/packages/excalidraw/README_NEXT.md | 16 --------- src/packages/excalidraw/index.tsx | 1 - src/utils.ts | 13 ++++++++ 15 files changed, 117 insertions(+), 67 deletions(-) diff --git a/src/actions/actionAlign.tsx b/src/actions/actionAlign.tsx index eb51e80b..f00ae298 100644 --- a/src/actions/actionAlign.tsx +++ b/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({ diff --git a/src/actions/actionDistribute.tsx b/src/actions/actionDistribute.tsx index 8744fac6..578a4a57 100644 --- a/src/actions/actionDistribute.tsx +++ b/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({ diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index 4a4e4bd1..b3045103 100644 --- a/src/actions/actionFlip.ts +++ b/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 = ( diff --git a/src/actions/actionHistory.tsx b/src/actions/actionHistory.tsx index 6b3cf713..3aa4ca8a 100644 --- a/src/actions/actionHistory.tsx +++ b/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, ), ) diff --git a/src/components/CheckboxItem.tsx b/src/components/CheckboxItem.tsx index a1fe246e..c7685294 100644 --- a/src/components/CheckboxItem.tsx +++ b/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 (
{ - onChange(!checked); + onChange(!checked, event); ( (event.currentTarget as HTMLDivElement).querySelector( ".Checkbox-box", diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index 26bec7ab..763dfb88 100644 --- a/src/components/LibraryMenu.tsx +++ b/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, @@ -236,6 +237,10 @@ export const LibraryMenu = ({ ], ); + const [lastSelectedItem, setLastSelectedItem] = useState< + LibraryItem["id"] | null + >(null); + return loadingState === "preloading" ? null : ( {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)); } }} diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 93f8bd17..b2dd630d 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/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); }} /> diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index f4e81848..6181da9c 100644 --- a/src/components/LibraryUnit.scss +++ b/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 { diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index f12c5caf..46b0e8d4 100644 --- a/src/components/LibraryUnit.tsx +++ b/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 = ( ); @@ -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(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) && ( onToggle(id)} + onChange={(checked, event) => onToggle(id, event)} className="library-unit__checkbox" /> )} diff --git a/src/data/restore.ts b/src/data/restore.ts index 32c7acb7..cb316b4c 100644 --- a/src/data/restore.ts +++ b/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); } diff --git a/src/element/index.ts b/src/element/index.ts index 78eaa669..ef4059c9 100644 --- a/src/element/index.ts +++ b/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); diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 8db3d150..afbb7b1e 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/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. diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index e4c1a561..6b38f9a5 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/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_** - -
-getElementsMap(elements:  ExcalidrawElement[]): {[id: string]: ExcalidrawElement}
-
- -**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 diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index aa0e3495..060908a5 100644 --- a/src/packages/excalidraw/index.tsx +++ b/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"; diff --git a/src/utils.ts b/src/utils.ts index a3b9fe0f..dd6e6573 100644 --- a/src/utils.ts +++ b/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 = ( + items: readonly T[], +) => { + return items.reduce((acc: Map, element) => { + acc.set(typeof element === "string" ? element : element.id, element); + return acc; + }, new Map()); +};