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

This commit is contained in:
David Luzar 2021-11-26 12:46:23 +01:00 committed by GitHub
parent b53d1f6f3e
commit 06db702b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 117 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,12 +8,15 @@ import { BinaryFiles, LibraryItem } from "../types";
import "./LibraryUnit.scss"; import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem"; import { CheckboxItem } from "./CheckboxItem";
// fa-plus
const PLUS_ICON = ( const PLUS_ICON = (
<svg viewBox="0 0 1792 1792"> <svg viewBox="0 0 1792 1792">
<path <path
fill="currentColor" 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"
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" style={{
stroke: "#fff",
strokeWidth: 140,
}}
transform="translate(0 64)"
/> />
</svg> </svg>
); );
@ -33,7 +36,7 @@ export const LibraryUnit = ({
isPending?: boolean; isPending?: boolean;
onClick: () => void; onClick: () => void;
selected: boolean; selected: boolean;
onToggle: (id: string) => void; onToggle: (id: string, event: React.MouseEvent) => void;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@ -84,7 +87,17 @@ export const LibraryUnit = ({
})} })}
ref={ref} ref={ref}
draggable={!!elements} draggable={!!elements}
onClick={!!elements || !!isPending ? onClick : undefined} onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
}
}
: undefined
}
onDragStart={(event) => { onDragStart={(event) => {
setIsHovered(false); setIsHovered(false);
event.dataTransfer.setData( event.dataTransfer.setData(
@ -97,7 +110,7 @@ export const LibraryUnit = ({
{id && elements && (isHovered || isMobile || selected) && ( {id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem <CheckboxItem
checked={selected} checked={selected}
onChange={() => onToggle(id)} onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox" className="library-unit__checkbox"
/> />
)} )}

View File

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

View File

@ -59,15 +59,6 @@ export {
} from "./sizeHelpers"; } from "./sizeHelpers";
export { showSelectedShapeActions } from "./showSelectedShapeActions"; 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[]) => export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
elements.reduce((acc, el) => acc + el.version, 0); elements.reduce((acc, el) => acc + el.version, 0);

View File

@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section.
### Features ### 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: - Changes to [`exportToCanvas`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToCanvas) util function:
- Add `maxWidthOrHeight?: number` attribute. - Add `maxWidthOrHeight?: number` attribute.

View File

@ -865,22 +865,6 @@ import { isInvisiblySmallElement } from "@excalidraw/excalidraw-next";
Returns `true` if element is invisibly small (e.g. width & height are zero). 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` #### `loadLibraryFromBlob`
```js ```js

View File

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

View File

@ -476,3 +476,16 @@ export const bytesToHexString = (bytes: Uint8Array) => {
export const getUpdatedTimestamp = () => export const getUpdatedTimestamp = () =>
process.env.NODE_ENV === "test" ? 1 : Date.now(); 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());
};