diff --git a/apps/web/src/common/dialog-controller.tsx b/apps/web/src/common/dialog-controller.tsx index 33a6eff46..5c1f99f95 100644 --- a/apps/web/src/common/dialog-controller.tsx +++ b/apps/web/src/common/dialog-controller.tsx @@ -88,6 +88,12 @@ export function closeOpenedDialog() { dialogs.forEach((elem) => elem.remove()); } +export function showAddTagsDialog(noteIds: string[]) { + return showDialog("AddTagsDialog", (Dialog, perform) => ( + perform(res)} noteIds={noteIds} /> + )); +} + export function showAddNotebookDialog() { return showDialog("AddNotebookDialog", (Dialog, perform) => ( . +*/ + +import { useCallback, useEffect, useState } from "react"; +import { Flex, Text } from "@theme-ui/components"; +import * as Icon from "../icons"; +import { db } from "../../common/db"; +import Dialog from "./dialog"; +import { useStore, store } from "../../stores/tag-store"; +import { store as notestore } from "../../stores/note-store"; +import { Perform } from "../../common/dialog-controller"; +import { FilteredList } from "../filtered-list"; + +type SelectedReference = { + id: string; + new: boolean; + op: "add" | "remove"; +}; + +type Item = { + id: string; + type: "tag" | "header"; + title: string; +}; +type Tag = Item & { noteIds: string[] }; + +export type AddTagsDialogProps = { + onClose: Perform; + noteIds: string[]; +}; + +function AddTagsDialog(props: AddTagsDialogProps) { + const { onClose, noteIds } = props; + + const refreshTags = useStore((store) => store.refresh); + const tags = useStore((store) => store.tags); + + useEffect(() => { + refreshTags(); + }, [refreshTags]); + + const [selected, setSelected] = useState([]); + + const getAllTags = useCallback(() => { + refreshTags(); + return (store.get().tags as Item[]).filter((a) => a.type !== "header"); + }, [refreshTags]); + + useEffect(() => { + if (!tags) return; + + setSelected((s) => { + const selected = s.slice(); + for (const tag of tags as Tag[]) { + if (tag.type === "header") continue; + if (selected.findIndex((a) => a.id === tag.id) > -1) continue; + if (tagHasNotes(tag, noteIds)) { + selected.push({ + id: tag.id, + op: "add", + new: false + }); + } + } + return selected; + }); + }, [noteIds, tags, setSelected]); + + return ( + onClose(false)} + width={450} + positiveButton={{ + text: "Done", + onClick: async () => { + for (const id of noteIds) { + for (const item of selected) { + if (item.op === "add") await db.notes?.note(id).tag(item.id); + else await db.notes?.note(id).untag(item.id); + } + } + notestore.refresh(); + onClose(true); + } + }} + negativeButton={{ + text: "Cancel", + onClick: () => onClose(false) + }} + > + + db.lookup?.tags(tags, query) || []} + onCreateNewItem={async (title) => { + const tag = await db.tags?.add(title); + setSelected((selected) => [ + ...selected, + { id: tag.id, new: true, op: "add" } + ]); + }} + renderItem={(tag, _index) => { + const selectedTag = selected.find((item) => item.id === tag.id); + return ( + { + setSelected((selected) => { + 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" }); + } + return copy; + }); + }} + /> + ); + }} + /> + + + ); +} + +function TagItem(props: { + tag: Item; + selected: boolean | SelectedReference["op"]; + onSelect: () => void; +}) { + const { tag, selected, onSelect } = props; + + return ( + + + + + #{tag.title} + + + + ); +} + +export default AddTagsDialog; + +function SelectedCheck({ + selected, + size = 20 +}: { + selected: SelectedReference["op"] | boolean; + size?: number; +}) { + return selected === "add" ? ( + + ) : selected === "remove" ? ( + + ) : ( + + ); +} + +function tagHasNotes(tag: Tag, noteIds: string[]) { + return tag.noteIds.some((id) => noteIds.indexOf(id) > -1); +} diff --git a/apps/web/src/components/dialogs/index.ts b/apps/web/src/components/dialogs/index.ts index 342ebfe03..4dc43e98c 100644 --- a/apps/web/src/components/dialogs/index.ts +++ b/apps/web/src/components/dialogs/index.ts @@ -60,6 +60,7 @@ const LanguageSelectorDialog = React.lazy( const BillingHistoryDialog = React.lazy( () => import("./billing-history-dialog") ); +const AddTagsDialog = React.lazy(() => import("./add-tags-dialog")); export const Dialogs = { AddNotebookDialog, @@ -89,5 +90,6 @@ export const Dialogs = { ReminderPreviewDialog, EmailChangeDialog, LanguageSelectorDialog, - BillingHistoryDialog + BillingHistoryDialog, + AddTagsDialog }; diff --git a/apps/web/src/components/dialogs/move-note-dialog.tsx b/apps/web/src/components/dialogs/move-note-dialog.tsx index a1b08652d..913872071 100644 --- a/apps/web/src/components/dialogs/move-note-dialog.tsx +++ b/apps/web/src/components/dialogs/move-note-dialog.tsx @@ -17,12 +17,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Box, Button, Flex, Input, Text } from "@theme-ui/components"; import * as Icon from "../icons"; import { db } from "../../common/db"; import Dialog from "./dialog"; -import Field from "../field"; import { useStore, store } from "../../stores/notebook-store"; import { store as notestore } from "../../stores/note-store"; import { Perform } from "../../common/dialog-controller"; @@ -30,6 +29,7 @@ import { showToast } from "../../utils/toast"; import { pluralize } from "../../utils/string"; import { isMac } from "../../utils/platform"; import create from "zustand"; +import { FilteredList } from "../filtered-list"; type MoveDialogProps = { onClose: Perform; noteIds: string[] }; type NotebookReference = { @@ -193,7 +193,7 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) { sx={{ overflowY: "hidden", flexDirection: "column" }} data-test-id="notebook-list" > - = { - placeholders: { filter: string; empty: string }; - items: () => T[]; - filter: (items: T[], query: string) => T[]; - onCreateNewItem: (title: string) => Promise; - renderItem: ( - item: T, - index: number, - refresh: () => void, - isSearching: boolean - ) => JSX.Element; -}; - -function FilteredTree(props: FilteredTreeProps) { - const { - items: _items, - filter, - onCreateNewItem, - placeholders, - renderItem - } = props; - - const [items, setItems] = useState([]); - const [query, setQuery] = useState(); - const noItemsFound = items.length <= 0 && query && query.length > 0; - const inputRef = useRef(null); - - const refresh = useCallback(() => { - setItems(_items()); - }, [_items]); - - useEffect(() => { - refresh(); - }, [refresh]); - - const _filter = useCallback( - (query) => { - setItems(() => { - const items = _items(); - if (!query) { - return items; - } - return filter(items, query); - }); - setQuery(query); - }, - [_items, filter] - ); - - const _createNewItem = useCallback( - async (title) => { - await onCreateNewItem(title); - refresh(); - setQuery(undefined); - if (inputRef.current) inputRef.current.value = ""; - }, - [inputRef, refresh, onCreateNewItem] - ); - - return ( - <> - - _filter((e.target as HTMLInputElement).value) - } - onKeyUp={async (e: KeyboardEvent) => { - if (e.key === "Enter" && noItemsFound) { - await _createNewItem(query); - } - }} - action={ - items.length <= 0 - ? { - icon: Icon.Plus, - onClick: async () => await _createNewItem(query) - } - : { icon: Icon.Search, onClick: () => _filter(query) } - } - /> - - {noItemsFound && ( - - )} - {items.map((item, index) => renderItem(item, index, refresh, !!query))} - - - ); -} - function SelectedCheck({ item, size = 20 diff --git a/apps/web/src/components/editor/header.js b/apps/web/src/components/editor/header.js index c2ffb73be..1c3130560 100644 --- a/apps/web/src/components/editor/header.js +++ b/apps/web/src/components/editor/header.js @@ -72,7 +72,7 @@ function Header({ readonly }) { } export default Header; -function Autosuggest({ +export function Autosuggest({ sessionId, filter, onRemove, diff --git a/apps/web/src/components/filtered-list/index.tsx b/apps/web/src/components/filtered-list/index.tsx new file mode 100644 index 000000000..d28b7e06c --- /dev/null +++ b/apps/web/src/components/filtered-list/index.tsx @@ -0,0 +1,151 @@ +/* +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 . +*/ + +import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; +import Field from "../field"; +import { Plus, Search } from "../icons"; +import { Button, Flex, Text } from "@theme-ui/components"; + +type FilterableItem = { + id: string; + title: string; +}; + +type FilteredListProps = { + placeholders: { filter: string; empty: string }; + items: () => T[]; + filter: (items: T[], query: string) => T[]; + onCreateNewItem: (title: string) => Promise; + renderItem: ( + item: T, + index: number, + refresh: () => void, + isSearching: boolean + ) => JSX.Element; +}; + +export function FilteredList( + props: FilteredListProps +) { + const { + items: _items, + filter, + onCreateNewItem, + placeholders, + renderItem + } = props; + + const [items, setItems] = useState([]); + const [query, setQuery] = useState(); + const noItemsFound = items.length <= 0 && query && query.length > 0; + const inputRef = useRef(null); + + const refresh = useCallback(() => { + setItems(_items()); + }, [_items]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const _filter = useCallback( + (query) => { + setItems(() => { + const items = _items(); + if (!query) { + return items; + } + return filter(items, query); + }); + setQuery(query); + }, + [_items, filter] + ); + + const _createNewItem = useCallback( + async (title) => { + await onCreateNewItem(title); + refresh(); + setQuery(undefined); + if (inputRef.current) inputRef.current.value = ""; + }, + [inputRef, refresh, onCreateNewItem] + ); + + return ( + <> + + _filter((e.target as HTMLInputElement).value) + } + onKeyUp={async (e: KeyboardEvent) => { + if (e.key === "Enter" && noItemsFound) { + await _createNewItem(query); + } + }} + action={ + items.length <= 0 + ? { + icon: Plus, + onClick: async () => await _createNewItem(query) + } + : { icon: Search, onClick: () => _filter(query) } + } + /> + + {noItemsFound && ( + + )} + {items.map((item, index) => renderItem(item, index, refresh, !!query))} + + + ); +} diff --git a/apps/web/src/components/note/index.js b/apps/web/src/components/note/index.js index 954dd8cb4..1e2babadd 100644 --- a/apps/web/src/components/note/index.js +++ b/apps/web/src/components/note/index.js @@ -25,6 +25,7 @@ import ListItem from "../list-item"; import { confirm, showAddReminderDialog, + showAddTagsDialog, showMoveNoteDialog } from "../../common/dialog-controller"; import { store, useStore } from "../../stores/note-store"; @@ -372,6 +373,16 @@ const menuItems = [ icon: Icon.Colors, items: colorsToMenuItems() }, + { + key: "add-tags", + title: "Tags", + icon: Icon.Tag2, + multiSelect: true, + items: tagsMenuItems + // onClick: async ({ items }) => { + // await showAddTagsDialog(items.map((i) => i.id)); + // } + }, { key: "sep2", type: "separator" }, { key: "print", @@ -598,3 +609,60 @@ function notebooksMenuItems({ items }) { return menuItems; } + +function tagsMenuItems({ items }) { + const noteIds = items.map((i) => i.id); + + const menuItems = []; + menuItems.push({ + key: "assign-tags", + title: "Assign to...", + icon: Icon.Plus, + onClick: async () => { + await showAddTagsDialog(noteIds); + } + }); + + const tags = items.map((note) => note.tags).flat(); + + if (tags?.length > 0) { + menuItems.push( + { + key: "remove-from-all-tags", + title: "Remove from all", + icon: Icon.RemoveShortcutLink, + onClick: async () => { + for (const note of items) { + for (const tag of tags) { + if (!note.tags.includes(tag)) continue; + await db.notes.note(note).untag(tag); + } + } + store.refresh(); + } + }, + { key: "sep", type: "separator" } + ); + + tags?.forEach((tag) => { + if (menuItems.find((item) => item.key === tag)) return; + + menuItems.push({ + key: tag, + title: db.tags.alias(tag), + icon: Icon.Tag, + checked: true, + tooltip: "Click to remove from this tag", + onClick: async () => { + for (const note of items) { + if (!note.tags.includes(tag)) continue; + await db.notes.note(note).untag(tag); + } + store.refresh(); + } + }); + }); + } + + return menuItems; +} diff --git a/packages/core/models/note.js b/packages/core/models/note.js index 0d7aaa7f7..7d33bcb12 100644 --- a/packages/core/models/note.js +++ b/packages/core/models/note.js @@ -194,7 +194,8 @@ export default class Note { } async untag(tag) { - if (deleteItem(this._note.tags, tag)) { + const tagItem = this._db.tags.tag(tag); + if (tagItem && deleteItem(this._note.tags, tagItem.title)) { await this._db.notes.add(this._note); } else console.error("This note is not tagged by the specified tag.", tag); await this._db.tags.untag(tag, this._note.id);