web: add move notebook dialog (#8099)

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
01zulfi
2025-06-05 11:44:04 +05:00
committed by Abdullah Atta
parent fdd25a11e7
commit 542a628435
7 changed files with 331 additions and 141 deletions

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import ListItem from "../list-item";
import { Button, Flex, Text } from "@theme-ui/components";
import { store, useStore as useNotesStore } from "../../stores/note-store";
import { useStore as useNotesStore } from "../../stores/note-store";
import { Notebook as NotebookType } from "@notesnook/core";
import {
ChevronDown,
@@ -30,7 +30,8 @@ import {
Shortcut,
Trash,
Notebook as NotebookIcon,
ArrowUp
ArrowUp,
Move
} from "../icons";
import { MenuItem } from "@notesnook/ui";
import { hashNavigate, navigate } from "../../navigation";
@@ -45,6 +46,7 @@ import { strings } from "@notesnook/intl";
import { db } from "../../common/db";
import { createSetDefaultHomepageMenuItem } from "../../common";
import { useStore as useNotebookStore } from "../../stores/notebook-store";
import { MoveNotebookDialog } from "../../dialogs/move-notebook-dialog";
type NotebookProps = {
item: NotebookType;
@@ -232,6 +234,15 @@ export const notebookMenuItems: (
onClick: () => appStore.addToShortcuts(notebook)
},
{ key: "sep1", type: "separator" },
{
type: "button",
key: "move",
icon: Move.path,
title: strings.move(),
onClick: () => {
MoveNotebookDialog.show({ notebook: notebook });
}
},
{
type: "button",
key: "move-to-top",
@@ -255,7 +266,7 @@ export const notebookMenuItems: (
},
multiSelect: false
},
{ key: "sep2", type: "separator", isHidden: context?.isRoot },
{ key: "sep2", type: "separator" },
{
type: "button",
key: "movetotrash",

View File

@@ -19,37 +19,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useState } from "react";
import { Flex, Text } from "@theme-ui/components";
import {
CheckCircleOutline,
CheckRemove,
CircleEmpty
} from "../components/icons";
import { db } from "../common/db";
import Dialog from "../components/dialog";
import { useStore, store } from "../stores/tag-store";
import { store as notestore } from "../stores/note-store";
import { FilteredList } from "../components/filtered-list";
import { ItemReference, Tag } from "@notesnook/core";
import { create } from "zustand";
import { VirtualizedGrouping } from "@notesnook/core";
import { ResolvedItem } from "@notesnook/common";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
type SelectedReference = {
id: string;
new: boolean;
op: "add" | "remove";
};
interface ISelectionStore {
selected: SelectedReference[];
setSelected(refs: SelectedReference[]): void;
}
export const useSelectionStore = create<ISelectionStore>((set) => ({
selected: [],
setSelected: (selected) => set({ selected: selected.slice() })
}));
import {
SelectedCheck,
SelectedReference,
selectMultiple,
useSelectionStore
} from "./move-note-dialog";
type AddTagsDialogProps = BaseDialogProps<boolean> & { noteIds: string[] };
export const AddTagsDialog = DialogManager.register(function AddTagsDialog(
@@ -91,8 +76,9 @@ export const AddTagsDialog = DialogManager.register(function AddTagsDialog(
positiveButton={{
text: strings.done(),
onClick: async () => {
const { selected } = useSelectionStore.getState();
for (const id of noteIds) {
for (const item of useSelectionStore.getState().selected) {
for (const item of selected) {
const tagRef: ItemReference = { type: "tag", id: item.id };
const noteRef: ItemReference = { id, type: "note" };
if (item.op === "add") await db.relations.add(tagRef, noteRef);
@@ -166,47 +152,15 @@ function TagItem(props: { tag: Tag }) {
}}
onClick={() => {
const { selected, setSelected } = useSelectionStore.getState();
const copy = selected.slice();
const index = copy.findIndex((item) => item.id === tag.id);
const isNew = copy[index] && copy[index].new;
if (isNew) {
copy.splice(index, 1);
} else if (index > -1) {
copy[index] = {
...copy[index],
op: copy[index].op === "add" ? "remove" : "add"
};
} else {
copy.push({ id: tag.id, new: true, op: "add" });
}
setSelected(copy);
setSelected(selectMultiple(tag, selected));
}}
>
<Flex sx={{ alignItems: "center" }}>
<SelectedCheck size={20} id={tag.id} />
<Text
className="title"
data-test-id="notebook-title"
variant="subtitle"
sx={{ fontWeight: "body", color: "paragraph" }}
>
<SelectedCheck size={18} item={tag} />
<Text className="title" data-test-id="tag-title" variant="body">
#{tag.title}
</Text>
</Flex>
</Flex>
);
}
function SelectedCheck({ id, size = 20 }: { id: string; size?: number }) {
const selected = useSelectionStore((store) => store.selected);
const selectedTag = selected.find((item) => item.id === id);
return selectedTag?.op === "add" ? (
<CheckCircleOutline size={size} sx={{ mr: 1 }} color="accent" />
) : selectedTag?.op === "remove" ? (
<CheckRemove size={size} sx={{ mr: 1 }} color="icon-error" />
) : (
<CircleEmpty size={size} sx={{ mr: 1, opacity: 0.4 }} />
);
}

View File

@@ -49,16 +49,16 @@ import {
} from "../components/virtualized-tree";
type MoveNoteDialogProps = BaseDialogProps<boolean> & { noteIds: string[] };
type NotebookReference = {
export type SelectedReference = {
id: string;
new: boolean;
op: "add" | "remove";
};
interface ISelectionStore {
selected: NotebookReference[];
selected: SelectedReference[];
isMultiselect: boolean;
setSelected(refs: NotebookReference[]): void;
setSelected(refs: SelectedReference[]): void;
setIsMultiselect(state: boolean): void;
}
export const useSelectionStore = create<ISelectionStore>((set) => ({
@@ -87,7 +87,7 @@ export const MoveNoteDialog = DialogManager.register(function MoveNoteDialog({
useEffect(() => {
(async function () {
const selected: NotebookReference[] = useSelectionStore
const selected: SelectedReference[] = useSelectionStore
.getState()
.selected.slice();
@@ -201,7 +201,7 @@ export const MoveNoteDialog = DialogManager.register(function MoveNoteDialog({
<Button
variant="anchor"
onClick={() => {
const originalSelection: NotebookReference[] = useSelectionStore
const originalSelection: SelectedReference[] = useSelectionStore
.getState()
.selected.filter((a) => !a.new)
.map((s) => ({ ...s, op: "add" }));
@@ -268,6 +268,7 @@ export const MoveNoteDialog = DialogManager.register(function MoveNoteDialog({
expand: true
});
}}
multiSelectable
/>
)}
/>
@@ -310,20 +311,28 @@ function calculateIndentation(
depth: number,
base: number
) {
if (expandable && depth > 0) return depth * 7 + base;
if (expandable && depth > 0) return depth * 18 + base;
else if (depth === 0) return 0;
else return depth * 12 + base;
else return depth * 24 + base;
}
function NotebookItem(props: {
export function NotebookItem(props: {
notebook: Notebook;
isExpanded: boolean;
isExpandable: boolean;
toggle: () => void;
depth: number;
onCreateItem: () => void;
multiSelectable?: boolean;
}) {
const { notebook, isExpanded, toggle, depth, isExpandable, onCreateItem } =
props;
const {
notebook,
isExpanded,
toggle,
depth,
isExpandable,
onCreateItem,
multiSelectable
} = props;
const setIsMultiselect = useSelectionStore((store) => store.setIsMultiselect);
const setSelected = useSelectionStore((store) => store.setSelected);
@@ -339,13 +348,13 @@ function NotebookItem(props: {
const isCtrlPressed = e.ctrlKey || e.metaKey;
if (isCtrlPressed) setIsMultiselect(true);
if (isMultiselect || isCtrlPressed) {
if (multiSelectable && (isMultiselect || isCtrlPressed)) {
setSelected(selectMultiple(notebook, selected));
} else {
setSelected(selectSingle(notebook, selected));
}
},
[isMultiselect, notebook, setIsMultiselect, setSelected]
[isMultiselect, multiSelectable, notebook, setIsMultiselect, setSelected]
);
return (
@@ -377,35 +386,17 @@ function NotebookItem(props: {
<Flex sx={{ alignItems: "center" }}>
{isExpandable ? (
isExpanded ? (
<ChevronDown
data-test-id="collapse-notebook"
size={20}
sx={{ height: "20px" }}
/>
<ChevronDown data-test-id="collapse-notebook" size={18} />
) : (
<ChevronRight
data-test-id="expand-notebook"
size={20}
sx={{ height: "20px" }}
/>
<ChevronRight data-test-id="expand-notebook" size={18} />
)
) : null}
<SelectedCheck size={20} item={notebook} onClick={check} />
<Text
className="title"
data-test-id="notebook-title"
variant="subtitle"
sx={{ fontWeight: "body" }}
>
<SelectedCheck size={18} item={notebook} onClick={check} />
<Text className="title" data-test-id="notebook-title" variant="body">
{notebook.title}
{/* <Text variant="subBody" sx={{ fontWeight: "body" }}>
{" "}
({pluralize(notebook.topics.length, "topic")})
</Text> */}
</Text>
</Flex>
<Flex data-test-id="notebook-tools" sx={{ alignItems: "center" }}>
<TopicSelectionIndicator notebook={notebook} />
<Button
variant="secondary"
data-test-id="add-sub-notebook"
@@ -426,46 +417,27 @@ function NotebookItem(props: {
);
}
function TopicSelectionIndicator({ notebook }: { notebook: Notebook }) {
const hasSelectedTopics = useSelectionStore(
(store) => store.selected.filter((nb) => nb.id === notebook.id).length > 0
);
if (!hasSelectedTopics) return null;
return <Circle size={8} color="accent" sx={{ mr: 1 }} />;
}
function SelectedCheck({
export function SelectedCheck({
item,
size = 20,
onClick
}: {
item?: Notebook;
item?: { id: string };
size?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}) {
const selectedItems = useSelectionStore((store) => store.selected);
const selectedItem =
item && selectedItems[findSelectionIndex(item, selectedItems)];
const selected =
selectedItem?.op === "remove" ? "remove" : selectedItem?.op === "add";
return selected === true ? (
return selectedItem?.op === "add" ? (
<CheckCircleOutline
size={size}
sx={{ mr: 1 }}
color="accent"
onClick={onClick}
/>
) : selected === null ? (
<CheckIntermediate
size={size}
sx={{ mr: 1 }}
color="var(--accent-secondary)"
onClick={onClick}
/>
) : selected === "remove" ? (
) : selectedItem?.op === "remove" ? (
<CheckRemove
size={size}
sx={{ mr: 1 }}
@@ -477,18 +449,15 @@ function SelectedCheck({
);
}
function createSelection(notebook: Notebook): NotebookReference {
function createSelection(item: { id: string }): SelectedReference {
return {
id: notebook.id,
id: item.id,
op: "add",
new: true
};
}
function findSelectionIndex(
ref: NotebookReference | Notebook,
array: NotebookReference[]
) {
function findSelectionIndex(ref: { id: string }, array: { id: string }[]) {
return array.findIndex((a) => a.id === ref.id);
}
@@ -498,7 +467,10 @@ function notebookHasNotes(notebookId: string, noteIds: string[]) {
.has(...noteIds);
}
function selectMultiple(topic: Notebook, selected: NotebookReference[]) {
export function selectMultiple(
topic: { id: string },
selected: SelectedReference[]
) {
const index = findSelectionIndex(topic, selected);
const isSelected = index > -1;
const item = selected[index];
@@ -514,8 +486,8 @@ function selectMultiple(topic: Notebook, selected: NotebookReference[]) {
return selected;
}
function selectSingle(topic: Notebook, array: NotebookReference[]) {
const selected: NotebookReference[] = array.filter((ref) => !ref.new);
function selectSingle(topic: { id: string }, array: SelectedReference[]) {
const selected: SelectedReference[] = array.filter((ref) => !ref.new);
const index = findSelectionIndex(topic, array);
const item = array[index];

View File

@@ -0,0 +1,255 @@
/*
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 { Notebook } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { Button, Flex, Text } from "@theme-ui/components";
import { useCallback, useEffect, useRef, useState } from "react";
import { db } from "../common/db";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import Dialog from "../components/dialog";
import Field from "../components/field";
import {
TreeNode,
VirtualizedTree,
VirtualizedTreeHandle
} from "../components/virtualized-tree";
import { store as notebookStore, useStore } from "../stores/notebook-store";
import { AddNotebookDialog } from "./add-notebook-dialog";
import { NotebookItem, useSelectionStore } from "./move-note-dialog";
type MoveNotebookDialogProps = BaseDialogProps<boolean> & {
notebook: Notebook;
};
export const MoveNotebookDialog = DialogManager.register(
function MoveNotebookDialog({ onClose, notebook }: MoveNotebookDialogProps) {
const setSelected = useSelectionStore((store) => store.setSelected);
const refreshNotebooks = useStore((store) => store.refresh);
const treeRef = useRef<VirtualizedTreeHandle<Notebook>>(null);
const [notebooks, setNotebooks] = useState<string[]>([]);
const notebookId = notebook.id;
useEffect(() => {
db.notebooks.roots
.ids(db.settings.getGroupOptions("notebooks"))
.then((ids) => setNotebooks(ids));
}, []);
useEffect(() => {
(async function () {
const parentNotebookId = await getParentNotebookId(notebookId);
const selected = useSelectionStore.getState().selected[0];
if (!selected && parentNotebookId) {
setSelected([{ id: parentNotebookId, new: false, op: "add" }]);
}
})();
}, [notebookId, refreshNotebooks, setSelected]);
useEffect(() => {
treeRef.current?.refresh();
}, [notebooks]);
const _onClose = useCallback(
(result: boolean) => {
setSelected([]);
onClose(result);
},
[setSelected, onClose]
);
return (
<Dialog
testId="move-notebook-dialog"
isOpen={true}
title={`${strings.move()} ${notebook.title}`}
description={strings.moveNotebookDesc()}
onClose={() => _onClose(false)}
width={500}
noScroll
positiveButton={{
text: strings.done(),
onClick: async () => {
const { selected } = useSelectionStore.getState();
for (const item of selected) {
if (item.op === "remove") {
await db.relations.unlink(
{ type: "notebook", id: item.id },
{ id: notebookId, type: "notebook" }
);
} else if (item.op === "add") {
await db.relations.add(
{ type: "notebook", id: item.id },
{ id: notebookId, type: "notebook" }
);
}
}
await notebookStore.refresh();
_onClose(true);
}
}}
negativeButton={{
text: strings.cancel(),
onClick: () => _onClose(false)
}}
>
<Flex
id="subnotebooks"
variant="columnFill"
sx={{
height: "80vh",
px: 4
}}
>
<Field
autoFocus
sx={{ m: 0, mb: 2 }}
styles={{
input: { p: "7.5px" }
}}
placeholder={strings.searchNotebooks()}
onChange={async (e) => {
const query = e.target.value.trim();
const ids = await (query
? db.lookup.notebooks(query).ids()
: db.notebooks.roots.ids(
db.settings.getGroupOptions("notebooks")
));
setNotebooks(ids);
}}
/>
{notebooks.length > 0 ? (
<>
<VirtualizedTree
rootId={"root"}
itemHeight={30}
treeRef={treeRef}
getChildNodes={async (id, depth) => {
const parentNotebookId = await getParentNotebookId(
notebookId
);
const nodes: TreeNode<Notebook>[] = [];
if (id === "root") {
for (const id of notebooks) {
if (id === notebookId) continue;
const notebook = (await db.notebooks.notebook(id))!;
const childrenCount = await db.relations
.from(notebook, "notebook")
.count();
const isParent = parentNotebookId === notebook.id;
nodes.push({
data: notebook,
depth: depth + 1,
id,
parentId: "root",
hasChildren: isParent
? childrenCount !== 1
: childrenCount > 0
});
}
return nodes;
}
const subNotebooks = await db.relations
.from({ type: "notebook", id }, "notebook")
.resolve();
for (const notebook of subNotebooks) {
if (notebook.id === notebookId) continue;
const childrenCount = await db.relations
.from(notebook, "notebook")
.count();
const isParent = parentNotebookId === notebook.id;
nodes.push({
parentId: id,
id: notebook.id,
data: notebook,
depth: depth + 1,
hasChildren: isParent
? childrenCount !== 1
: childrenCount > 0
});
}
return nodes;
}}
renderItem={({ item, expanded, index, collapse, expand }) => (
<NotebookItem
notebook={item.data}
depth={item.depth}
isExpandable={item.hasChildren}
isExpanded={expanded}
toggle={expanded ? collapse : expand}
onCreateItem={() => {
treeRef.current?.refreshItem(index, item.data, {
expand: true
});
}}
/>
)}
/>
</>
) : (
<Flex
sx={{
my: 2,
flexDirection: "column",
justifyContent: "center",
alignItems: "center"
}}
>
<Text variant="body">{strings.notebooksEmpty()}</Text>
<Button
data-test-id="add-new-notebook"
variant="secondary"
sx={{ mt: 2 }}
onClick={() =>
AddNotebookDialog.show({}).then((res) =>
res
? db.notebooks.roots
.ids(db.settings.getGroupOptions("notebooks"))
.then((ids) => setNotebooks(ids))
: null
)
}
>
{strings.addNotebook()}
</Button>
</Flex>
)}
</Flex>
</Dialog>
);
}
);
async function getParentNotebookId(notebookId: string) {
const relation = await db.relations
.to(
{
id: notebookId,
type: "notebook"
},
"notebook"
)
.get();
return relation[0]?.fromId;
}

View File

@@ -1,17 +1,11 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2025-04-10 10:29+0500\n"
"POT-Creation-Date: 2025-06-05 11:43+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/strings.ts:2410
msgid " \"Notebook > Notes\""
@@ -5403,6 +5397,10 @@ msgstr "Select"
msgid "Select a backup file from your device to restore backup"
msgstr "Select a backup file from your device to restore backup"
#: src/strings.ts:2488
msgid "Select a notebook to move this notebook into, or unselect to move it to the root level."
msgstr "Select a notebook to move this notebook into, or unselect to move it to the root level."
#: src/strings.ts:2097
msgid "Select a theme"
msgstr "Select a theme"

View File

@@ -1,17 +1,11 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2025-04-10 10:29+0500\n"
"POT-Creation-Date: 2025-06-05 11:43+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: pseudo-LOCALE\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/strings.ts:2410
msgid " \"Notebook > Notes\""
@@ -5377,6 +5371,10 @@ msgstr ""
msgid "Select a backup file from your device to restore backup"
msgstr ""
#: src/strings.ts:2488
msgid "Select a notebook to move this notebook into, or unselect to move it to the root level."
msgstr ""
#: src/strings.ts:2097
msgid "Select a theme"
msgstr ""

View File

@@ -2483,5 +2483,7 @@ Use this if changes from other devices are not appearing on this device. This wi
unsetAsHomepage: () => t`Reset homepage`,
archive: () => t`Archive`,
yourArchiveIsEmpty: () => t`Your archive is empty`,
unarchive: () => t`Unarchive`
unarchive: () => t`Unarchive`,
moveNotebookDesc: () =>
t`Select a notebook to move this notebook into, or unselect to move it to the root level.`
};