mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
235 lines
6.5 KiB
JavaScript
235 lines
6.5 KiB
JavaScript
/*
|
|
This file is part of the Notesnook project (https://notesnook.com/)
|
|
|
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import React, { useCallback, useRef, useEffect, useState } from "react";
|
|
import { Flex } from "@theme-ui/components";
|
|
import { getPosition } from "../../hooks/use-menu";
|
|
import { FlexScrollContainer } from "../scroll-container";
|
|
import MenuItem from "./menu-item";
|
|
import { store as selectionStore } from "../../stores/selection-store";
|
|
|
|
function useMenuFocus(items, onAction, onClose) {
|
|
const [focusIndex, setFocusIndex] = useState(-1);
|
|
const [isSubmenuOpen, setIsSubmenuOpen] = useState(false);
|
|
|
|
const moveItemIntoView = useCallback(
|
|
(index) => {
|
|
const item = items[index];
|
|
if (!item) return;
|
|
const element = document.getElementById(item.key);
|
|
if (!element) return;
|
|
element.scrollIntoView({
|
|
behavior: "auto"
|
|
});
|
|
},
|
|
[items]
|
|
);
|
|
|
|
const onKeyDown = useCallback(
|
|
(e) => {
|
|
const isSeperator = (i) =>
|
|
items && (items[i]?.type === "separator" || items[i]?.isDisabled);
|
|
const moveDown = (i) => (i < items.length - 1 ? ++i : 0);
|
|
const moveUp = (i) => (i > 0 ? --i : items.length - 1);
|
|
const hasSubmenu = (i) => items && items[i]?.hasSubmenu;
|
|
const openSubmenu = (index) => {
|
|
if (!hasSubmenu(index)) return;
|
|
setIsSubmenuOpen(true);
|
|
};
|
|
|
|
const closeSubmenu = (index) => {
|
|
if (!hasSubmenu(index)) return;
|
|
setIsSubmenuOpen(false);
|
|
};
|
|
|
|
setFocusIndex((i) => {
|
|
let nextIndex = i;
|
|
|
|
switch (e.key) {
|
|
case "ArrowUp":
|
|
if (isSubmenuOpen) break;
|
|
nextIndex = moveUp(i);
|
|
while (isSeperator(nextIndex)) {
|
|
nextIndex = moveUp(nextIndex);
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
if (isSubmenuOpen) break;
|
|
nextIndex = moveDown(i);
|
|
while (isSeperator(nextIndex)) {
|
|
nextIndex = moveDown(nextIndex);
|
|
}
|
|
break;
|
|
case "ArrowRight":
|
|
openSubmenu(i);
|
|
break;
|
|
case "ArrowLeft":
|
|
closeSubmenu(i);
|
|
break;
|
|
case "Enter":
|
|
onAction && onAction(e);
|
|
break;
|
|
case "Escape":
|
|
onClose && onClose(e);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
if (nextIndex !== i) moveItemIntoView(nextIndex);
|
|
|
|
return nextIndex;
|
|
});
|
|
},
|
|
[items, isSubmenuOpen, moveItemIntoView, onAction, onClose]
|
|
);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [onKeyDown]);
|
|
|
|
return [focusIndex, setFocusIndex, isSubmenuOpen, setIsSubmenuOpen];
|
|
}
|
|
|
|
function Menu({ items, data, title, closeMenu }) {
|
|
const hoverTimeout = useRef();
|
|
const onAction = useCallback(
|
|
(e, item) => {
|
|
e.stopPropagation();
|
|
if (closeMenu) closeMenu();
|
|
if (item.onClick) {
|
|
item.onClick();
|
|
// TODO: this probably shouldn't be here.
|
|
selectionStore.toggleSelectionMode(false);
|
|
}
|
|
},
|
|
[closeMenu]
|
|
);
|
|
|
|
const [focusIndex, setFocusIndex, isSubmenuOpen, setIsSubmenuOpen] =
|
|
useMenuFocus(
|
|
items,
|
|
(e) => {
|
|
const item = items[focusIndex];
|
|
if (item) onAction(e, item);
|
|
},
|
|
() => closeMenu()
|
|
);
|
|
|
|
const subMenuRef = useRef();
|
|
useEffect(() => {
|
|
const item = items[focusIndex];
|
|
if (!item || !subMenuRef.current) return;
|
|
|
|
if (!isSubmenuOpen) {
|
|
subMenuRef.current.style.visibility = "hidden";
|
|
return;
|
|
}
|
|
|
|
const { top, left } = getPosition(subMenuRef.current, {
|
|
yOffset: document.getElementById(item.key).offsetHeight,
|
|
relativeTo: document.getElementById(item.key),
|
|
location: "right"
|
|
});
|
|
|
|
subMenuRef.current.style.visibility = "visible";
|
|
subMenuRef.current.style.top = `${top}px`;
|
|
subMenuRef.current.style.left = `${left}px`;
|
|
}, [isSubmenuOpen, focusIndex, items]);
|
|
|
|
return (
|
|
<>
|
|
<MenuContainer title={title}>
|
|
{items.map((item, index) => (
|
|
<MenuItem
|
|
key={item.key}
|
|
index={index}
|
|
item={item}
|
|
onClick={(e) => {
|
|
if (item.items?.length) {
|
|
setFocusIndex(index);
|
|
setIsSubmenuOpen(true);
|
|
} else onAction(e, item);
|
|
}}
|
|
isFocused={focusIndex === index}
|
|
onMouseEnter={() => {
|
|
if (item.isDisabled) {
|
|
setFocusIndex(-1);
|
|
return;
|
|
}
|
|
|
|
clearTimeout(hoverTimeout.current);
|
|
setFocusIndex(index);
|
|
setIsSubmenuOpen(false);
|
|
if (item.items?.length) {
|
|
hoverTimeout.current = setTimeout(() => {
|
|
setIsSubmenuOpen(true);
|
|
}, 500);
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
clearTimeout(hoverTimeout.current);
|
|
}}
|
|
/>
|
|
))}
|
|
</MenuContainer>
|
|
{isSubmenuOpen && (
|
|
<Flex
|
|
ref={subMenuRef}
|
|
style={{ visibility: "hidden" }}
|
|
sx={{
|
|
position: "absolute"
|
|
}}
|
|
>
|
|
<Menu
|
|
items={items[focusIndex]?.items || []}
|
|
closeMenu={closeMenu}
|
|
data={data}
|
|
/>
|
|
</Flex>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
export default React.memo(Menu);
|
|
|
|
function MenuContainer({ title, children }) {
|
|
return (
|
|
<Flex
|
|
className="menuContainer"
|
|
tabIndex={-1}
|
|
py={1}
|
|
sx={{
|
|
bg: "bgSecondary",
|
|
padding: 0,
|
|
margin: 0,
|
|
borderRadius: "default",
|
|
boxShadow: "0px 0px 10px 0px #00000022",
|
|
border: "1px solid var(--border)",
|
|
width: 220,
|
|
flexDirection: "column"
|
|
}}
|
|
>
|
|
<FlexScrollContainer>{children}</FlexScrollContainer>
|
|
</Flex>
|
|
);
|
|
}
|