web: add support for reordering navigation menu items

This commit is contained in:
Abdullah Atta
2023-12-20 09:43:46 +05:00
parent 56a9b5a5d9
commit 24f1307955
5 changed files with 331 additions and 126 deletions

View File

@@ -20,29 +20,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
import { DatabasePersistence, NNStorage } from "../interfaces/storage";
import { logger } from "../utils/logger";
import type Database from "@notesnook/core/dist/api";
import { showMigrationDialog } from "./dialog-controller";
// import { SQLocalKysely } from "sqlocal/kysely";
import { WaSqliteWorkerDriver } from "./sqlite/sqlite.kysely";
import { SqliteAdapter, SqliteQueryCompiler, SqliteIntrospector } from "kysely";
import { database } from "@notesnook/common";
// import SQLiteESMFactory from "./sqlite/wa-sqlite-async";
// import * as SQLite from "./sqlite/sqlite-api";
// import { IDBBatchAtomicVFS } from "./sqlite/IDBBatchAtomicVFS";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
let db: Database = {};
const db = database;
async function initializeDatabase(persistence: DatabasePersistence) {
console.log("initi");
logger.measure("Database initialization");
const { database } = await import("@notesnook/common");
const { FileStorage } = await import("../interfaces/fs");
const { Compressor } = await import("../utils/compressor");
const { KeyChain } = await import("../interfaces/key-store");
db = database;
const databaseKey = await KeyChain.extractKey();
db.host({

View File

@@ -17,7 +17,7 @@ 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 { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { Box, Button, Flex } from "@theme-ui/components";
import {
Note,
@@ -38,7 +38,7 @@ import {
Reminders
} from "../icons";
import { AnimatedFlex } from "../animated";
import NavigationItem from "./navigation-item";
import NavigationItem, { SortableNavigationItem } from "./navigation-item";
import { hardNavigate, hashNavigate, navigate } from "../../navigation";
import { db } from "../../common/db";
import useMobile from "../../hooks/use-mobile";
@@ -49,8 +49,28 @@ import { useStore as useThemeStore } from "../../stores/theme-store";
import useLocation from "../../hooks/use-location";
import { FlexScrollContainer } from "../scroll-container";
import { ScopedThemeProvider } from "../theme-provider";
import {
closestCenter,
DndContext,
useSensor,
useSensors,
KeyboardSensor,
DragOverlay,
MeasuringStrategy,
MouseSensor
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from "@dnd-kit/sortable";
import { isUserPremium } from "../../hooks/use-is-user-premium";
import { showToast } from "../../utils/toast";
import { usePersistentState } from "../../hooks/use-persistent-state";
type Route = {
id: string;
title: string;
path: string;
icon: Icon;
@@ -68,33 +88,38 @@ function shouldSelectNavItem(route: string, pin: { type: string; id: string }) {
}
const routes: Route[] = [
{ title: "Notes", path: "/notes", icon: Note },
{ id: "notes", title: "Notes", path: "/notes", icon: Note },
{
id: "notebooks",
title: "Notebooks",
path: "/notebooks",
icon: Notebook
},
{
id: "favorites",
title: "Favorites",
path: "/favorites",
icon: StarOutline
},
{ title: "Tags", path: "/tags", icon: Tag },
{
title: "Monographs",
path: "/monographs",
icon: Monographs
},
{ id: "tags", title: "Tags", path: "/tags", icon: Tag },
{
id: "reminders",
title: "Reminders",
path: "/reminders",
icon: Reminders,
tag: "Beta"
},
{ title: "Trash", path: "/trash", icon: Trash }
{
id: "monographs",
title: "Monographs",
path: "/monographs",
icon: Monographs
},
{ id: "trash", title: "Trash", path: "/trash", icon: Trash }
];
const settings: Route = {
id: "settings",
title: "Settings",
path: "/settings",
icon: Settings
@@ -180,97 +205,163 @@ function NavigationMenu(props: NavigationMenuProps) {
suppressScrollX={true}
>
<Flex sx={{ flexDirection: "column" }}>
{routes.map((item) => (
<NavigationItem
isTablet={isTablet}
key={item.path}
title={item.title}
icon={item.icon}
tag={item.tag}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
onClick={() => {
if (!isMobile && location === item.path)
return toggleNavigationContainer();
_navigate(item.path);
}}
/>
))}
{colors.map((color, index) => (
<NavigationItem
animate={!IS_DESKTOP_APP}
index={index}
isTablet={isTablet}
key={color.id}
title={color.title}
icon={Circle}
selected={location === `/colors/${color.id}`}
color={color.colorCode}
onClick={() => {
_navigate(`/colors/${color.id}`);
}}
menuItems={[
{
type: "button",
key: "rename",
title: "Rename color",
onClick: async () => {
await showRenameColorDialog(color);
}
<ReorderableList
items={routes}
orderKey={`sidebarOrder:builtin`}
order={() => db.settings.getSideBarOrder("builtin")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("builtin", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
title={item.title}
icon={item.icon}
tag={item.tag}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
]}
/>
))}
/>
)}
renderItem={({ item }) => (
<SortableNavigationItem
key={item.id}
id={item.id}
isTablet={isTablet}
title={item.title}
icon={item.icon}
tag={item.tag}
selected={
item.path === "/"
? location === item.path
: location.startsWith(item.path)
}
onClick={() => {
if (!isMobile && location === item.path)
return toggleNavigationContainer();
_navigate(item.path);
}}
/>
)}
/>
<ReorderableList
items={colors}
orderKey={`sidebarOrder:colors`}
order={() => db.settings.getSideBarOrder("colors")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("colors", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
title={item.title}
icon={Circle}
color={item.colorCode}
selected={location === `/colors/${item.id}`}
/>
)}
renderItem={({ item: color }) => (
<SortableNavigationItem
id={color.id}
isTablet={isTablet}
key={color.id}
title={color.title}
icon={Circle}
selected={location === `/colors/${color.id}`}
color={color.colorCode}
onClick={() => {
_navigate(`/colors/${color.id}`);
}}
menuItems={[
{
type: "button",
key: "rename",
title: "Rename color",
onClick: async () => {
await showRenameColorDialog(color.id);
}
}
]}
/>
)}
/>
<Box
bg="separator"
my={1}
sx={{ width: "85%", height: "0.8px", alignSelf: "center" }}
/>
{shortcuts.map((item, index) => (
<NavigationItem
animate={!IS_DESKTOP_APP}
index={colors.length - 1 + index}
isTablet={isTablet}
key={item.id}
title={item.title}
menuItems={[
{
type: "button",
key: "removeshortcut",
title: "Remove shortcut",
onClick: async () => {
await db.shortcuts.remove(item.id);
refreshNavItems();
<ReorderableList
items={shortcuts}
orderKey={`sidebarOrder:shortcuts`}
order={() => db.settings.getSideBarOrder("shortcuts")}
onOrderChanged={(order) =>
db.settings.setSideBarOrder("shortcuts", order)
}
renderOverlay={({ item }) => (
<NavigationItem
id={item.id}
isTablet={isTablet}
key={item.id}
title={item.title}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
isShortcut
selected={shouldSelectNavItem(location, item)}
/>
)}
renderItem={({ item }) => (
<SortableNavigationItem
id={item.id}
isTablet={isTablet}
key={item.id}
title={item.title}
menuItems={[
{
type: "button",
key: "removeshortcut",
title: "Remove shortcut",
onClick: async () => {
await db.shortcuts.remove(item.id);
refreshNavItems();
}
}
]}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
]}
icon={
item.type === "notebook"
? Notebook2
: item.type === "tag"
? Tag2
: Topic
}
isShortcut
selected={shouldSelectNavItem(location, item)}
onClick={() => {
if (item.type === "notebook") {
_navigate(`/notebooks/${item.id}`);
} else if (item.type === "tag") {
_navigate(`/tags/${item.id}`);
}
}}
/>
))}
isShortcut
selected={shouldSelectNavItem(location, item)}
onClick={() => {
if (item.type === "notebook") {
_navigate(`/notebooks/${item.id}`);
} else if (item.type === "tag") {
_navigate(`/tags/${item.id}`);
}
}}
/>
)}
/>
</Flex>
</FlexScrollContainer>
<Flex sx={{ flexDirection: "column" }}>
{isLoggedIn === false && (
<NavigationItem
id="login"
isTablet={isTablet}
title="Login"
icon={Login}
@@ -279,6 +370,7 @@ function NavigationMenu(props: NavigationMenuProps) {
)}
{isTablet && (
<NavigationItem
id="change-theme"
isTablet={isTablet}
title={theme === "dark" ? "Light mode" : "Dark mode"}
icon={theme === "dark" ? LightMode : DarkMode}
@@ -289,6 +381,7 @@ function NavigationMenu(props: NavigationMenuProps) {
/>
)}
<NavigationItem
id={settings.id}
isTablet={isTablet}
key={settings.path}
title={settings.title}
@@ -341,3 +434,99 @@ function findNestedRoute(location: string) {
}
return nestedRoute;
}
type ReorderableListProps<T> = {
orderKey: string;
items: T[];
renderItem: (props: { item: T }) => JSX.Element;
renderOverlay: (props: { item: T }) => JSX.Element;
onOrderChanged: (newOrder: string[]) => void;
order: () => string[];
};
function ReorderableList<T extends { id: string }>(
props: ReorderableListProps<T>
) {
const {
orderKey,
items,
renderItem: Item,
renderOverlay: Overlay,
onOrderChanged,
order: _order
} = props;
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 10
}
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
);
const [activeItem, setActiveItem] = useState<T>();
const [order, setOrder] = usePersistentState<string[]>(orderKey, _order());
const orderedItems = orderItems(items, order);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => {
if (!isUserPremium()) {
showToast("error", "You need to be Pro to customize the sidebar.");
return;
}
setActiveItem(orderedItems.find((i) => i.id === event.active.id));
}}
onDragEnd={(event) => {
const { active, over } = event;
const overId = over?.id as string;
if (overId && active.id !== overId) {
const transitionOrder =
order.length === 0 ? orderedItems.map((i) => i.id) : order;
const newIndex = transitionOrder.indexOf(overId);
const oldIndex = transitionOrder.indexOf(active.id as string);
const newOrder = arrayMove(transitionOrder, oldIndex, newIndex);
setOrder(newOrder);
onOrderChanged(newOrder);
}
setActiveItem(undefined);
}}
measuring={{
droppable: { strategy: MeasuringStrategy.Always }
}}
>
<SortableContext
items={orderedItems}
strategy={verticalListSortingStrategy}
>
{orderedItems.map((item) => (
<Item key={item.id} item={item} />
))}
<DragOverlay
dropAnimation={{
duration: 500,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)"
}}
>
{activeItem && <Overlay item={activeItem} />}
</DragOverlay>
</SortableContext>
</DndContext>
);
}
function orderItems<T extends { id: string }>(items: T[], order: string[]) {
const sorted: T[] = [];
order.forEach((id) => {
const item = items.find((i) => i.id === id);
if (!item) return;
sorted.push(item);
});
sorted.push(...items.filter((i) => !order.includes(i.id)));
return sorted;
}

View File

@@ -17,15 +17,16 @@ 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 { Button, Text } from "@theme-ui/components";
import { Button, Flex, FlexProps, Text } from "@theme-ui/components";
import { useStore as useAppStore } from "../../stores/app-store";
import { Menu } from "../../hooks/use-menu";
import useMobile from "../../hooks/use-mobile";
import { PropsWithChildren } from "react";
import { Icon, Shortcut } from "../icons";
import { AnimatedFlex } from "../animated";
import { SchemeColors, createButtonVariant } from "@notesnook/theme";
import { MenuItem } from "@notesnook/ui";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
type NavigationItemProps = {
icon: Icon;
@@ -43,7 +44,11 @@ type NavigationItemProps = {
menuItems?: MenuItem[];
};
function NavigationItem(props: PropsWithChildren<NavigationItemProps>) {
function NavigationItem(
props: PropsWithChildren<
NavigationItemProps & { containerRef?: React.Ref<HTMLElement> } & FlexProps
>
) {
const {
icon: Icon,
color,
@@ -57,24 +62,17 @@ function NavigationItem(props: PropsWithChildren<NavigationItemProps>) {
onClick,
menuItems,
count,
animate = false,
index = 0
sx,
containerRef,
...restProps
} = props;
const toggleSideMenu = useAppStore((store) => store.toggleSideMenu);
const isMobile = useMobile();
return (
<AnimatedFlex
initial={{
opacity: animate ? 0 : 1,
// y: animate ? 0 : 0,
x: animate ? (isTablet ? 0 : 10) : 0
}}
animate={{
opacity: 1,
x: 0
}}
transition={{ duration: 0.1, delay: index * 0.05, ease: "easeIn" }}
<Flex
{...restProps}
ref={containerRef}
sx={{
...createButtonVariant(
selected ? "background-selected" : "transparent",
@@ -92,7 +90,8 @@ function NavigationItem(props: PropsWithChildren<NavigationItemProps>) {
alignItems: "center",
position: "relative",
":first-of-type": { mt: 1 },
":last-of-type": { mb: 1 }
":last-of-type": { mb: 1 },
...sx
// ":hover:not(:disabled)": {
// bg: "hover",
// filter: "brightness(100%)"
@@ -185,7 +184,29 @@ function NavigationItem(props: PropsWithChildren<NavigationItemProps>) {
{tag}
</Text>
) : null}
</AnimatedFlex>
</Flex>
);
}
export default NavigationItem;
export function SortableNavigationItem(
props: PropsWithChildren<{ id: string } & NavigationItemProps>
) {
const { id, ...restProps } = props;
const { attributes, listeners, setNodeRef, transform, transition, active } =
useSortable({ id });
return (
<NavigationItem
{...restProps}
containerRef={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
visibility: active?.id === id ? "hidden" : "visible"
}}
{...listeners}
{...attributes}
/>
);
}

View File

@@ -24,7 +24,8 @@ import {
GroupingKey,
SettingItem,
SettingItemMap,
SideBarSectionKey,
SideBarHideableSection,
SideBarSection,
ToolbarConfig,
ToolbarConfigPlatforms,
TrashCleanupInterval
@@ -63,13 +64,12 @@ const defaultSettings: SettingItemMap = {
"toolbarConfig:desktop": undefined,
"toolbarConfig:mobile": undefined,
"sideBarOrder:builtin": [],
"sideBarOrder:colors": [],
"sideBarOrder:menu": [],
"sideBarOrder:pinned": [],
"sideBarOrder:shortcuts": [],
"sideBarHiddenItems:colors": [],
"sideBarHiddenItems:menu": [],
"sideBarHiddenItems:pinned": []
"sideBarHiddenItems:builtin": [],
"sideBarHiddenItems:colors": []
};
export class Settings implements ICollection {
@@ -177,19 +177,19 @@ export class Settings implements ICollection {
return this.set("timeFormat", format);
}
getSideBarOrder(section: SideBarSectionKey) {
getSideBarOrder(section: SideBarSection) {
return this.get(`sideBarOrder:${section}`);
}
setSideBarOrder(section: SideBarSectionKey, order: string[]) {
setSideBarOrder(section: SideBarSection, order: string[]) {
return this.set(`sideBarOrder:${section}`, order);
}
getSideBarHiddenItems(section: SideBarSectionKey) {
getSideBarHiddenItems(section: SideBarHideableSection) {
return this.get(`sideBarHiddenItems:${section}`);
}
setSideBarHiddenItems(section: SideBarSectionKey, order: string[]) {
setSideBarHiddenItems(section: SideBarHideableSection, order: string[]) {
return this.set(`sideBarHiddenItems:${section}`, order);
}
}

View File

@@ -33,8 +33,6 @@ export type SortOptions = {
sortDirection: "desc" | "asc";
};
export type SideBarSectionKey = "menu" | "colors" | "pinned";
export type GroupOptions = SortOptions & {
groupBy: "none" | "abc" | "year" | "month" | "week" | "default";
};
@@ -425,6 +423,8 @@ export interface LegacySettingsItem extends BaseItem<"settings"> {
}
export type ToolbarConfigPlatforms = "desktop" | "mobile";
export type SideBarSection = "builtin" | "colors" | "shortcuts";
export type SideBarHideableSection = "builtin" | "colors";
export type SettingItemMap = {
trashCleanupInterval: TrashCleanupInterval;
titleFormat: string;
@@ -433,8 +433,8 @@ export type SettingItemMap = {
defaultNotebook: string | undefined;
} & Record<`groupOptions:${GroupingKey}`, GroupOptions> &
Record<`toolbarConfig:${ToolbarConfigPlatforms}`, ToolbarConfig | undefined> &
Record<`sideBarOrder:${SideBarSectionKey}`, string[]> &
Record<`sideBarHiddenItems:${SideBarSectionKey}`, string[]>;
Record<`sideBarOrder:${SideBarSection}`, string[]> &
Record<`sideBarHiddenItems:${SideBarHideableSection}`, string[]>;
export interface SettingItem<
TKey extends keyof SettingItemMap = keyof SettingItemMap