mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
|
import { useCallback, useEffect, useMemo } from "react";
|
||
|
import { useRouter } from "next/router";
|
||
|
// hooks
|
||
|
import { useMultipleSelectStore } from "@/hooks/store";
|
||
|
|
||
|
export type TEntityDetails = {
|
||
|
entityID: string;
|
||
|
groupID: string;
|
||
|
};
|
||
|
|
||
|
type Props = {
|
||
|
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||
|
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||
|
};
|
||
|
|
||
|
export type TSelectionSnapshot = {
|
||
|
isSelectionActive: boolean;
|
||
|
selectedEntityIds: string[];
|
||
|
};
|
||
|
|
||
|
export type TSelectionHelper = {
|
||
|
handleClearSelection: () => void;
|
||
|
handleEntityClick: (event: React.MouseEvent, entityID: string, groupId: string) => void;
|
||
|
getIsEntitySelected: (entityID: string) => boolean;
|
||
|
getIsEntityActive: (entityID: string) => boolean;
|
||
|
handleGroupClick: (groupID: string) => void;
|
||
|
isGroupSelected: (groupID: string) => "empty" | "partial" | "complete";
|
||
|
};
|
||
|
|
||
|
export const useMultipleSelect = (props: Props) => {
|
||
|
const { containerRef, entities } = props;
|
||
|
// router
|
||
|
const router = useRouter();
|
||
|
// store hooks
|
||
|
const {
|
||
|
updateSelectedEntityDetails,
|
||
|
bulkUpdateSelectedEntityDetails,
|
||
|
getActiveEntityDetails,
|
||
|
updateActiveEntityDetails,
|
||
|
getPreviousActiveEntity,
|
||
|
updatePreviousActiveEntity,
|
||
|
getNextActiveEntity,
|
||
|
updateNextActiveEntity,
|
||
|
getLastSelectedEntityDetails,
|
||
|
clearSelection,
|
||
|
getIsEntitySelected,
|
||
|
getIsEntityActive,
|
||
|
} = useMultipleSelectStore();
|
||
|
|
||
|
const groups = useMemo(() => Object.keys(entities), [entities]);
|
||
|
|
||
|
const entitiesList: TEntityDetails[] = useMemo(
|
||
|
() =>
|
||
|
groups
|
||
|
.map((groupID) =>
|
||
|
entities[groupID].map((entityID) => ({
|
||
|
entityID,
|
||
|
groupID,
|
||
|
}))
|
||
|
)
|
||
|
.flat(1),
|
||
|
[entities, groups]
|
||
|
);
|
||
|
|
||
|
const getPreviousAndNextEntities = useCallback(
|
||
|
(entityID: string) => {
|
||
|
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
|
||
|
|
||
|
// entity position
|
||
|
const isFirstEntity = currentEntityIndex === 0;
|
||
|
const isLastEntity = currentEntityIndex === entitiesList.length - 1;
|
||
|
|
||
|
let previousEntity: TEntityDetails | null = null;
|
||
|
let nextEntity: TEntityDetails | null = null;
|
||
|
|
||
|
if (isLastEntity) {
|
||
|
nextEntity = null;
|
||
|
} else {
|
||
|
nextEntity = entitiesList[currentEntityIndex + 1];
|
||
|
}
|
||
|
|
||
|
if (isFirstEntity) {
|
||
|
previousEntity = null;
|
||
|
} else {
|
||
|
previousEntity = entitiesList[currentEntityIndex - 1];
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
previousEntity,
|
||
|
nextEntity,
|
||
|
};
|
||
|
},
|
||
|
[entitiesList]
|
||
|
);
|
||
|
|
||
|
const handleActiveEntityChange = useCallback(
|
||
|
(entityDetails: TEntityDetails | null, shouldScroll: boolean = true) => {
|
||
|
if (!entityDetails) {
|
||
|
updateActiveEntityDetails(null);
|
||
|
updatePreviousActiveEntity(null);
|
||
|
updateNextActiveEntity(null);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
updateActiveEntityDetails(entityDetails);
|
||
|
|
||
|
// scroll to get the active element in view
|
||
|
const activeElement = document.querySelector(
|
||
|
`[data-entity-id="${entityDetails.entityID}"][data-entity-group-id="${entityDetails.groupID}"]`
|
||
|
);
|
||
|
if (activeElement && containerRef.current && shouldScroll) {
|
||
|
const SCROLL_OFFSET = 200;
|
||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||
|
const elementRect = activeElement.getBoundingClientRect();
|
||
|
|
||
|
const isInView =
|
||
|
elementRect.top >= containerRect.top + SCROLL_OFFSET &&
|
||
|
elementRect.bottom <= containerRect.bottom - SCROLL_OFFSET;
|
||
|
|
||
|
if (!isInView) {
|
||
|
containerRef.current.scrollBy({
|
||
|
top: elementRect.top < containerRect.top + SCROLL_OFFSET ? -50 : 50,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const { previousEntity: previousActiveEntity, nextEntity: nextActiveEntity } = getPreviousAndNextEntities(
|
||
|
entityDetails.entityID
|
||
|
);
|
||
|
updatePreviousActiveEntity(previousActiveEntity);
|
||
|
updateNextActiveEntity(nextActiveEntity);
|
||
|
},
|
||
|
[
|
||
|
containerRef,
|
||
|
getPreviousAndNextEntities,
|
||
|
updateActiveEntityDetails,
|
||
|
updateNextActiveEntity,
|
||
|
updatePreviousActiveEntity,
|
||
|
]
|
||
|
);
|
||
|
|
||
|
const handleEntitySelection = useCallback(
|
||
|
(
|
||
|
entityDetails: TEntityDetails | TEntityDetails[],
|
||
|
shouldScroll: boolean = true,
|
||
|
forceAction: "force-add" | "force-remove" | null = null
|
||
|
) => {
|
||
|
if (Array.isArray(entityDetails)) {
|
||
|
bulkUpdateSelectedEntityDetails(entityDetails, forceAction === "force-add" ? "add" : "remove");
|
||
|
if (forceAction === "force-add" && entityDetails.length > 0) {
|
||
|
handleActiveEntityChange(entityDetails[entityDetails.length - 1], shouldScroll);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (forceAction) {
|
||
|
if (forceAction === "force-add") {
|
||
|
console.log("force adding");
|
||
|
updateSelectedEntityDetails(entityDetails, "add");
|
||
|
handleActiveEntityChange(entityDetails, shouldScroll);
|
||
|
}
|
||
|
if (forceAction === "force-remove") {
|
||
|
updateSelectedEntityDetails(entityDetails, "remove");
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const isSelected = getIsEntitySelected(entityDetails.entityID);
|
||
|
if (isSelected) {
|
||
|
updateSelectedEntityDetails(entityDetails, "remove");
|
||
|
handleActiveEntityChange(entityDetails, shouldScroll);
|
||
|
} else {
|
||
|
updateSelectedEntityDetails(entityDetails, "add");
|
||
|
handleActiveEntityChange(entityDetails, shouldScroll);
|
||
|
}
|
||
|
},
|
||
|
[bulkUpdateSelectedEntityDetails, getIsEntitySelected, handleActiveEntityChange, updateSelectedEntityDetails]
|
||
|
);
|
||
|
|
||
|
/**
|
||
|
* @description toggle entity selection
|
||
|
* @param {React.MouseEvent} event
|
||
|
* @param {string} entityID
|
||
|
* @param {string} groupID
|
||
|
*/
|
||
|
const handleEntityClick = useCallback(
|
||
|
(e: React.MouseEvent, entityID: string, groupID: string) => {
|
||
|
const lastSelectedEntityDetails = getLastSelectedEntityDetails();
|
||
|
if (e.shiftKey && lastSelectedEntityDetails) {
|
||
|
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
|
||
|
|
||
|
const lastEntityIndex = entitiesList.findIndex(
|
||
|
(entity) => entity?.entityID === lastSelectedEntityDetails.entityID
|
||
|
);
|
||
|
if (lastEntityIndex < currentEntityIndex) {
|
||
|
for (let i = lastEntityIndex + 1; i <= currentEntityIndex; i++) {
|
||
|
const entityDetails = entitiesList[i];
|
||
|
if (entityDetails) {
|
||
|
handleEntitySelection(entityDetails, false);
|
||
|
}
|
||
|
}
|
||
|
} else if (lastEntityIndex > currentEntityIndex) {
|
||
|
for (let i = currentEntityIndex; i <= lastEntityIndex - 1; i++) {
|
||
|
const entityDetails = entitiesList[i];
|
||
|
if (entityDetails) {
|
||
|
handleEntitySelection(entityDetails, false);
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
const startIndex = lastEntityIndex + 1;
|
||
|
const endIndex = currentEntityIndex;
|
||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||
|
const entityDetails = entitiesList[i];
|
||
|
if (entityDetails) {
|
||
|
handleEntitySelection(entityDetails, false);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
handleEntitySelection({ entityID, groupID }, false);
|
||
|
},
|
||
|
[entitiesList, handleEntitySelection, getLastSelectedEntityDetails]
|
||
|
);
|
||
|
|
||
|
/**
|
||
|
* @description check if any entity of the group is selected
|
||
|
* @param {string} groupID
|
||
|
* @returns {boolean}
|
||
|
*/
|
||
|
const isGroupSelected = useCallback(
|
||
|
(groupID: string) => {
|
||
|
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||
|
const totalSelected = groupEntities.filter((entity) => getIsEntitySelected(entity.entityID ?? "")).length;
|
||
|
if (totalSelected === 0) return "empty";
|
||
|
if (totalSelected === groupEntities.length) return "complete";
|
||
|
return "partial";
|
||
|
},
|
||
|
[entitiesList, getIsEntitySelected]
|
||
|
);
|
||
|
|
||
|
/**
|
||
|
* @description toggle group selection
|
||
|
* @param {string} groupID
|
||
|
*/
|
||
|
const handleGroupClick = useCallback(
|
||
|
(groupID: string) => {
|
||
|
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||
|
const groupSelectionStatus = isGroupSelected(groupID);
|
||
|
// groupEntities.map((entity) => {
|
||
|
// console.log("group click");
|
||
|
// handleEntitySelection(entity, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||
|
// });
|
||
|
handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||
|
},
|
||
|
[entitiesList, handleEntitySelection, isGroupSelected]
|
||
|
);
|
||
|
|
||
|
// clear selection on escape key press
|
||
|
useEffect(() => {
|
||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||
|
if (e.key === "Escape") clearSelection();
|
||
|
};
|
||
|
|
||
|
window.addEventListener("keydown", handleKeyDown);
|
||
|
return () => {
|
||
|
window.removeEventListener("keydown", handleKeyDown);
|
||
|
};
|
||
|
}, [clearSelection]);
|
||
|
|
||
|
// select entities on shift + arrow up/down key press
|
||
|
useEffect(() => {
|
||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||
|
if (!e.shiftKey) return;
|
||
|
|
||
|
const activeEntityDetails = getActiveEntityDetails();
|
||
|
const nextActiveEntity = getNextActiveEntity();
|
||
|
const previousActiveEntity = getPreviousActiveEntity();
|
||
|
|
||
|
if (e.key === "ArrowDown" && activeEntityDetails) {
|
||
|
if (!nextActiveEntity) return;
|
||
|
handleEntitySelection(nextActiveEntity);
|
||
|
}
|
||
|
if (e.key === "ArrowUp" && activeEntityDetails) {
|
||
|
if (!previousActiveEntity) return;
|
||
|
handleEntitySelection(previousActiveEntity);
|
||
|
}
|
||
|
};
|
||
|
window.addEventListener("keydown", handleKeyDown);
|
||
|
|
||
|
return () => {
|
||
|
window.removeEventListener("keydown", handleKeyDown);
|
||
|
};
|
||
|
}, [
|
||
|
getActiveEntityDetails,
|
||
|
handleEntitySelection,
|
||
|
getLastSelectedEntityDetails,
|
||
|
getNextActiveEntity,
|
||
|
getPreviousActiveEntity,
|
||
|
]);
|
||
|
|
||
|
useEffect(() => {
|
||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||
|
if (e.shiftKey) return;
|
||
|
const activeEntityDetails = getActiveEntityDetails();
|
||
|
// set active entity id to the first entity
|
||
|
if (["ArrowUp", "ArrowDown"].includes(e.key) && !activeEntityDetails) {
|
||
|
const firstElementDetails = entitiesList[0];
|
||
|
if (!firstElementDetails) return;
|
||
|
handleActiveEntityChange(firstElementDetails);
|
||
|
}
|
||
|
|
||
|
if (e.key === "ArrowDown" && activeEntityDetails) {
|
||
|
if (!activeEntityDetails) return;
|
||
|
const { nextEntity: nextActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
|
||
|
if (nextActiveEntity) {
|
||
|
handleActiveEntityChange(nextActiveEntity);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (e.key === "ArrowUp" && activeEntityDetails) {
|
||
|
if (!activeEntityDetails) return;
|
||
|
const { previousEntity: previousActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
|
||
|
if (previousActiveEntity) {
|
||
|
handleActiveEntityChange(previousActiveEntity);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
window.addEventListener("keydown", handleKeyDown);
|
||
|
|
||
|
return () => {
|
||
|
window.removeEventListener("keydown", handleKeyDown);
|
||
|
};
|
||
|
}, [getActiveEntityDetails, entitiesList, groups, getPreviousAndNextEntities, handleActiveEntityChange]);
|
||
|
|
||
|
// clear selection on route change
|
||
|
useEffect(() => {
|
||
|
const handleRouteChange = () => clearSelection();
|
||
|
|
||
|
router.events.on("routeChangeComplete", handleRouteChange);
|
||
|
|
||
|
return () => {
|
||
|
router.events.off("routeChangeComplete", handleRouteChange);
|
||
|
};
|
||
|
}, [clearSelection, router.events]);
|
||
|
|
||
|
/**
|
||
|
* @description helper functions for selection
|
||
|
*/
|
||
|
const helpers: TSelectionHelper = useMemo(
|
||
|
() => ({
|
||
|
handleClearSelection: clearSelection,
|
||
|
handleEntityClick,
|
||
|
getIsEntitySelected,
|
||
|
getIsEntityActive,
|
||
|
handleGroupClick,
|
||
|
isGroupSelected,
|
||
|
}),
|
||
|
[clearSelection, getIsEntityActive, getIsEntitySelected, handleEntityClick, handleGroupClick, isGroupSelected]
|
||
|
);
|
||
|
|
||
|
return helpers;
|
||
|
};
|