From 96c351b8bbeb55f349d6c4eceacff077f7ba6ddf Mon Sep 17 00:00:00 2001 From: thecodrr Date: Mon, 14 Feb 2022 12:46:37 +0500 Subject: [PATCH] refactor: handle multiselect actions directly --- apps/web/src/common/dialogcontroller.js | 10 +- apps/web/src/common/export.ts | 61 +++++++ apps/web/src/common/index.js | 9 - apps/web/src/common/multi-select.ts | 103 +++++++++++ apps/web/src/common/selectionoptions.js | 167 ------------------ apps/web/src/common/task-manager.ts | 65 +++++++ apps/web/src/common/toasts.js | 15 +- .../src/components/dialogs/exportdialog.js | 152 ---------------- apps/web/src/components/dialogs/index.js | 2 - .../src/components/dialogs/progress-dialog.js | 47 +++-- apps/web/src/components/icons/resolver.js | 1 + apps/web/src/components/list-item/index.js | 19 +- apps/web/src/components/menu/index.js | 9 +- apps/web/src/components/note/index.js | 42 +++-- apps/web/src/components/notebook/index.js | 9 +- apps/web/src/components/tag/index.js | 15 +- apps/web/src/components/topic/index.js | 30 ++-- apps/web/src/components/trash-item/index.js | 52 ++---- apps/web/src/react-app-env.d.ts | 1 + apps/web/src/stores/note-store.js | 10 +- apps/web/src/stores/notebook-store.js | 4 +- apps/web/src/stores/trash-store.js | 14 +- apps/web/tsconfig.json | 22 +++ 23 files changed, 385 insertions(+), 474 deletions(-) create mode 100644 apps/web/src/common/export.ts create mode 100644 apps/web/src/common/multi-select.ts delete mode 100644 apps/web/src/common/selectionoptions.js create mode 100644 apps/web/src/common/task-manager.ts delete mode 100644 apps/web/src/components/dialogs/exportdialog.js create mode 100644 apps/web/src/react-app-env.d.ts create mode 100644 apps/web/tsconfig.json diff --git a/apps/web/src/common/dialogcontroller.js b/apps/web/src/common/dialogcontroller.js index 1f2d045eb..7812f5bcd 100644 --- a/apps/web/src/common/dialogcontroller.js +++ b/apps/web/src/common/dialogcontroller.js @@ -268,15 +268,17 @@ export function showLoadingDialog(dialogData) { )); } +/** + * + * @param {{title: string, subtitle?: string, action: Function}} dialogData + * @returns + */ export function showProgressDialog(dialogData) { - const { title, message, subtitle, total, setProgress, action } = dialogData; + const { title, subtitle, action } = dialogData; return showDialog((Dialogs, perform) => ( perform(e)} /> diff --git a/apps/web/src/common/export.ts b/apps/web/src/common/export.ts new file mode 100644 index 000000000..454a3dac8 --- /dev/null +++ b/apps/web/src/common/export.ts @@ -0,0 +1,61 @@ +import download from "../utils/download"; +import { db } from "./db"; +import { TaskManager } from "./task-manager"; +import { zip } from "../utils/zip"; + +async function exportToPDF(content: string): Promise { + if (!content) return false; + const { default: printjs } = await import("print-js"); + return new Promise(async (resolve) => { + printjs({ + printable: content, + type: "raw-html", + onPrintDialogClose: () => { + resolve(false); + }, + onError: () => resolve(false), + }); + resolve(true); + }); +} + +export async function exportNotes( + format: "pdf" | "md" | "txt" | "html", + noteIds: string[] +): Promise { + return await TaskManager.startTask({ + type: "modal", + title: "Exporting notes", + subtitle: "Please wait while your notes are exported.", + action: async (report) => { + if (format === "pdf") { + const note = db.notes.note(noteIds[0]); + return await exportToPDF(await note.export("html")); + } + + var files = []; + let index = 0; + for (var noteId of noteIds) { + const note = db.notes.note(noteId); + report({ + current: ++index, + total: noteIds.length, + text: `Exporting "${note.title}"...`, + }); + console.log("Exporting", note.title); + const content = await note.export(format).catch(() => {}); + if (!content) continue; + files.push({ filename: note.title, content }); + } + + if (!files.length) return false; + if (files.length === 1) { + download(files[0].filename, files[0].content, format); + } else { + const zipped = zip(files, format); + download("notes", zipped, "zip"); + } + return true; + }, + }); +} diff --git a/apps/web/src/common/index.js b/apps/web/src/common/index.js index d0a1b5c1d..69f558cff 100644 --- a/apps/web/src/common/index.js +++ b/apps/web/src/common/index.js @@ -1,4 +1,3 @@ -import SelectionOptions from "./selectionoptions"; import download from "../utils/download"; import { showFeatureDialog, @@ -32,14 +31,6 @@ export const SUBSCRIPTION_STATUS = { PREMIUM_CANCELED: 7, }; -export const SELECTION_OPTIONS_MAP = { - notes: SelectionOptions.NotesOptions, - notebooks: SelectionOptions.NotebooksOptions, - favorites: SelectionOptions.FavoritesOptions, - trash: SelectionOptions.TrashOptions, - topics: SelectionOptions.TopicOptions, -}; - export const CREATE_BUTTON_MAP = { notes: { title: "Make a note", diff --git a/apps/web/src/common/multi-select.ts b/apps/web/src/common/multi-select.ts new file mode 100644 index 000000000..7f8410da8 --- /dev/null +++ b/apps/web/src/common/multi-select.ts @@ -0,0 +1,103 @@ +import { removeStatus, updateStatus } from "../hooks/use-status"; +import { + showMultiDeleteConfirmation, + showMultiPermanentDeleteConfirmation, +} from "./dialog-controller"; +import { store as editorStore } from "../stores/editor-store"; +import { store as appStore } from "../stores/app-store"; +import { store as noteStore } from "../stores/note-store"; +import { store as notebookStore } from "../stores/notebook-store"; +import { db } from "./db"; +import { hashNavigate } from "../navigation"; +import { showToast } from "../utils/toast"; +import Vault from "./vault"; +import { showItemDeletedToast } from "./toasts"; +import { TaskManager } from "./task-manager"; + +async function moveNotesToTrash(notes: any[]) { + const item = notes[0]; + const isMultiselect = notes.length > 1; + if (isMultiselect) { + if (!(await showMultiDeleteConfirmation(notes.length))) return; + } else { + if (item.locked && !(await Vault.unlockNote(item.id))) return; + } + + var isAnyNoteOpened = false; + const items = notes.map((item) => { + if (item.id === editorStore.get().session.id) isAnyNoteOpened = true; + if (item.locked || db.monographs.isPublished(item.id)) return 0; + return item.id; + }); + + if (isAnyNoteOpened) { + hashNavigate("/notes/create", { addNonce: true }); + } + + await TaskManager.startTask({ + type: "status", + id: "deleteNotes", + action: async (report) => { + report({ + text: `Deleting ${items.length} notes...`, + }); + await noteStore.delete(...items); + }, + }); + + if (isMultiselect) { + showToast("success", `${items.length} notes moved to trash`); + } else { + showItemDeletedToast(item); + } +} + +async function moveNotebooksToTrash(notebooks: any[]) { + const item = notebooks[0]; + const isMultiselect = notebooks.length > 1; + if (isMultiselect) { + if (!(await showMultiDeleteConfirmation(notebooks.length))) return; + } else { + if (item.locked && !(await Vault.unlockNote(item.id))) return; + } + + await TaskManager.startTask({ + type: "status", + id: "deleteNotebooks", + action: async (report) => { + report({ + text: `Deleting ${notebooks.length} notebooks...`, + }); + await notebookStore.delete(...notebooks.map((i) => i.id)); + }, + }); + + if (isMultiselect) { + showToast("success", `${notebooks.length} notebooks moved to trash`); + } else { + showItemDeletedToast(item); + } +} + +async function deleteTopics(notebookId: string, topics: any[]) { + await TaskManager.startTask({ + type: "status", + id: "deleteTopics", + action: async (report) => { + report({ + text: `Deleting ${topics.length} topics...`, + }); + await db.notebooks + .notebook(notebookId) + .topics.delete(...topics.map((t) => t.id)); + notebookStore.setSelectedNotebook(notebookId); + }, + }); + showToast("success", `${topics.length} topics deleted`); +} + +export const Multiselect = { + moveNotebooksToTrash, + moveNotesToTrash, + deleteTopics, +}; diff --git a/apps/web/src/common/selectionoptions.js b/apps/web/src/common/selectionoptions.js deleted file mode 100644 index 1e3ce039b..000000000 --- a/apps/web/src/common/selectionoptions.js +++ /dev/null @@ -1,167 +0,0 @@ -import * as Icon from "../components/icons"; -import { store as selectionStore } from "../stores/selection-store"; -import { store as notesStore } from "../stores/note-store"; -import { store as nbStore } from "../stores/notebook-store"; -import { store as editorStore } from "../stores/editor-store"; -import { store as appStore } from "../stores/app-store"; -import { store as trashStore } from "../stores/trash-store"; -import { db } from "./db"; -import { showMoveNoteDialog } from "../common/dialog-controller"; -import { - showMultiDeleteConfirmation, - showMultiPermanentDeleteConfirmation, -} from "../common/dialog-controller"; -import { showExportDialog } from "../common/dialog-controller"; -import { showToast } from "../utils/toast"; -import { hashNavigate } from "../navigation"; -import { removeStatus, updateStatus } from "../hooks/use-status"; - -function createOption(key, title, icon, onClick) { - return { - key, - icon, - title, - onClick: async () => { - await onClick.call(this, selectionStore.get()); - selectionStore.toggleSelectionMode(false); - removeStatus(key); - }, - }; -} - -function createOptions(options = []) { - return [...options, DeleteOption]; -} - -const DeleteOption = createOption( - "deleteOption", - "Delete", - Icon.Trash, - async function (state) { - const item = state.selectedItems[0]; - - const confirmDialog = item.dateDeleted - ? showMultiPermanentDeleteConfirmation - : showMultiDeleteConfirmation; - if (!(await confirmDialog(state.selectedItems.length))) return; - - const statusText = `${ - item.dateDeleted ? `Permanently deleting` : `Deleting` - } ${state.selectedItems.length} items...`; - updateStatus({ key: "deleteOption", status: statusText }); - - var isAnyNoteOpened = false; - const items = state.selectedItems.map((item) => { - if (item.id === editorStore.get().session.id) isAnyNoteOpened = true; - if (item.locked || db.monographs.isPublished(item.id)) return 0; - return item.id; - }); - - if (item.dateDeleted) { - // we are in trash - await db.trash.delete(...items); - trashStore.refresh(); - showToast("success", `${items.length} items permanently deleted!`); - return; - } - - if (isAnyNoteOpened) { - hashNavigate("/notes/create", { addNonce: true }); - } - - if (item.type === "note") { - await db.notes.delete(...items); - } else if (item.type === "notebook") { - await db.notebooks.delete(...items); - } else if (item.type === "topic") { - await db.notebooks.notebook(item.notebookId).topics.delete(...items); - nbStore.setSelectedNotebook(item.notebookId); - } - appStore.refresh(); - showToast("success", `${items.length} ${item.type}s moved to trash!`); - } -); - -const UnfavoriteOption = createOption( - "unfavoriteOption", - "Unfavorite", - Icon.Star, - function (state) { - updateStatus({ - key: "unfavoriteOption", - status: `Unfavoriting ${state.selectedItems.length} notes...`, - }); - - // we know only notes can be favorited - state.selectedItems.forEach(async (item) => { - if (!item.favorite) return; - await db.notes.note(item.id).favorite(); - }); - notesStore.setContext({ type: "favorites" }); - } -); - -const AddToNotebookOption = createOption( - "atnOption", - "Add to notebook(s)", - Icon.AddToNotebook, - async function (state) { - updateStatus({ - key: "atnOption", - status: `Adding ${state.selectedItems.length} notes to notebooks...`, - }); - - const items = state.selectedItems.map((item) => item.id); - await showMoveNoteDialog(items); - showToast("success", `${items.length} notes moved!`); - } -); - -const ExportOption = createOption( - "exportOption", - "Export", - Icon.Export, - async function (state) { - updateStatus({ - key: "exportOption", - status: `Exporting ${state.selectedItems.length} notes...`, - }); - - const items = state.selectedItems.map((item) => item.id); - if (await showExportDialog(items)) { - await showToast("success", `${items.length} notes exported!`); - } - } -); - -const RestoreOption = createOption( - "restoreOption", - "Restore", - Icon.Restore, - async function (state) { - updateStatus({ - key: "restoreOption", - status: `Restoring ${state.selectedItems.length} items...`, - }); - - const items = state.selectedItems.map((item) => item.id); - await db.trash.restore(...items); - appStore.refresh(); - showToast("success", `${items.length} items restored!`); - } -); - -const NotesOptions = createOptions([AddToNotebookOption, ExportOption]); -const NotebooksOptions = createOptions(); -const TopicOptions = createOptions(); -const TrashOptions = createOptions([RestoreOption]); -const FavoritesOptions = createOptions([UnfavoriteOption]); - -const SelectionOptions = { - NotebooksOptions, - NotesOptions, - TopicOptions, - TrashOptions, - FavoritesOptions, -}; -export default SelectionOptions; diff --git a/apps/web/src/common/task-manager.ts b/apps/web/src/common/task-manager.ts new file mode 100644 index 000000000..904779f4b --- /dev/null +++ b/apps/web/src/common/task-manager.ts @@ -0,0 +1,65 @@ +import { removeStatus, updateStatus } from "../hooks/use-status"; +import { showProgressDialog } from "./dialog-controller"; + +type TaskType = "status" | "modal"; +type TaskAction = (report: ProgressReportCallback) => T | Promise; +type BaseTaskDefinition = { + type: TTaskType; + action: TaskAction; +}; + +type StatusTaskDefinition = BaseTaskDefinition< + "status", + TReturnType +> & { + id: string; +}; + +type ModalTaskDefinition = BaseTaskDefinition< + "modal", + TReturnType +> & { + title: string; + subtitle: string; +}; + +type TaskDefinition = + | StatusTaskDefinition + | ModalTaskDefinition; + +type TaskProgress = { + total?: number; + current?: number; + text: string; +}; + +type ProgressReportCallback = (progress: TaskProgress) => void; + +export class TaskManager { + static async startTask(task: TaskDefinition): Promise { + switch (task.type) { + case "status": + const statusTask = task; + const result = await statusTask.action((progress) => { + let percentage: number | undefined = undefined; + if (progress.current && progress.total) + percentage = Math.round((progress.current / progress.total) * 100); + + updateStatus({ + key: statusTask.id, + status: progress.text, + progress: percentage, + icon: null, + }); + }); + removeStatus(statusTask.id); + return result; + case "modal": + return await showProgressDialog({ + title: task.title, + subtitle: task.subtitle, + action: task.action, + }); + } + } +} diff --git a/apps/web/src/common/toasts.js b/apps/web/src/common/toasts.js index 0b263ba2b..69e96280d 100644 --- a/apps/web/src/common/toasts.js +++ b/apps/web/src/common/toasts.js @@ -57,25 +57,20 @@ function showItemDeletedToast(item) { var toast = showToast("success", messageText, actions); } -async function showPermanentDeleteToast(item) { - const noun = item.itemType === "note" ? "Note" : "Notebook"; - const messageText = `${noun} permanently deleted!`; - const timeoutId = setTimeout(() => { - trashstore.delete(item.id, true); - trashstore.refresh(); - }, 5000); +async function showUndoableToast(message, onAction, onUndo) { + const timeoutId = setTimeout(onAction, 5000); const undoAction = async () => { toast.hide(); - trashstore.refresh(); + onUndo(); clearTimeout(timeoutId); }; let actions = [{ text: "Undo", onClick: undoAction }]; - var toast = showToast("success", messageText, actions); + var toast = showToast("success", message, actions); } export { showNotesMovedToast, showUnpinnedToast, showItemDeletedToast, - showPermanentDeleteToast, + showUndoableToast, }; diff --git a/apps/web/src/components/dialogs/exportdialog.js b/apps/web/src/components/dialogs/exportdialog.js deleted file mode 100644 index 0a85d74ac..000000000 --- a/apps/web/src/components/dialogs/exportdialog.js +++ /dev/null @@ -1,152 +0,0 @@ -import React, { useState } from "react"; -import { Flex, Button, Text } from "rebass"; -import * as Icon from "../icons"; -import Dialog from "./dialog"; -import download from "../../utils/download"; -import { zip } from "../../utils/zip"; -import { db } from "../../common/db"; - -const formats = [ - { - type: "pdf", - title: "PDF", - icon: Icon.PDF, - subtitle: - "Can be opened in any PDF reader like Adobe Acrobat or Foxit Reader.", - }, - { - type: "md", - title: "Markdown", - icon: Icon.Markdown, - subtitle: "Can be opened in any plain-text or markdown editor.", - }, - { - type: "html", - title: "HTML", - icon: Icon.HTML, - subtitle: "Can be opened in any web browser like Google Chrome.", - }, - { - type: "txt", - title: "Text", - icon: Icon.Text, - subtitle: "Can be opened in any plain-text editor.", - }, -]; -function ExportDialog(props) { - const { noteIds } = props; - const [progress, setProgress] = useState(); - - return ( - - - {progress ? ( - - - Processing note {progress} of {noteIds.length} - - - ) : ( - <> - {formats.map(({ type, title, icon: Icon }) => { - if (type === "pdf" && noteIds.length > 1) return null; - - return ( - - ); - })} - - )} - - - ); -} -export default ExportDialog; - -async function exportToPDF(content) { - if (!content) return false; - return new Promise((resolve) => { - return import("print-js").then(async ({ default: printjs }) => { - printjs({ - printable: content, - type: "raw-html", - onPrintDialogClose: () => { - resolve(); - }, - }); - return true; - // TODO - // const doc = new jsPDF("p", "px", "letter"); - // const div = document.createElement("div"); - // const { width, height } = doc.internal.pageSize; - // div.innerHTML = content; - // div.style.width = width - 80 + "px"; - // div.style.height = height - 80 + "px"; - // div.style.position = "absolute"; - // div.style.top = 0; - // div.style.left = 0; - // div.style.margin = "40px"; - // div.style.fontSize = "11px"; - // document.body.appendChild(div); - - // await doc.html(div, { - // callback: async (doc) => { - // div.remove(); - // resolve(doc.output()); - // }, - // }); - }); - }); -} diff --git a/apps/web/src/components/dialogs/index.js b/apps/web/src/components/dialogs/index.js index 43c0c1706..c686ac2fc 100644 --- a/apps/web/src/components/dialogs/index.js +++ b/apps/web/src/components/dialogs/index.js @@ -2,7 +2,6 @@ import AddNotebookDialog from "./addnotebookdialog"; import BuyDialog from "./buy-dialog"; import Confirm from "./confirm"; import EmailVerificationDialog from "./emailverificationdialog"; -import ExportDialog from "./exportdialog"; import ImportDialog from "./importdialog"; import LoadingDialog from "./loadingdialog"; import ProgressDialog from "./progressdialog"; @@ -22,7 +21,6 @@ const Dialogs = { BuyDialog, Confirm, EmailVerificationDialog, - ExportDialog, LoadingDialog, MoveDialog, PasswordDialog, diff --git a/apps/web/src/components/dialogs/progress-dialog.js b/apps/web/src/components/dialogs/progress-dialog.js index 624b85e38..621a70a28 100644 --- a/apps/web/src/components/dialogs/progress-dialog.js +++ b/apps/web/src/components/dialogs/progress-dialog.js @@ -3,23 +3,16 @@ import { Box, Flex, Text } from "rebass"; import Dialog from "./dialog"; function ProgressDialog(props) { - const [{ loaded, progress }, setProgress] = useState({ - loaded: 0, - progress: 0, + const [{ current, total, text }, setProgress] = useState({ + current: 0, + total: 1, + text: "", }); - useEffect(() => { - if (!props.setProgress) return; - const undo = props.setProgress(setProgress); - return () => { - undo && undo(); - }; - }, [props, setProgress]); - useEffect(() => { (async function () { try { - props.onDone(await props.action()); + props.onDone(await props.action(setProgress)); } catch (e) { props.onDone(e); } @@ -34,19 +27,23 @@ function ProgressDialog(props) { onClose={() => {}} > - {props.message} - - {loaded} of {props.total} - - + {text} + {current > 0 && ( + <> + + {current} of {total} + + + + )} ); diff --git a/apps/web/src/components/icons/resolver.js b/apps/web/src/components/icons/resolver.js index 86eb8c12f..8b4a1fdcf 100644 --- a/apps/web/src/components/icons/resolver.js +++ b/apps/web/src/components/icons/resolver.js @@ -2,6 +2,7 @@ const { toTitleCase, toCamelCase } = require("../../utils/string"); var icons = undefined; export function getIconFromAlias(alias) { + if (!alias) return; const iconName = toTitleCase(toCamelCase(alias)); if (!icons) icons = require("./index"); return icons[iconName]; diff --git a/apps/web/src/components/list-item/index.js b/apps/web/src/components/list-item/index.js index b95253f9a..94f760db8 100644 --- a/apps/web/src/components/list-item/index.js +++ b/apps/web/src/components/list-item/index.js @@ -5,7 +5,6 @@ import { useStore as useSelectionStore, } from "../../stores/selection-store"; import { useMenuTrigger } from "../../hooks/use-menu"; -import { SELECTION_OPTIONS_MAP } from "../../common"; import Config from "../../utils/config"; import { db } from "../../common/db"; import * as clipboard from "clipboard-polyfill/text"; @@ -62,24 +61,20 @@ function ListItem(props) { e.preventDefault(); let items = props.menu?.items || []; let title = props.item.title; + let selectedItems = selectionStore.get().selectedItems.slice(); if (isSelected) { - const options = SELECTION_OPTIONS_MAP[window.currentViewType]; - items = options.map((option) => { - return { - key: option.key, - title: () => option.title, - icon: option.icon, - onClick: option.onClick, - }; - }); - title = `${selectionStore.get().selectedItems.length} selected`; + title = `${selectedItems.length} items selected`; + items = items.filter((item) => item.multiSelect); } else if (Config.get("debugMode", false)) { items.push(...debugMenuItems(props.item.type)); + } else { + selectedItems.push(props.item); } openMenu(items, { title, + items: selectedItems, ...props.menu?.extraData, }); }} @@ -119,7 +114,7 @@ function ListItem(props) { } else { selectionStore.toggleSelectionMode(false); selectItem(props.item); - props.onClick(); + props.onClick && props.onClick(); } }} data-test-id={`${props.item.type}-${props.index}`} diff --git a/apps/web/src/components/menu/index.js b/apps/web/src/components/menu/index.js index 92744facd..4e9962557 100644 --- a/apps/web/src/components/menu/index.js +++ b/apps/web/src/components/menu/index.js @@ -3,6 +3,7 @@ import { Flex, Text } from "rebass"; 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) { const [focusIndex, setFocusIndex] = useState(-1); @@ -72,7 +73,11 @@ function Menu({ items, data, title, closeMenu }) { (e, item) => { e.stopPropagation(); if (closeMenu) closeMenu(); - if (item.onClick) item.onClick(data, item); + if (item.onClick) { + item.onClick(data, item); + // TODO: this probably shouldn't be here. + selectionStore.toggleSelectionMode(false); + } }, [closeMenu, data] ); @@ -87,7 +92,7 @@ function Menu({ items, data, title, closeMenu }) { const item = items[focusIndex]; if (!item) return; const element = document.getElementById(item.key); - + if (!element) return; element.scrollIntoView({ behavior: "auto" }); }, [focusIndex, items]); diff --git a/apps/web/src/components/note/index.js b/apps/web/src/components/note/index.js index fcc7fb0b8..2f7a02432 100644 --- a/apps/web/src/components/note/index.js +++ b/apps/web/src/components/note/index.js @@ -15,6 +15,8 @@ import { showPublishView } from "../publish-view"; import Vault from "../../common/vault"; import IconTag from "../icon-tag"; import { COLORS } from "../../common"; +import { exportNotes } from "../../common/export"; +import { Multiselect } from "../../common/multi-select"; function Note(props) { const { tags, notebook, item, index, context, attachments, date } = props; @@ -94,6 +96,9 @@ function Note(props) { data-test-id={`note-${index}-locked`} /> )} + {note.favorite && ( + + )} ) : ( @@ -229,22 +234,21 @@ const menuItems = [ onClick: async ({ note }) => { await pin(note); }, - modifier: ["Alt", "P"], }, { key: "favorite", title: ({ note }) => (note.favorite ? "Unfavorite" : "Favorite"), icon: Icon.StarOutline, onClick: ({ note }) => store.favorite(note), - modifier: ["Alt", "F"], }, { key: "addtonotebook", title: "Add to notebook(s)", icon: Icon.AddToNotebook, - onClick: async ({ note }) => { - await showMoveNoteDialog([note.id]); + onClick: async ({ items }) => { + await showMoveNoteDialog(items.map((i) => i.id)); }, + multiSelect: true, }, { key: "colors", @@ -264,7 +268,6 @@ const menuItems = [ }, })), }, - { key: "publish", disabled: ({ note }) => !db.monographs.isPublished(note.id) && note.locked, @@ -287,10 +290,14 @@ const menuItems = [ title: format.title, tooltip: `Export as ${format.title} - ${format.subtitle}`, icon: format.icon, - onClick: ({ note }) => { - alert("TBI"); + onClick: async ({ items }) => { + await exportNotes( + format.type, + items.map((i) => i.id) + ); }, })), + multiSelect: true, isPro: true, }, { @@ -308,24 +315,20 @@ const menuItems = [ } }, isPro: true, - modifier: ["Alt", "L"], }, - { key: "movetotrash", title: "Move to trash", color: "red", iconColor: "red", icon: Icon.Trash, - disabled: ({ note }) => db.monographs.isPublished(note.id), + disabled: ({ items }) => + items.length === 1 && db.monographs.isPublished(items[0].id), disableReason: "Please unpublish this note to move it to trash", - onClick: async ({ note }) => { - if (note.locked) { - if (!(await Vault.unlockNote(note.id))) return; - } - await store.delete(note.id).then(() => showItemDeletedToast(note)); + onClick: async ({ items }) => { + await Multiselect.moveNotesToTrash(items); }, - modifier: ["Delete"], + multiSelect: true, }, ]; @@ -336,13 +339,14 @@ const topicNoteMenuItems = [ title: "Remove from topic", icon: Icon.TopicRemove, color: "red", - onClick: async ({ note, context }) => { + iconColor: "red", + onClick: async ({ items, context }) => { await db.notebooks .notebook(context.value.id) .topics.topic(context.value.topic) - .delete(note.id); + .delete(...items.map((i) => i.id)); store.refresh(); - await showToast("success", "Note removed from topic!"); }, + multiSelect: true, }, ]; diff --git a/apps/web/src/components/notebook/index.js b/apps/web/src/components/notebook/index.js index fce8dce8f..f50ba62d2 100644 --- a/apps/web/src/components/notebook/index.js +++ b/apps/web/src/components/notebook/index.js @@ -9,6 +9,7 @@ import * as Icon from "../icons"; import { hashNavigate, navigate } from "../../navigation"; import IconTag from "../icon-tag"; import { showToast } from "../../utils/toast"; +import { Multiselect } from "../../common/multi-select"; function Notebook(props) { const { item, index, totalNotes, date } = props; @@ -124,11 +125,11 @@ const menuItems = [ { title: "Move to trash", color: "red", + iconColor: "red", icon: Icon.Trash, - onClick: async ({ notebook }) => { - await store - .delete(notebook.id) - .then(() => showItemDeletedToast(notebook)); + onClick: async ({ items }) => { + await Multiselect.moveNotebooksToTrash(items); }, + multiSelect: true, }, ]; diff --git a/apps/web/src/components/tag/index.js b/apps/web/src/components/tag/index.js index 256e7d09b..a992095af 100644 --- a/apps/web/src/components/tag/index.js +++ b/apps/web/src/components/tag/index.js @@ -25,16 +25,19 @@ const menuItems = [ }, { color: "error", + iconColor: "error", title: "Delete", icon: Icon.DeleteForver, - onClick: async ({ tag }) => { - if (tag.noteIds.includes(editorStore.get().session.id)) - editorStore.clearSession(); - - await db.tags.remove(tag.id); - showToast("success", "Tag deleted!"); + onClick: async ({ items }) => { + for (let tag of items) { + if (tag.noteIds.includes(editorStore.get().session.id)) + await editorStore.clearSession(); + await db.tags.remove(tag.id); + } + showToast("success", `${items.length} tags deleted`); tagStore.refresh(); }, + multiSelect: true, }, ]; diff --git a/apps/web/src/components/topic/index.js b/apps/web/src/components/topic/index.js index f2f20f751..dec03903a 100644 --- a/apps/web/src/components/topic/index.js +++ b/apps/web/src/components/topic/index.js @@ -6,6 +6,7 @@ import { store as appStore } from "../../stores/app-store"; import { hashNavigate } from "../../navigation"; import { Flex, Text } from "rebass"; import * as Icon from "../icons"; +import { Multiselect } from "../../common/multi-select"; function Topic({ item, index, onClick }) { const topic = item; @@ -32,7 +33,7 @@ function Topic({ item, index, onClick }) { index={index} menu={{ items: menuItems, - extraData: { topic }, + extraData: { topic, notebookId: topic.notebookId }, }} /> ); @@ -45,16 +46,6 @@ export default React.memo(Topic, (prev, next) => { ); }); -const generalTopicMenuItems = [ - { - key: "shortcut", - title: ({ topic }) => - db.settings.isPinned(topic.id) ? "Remove shortcut" : "Create shortcut", - icon: Icon.Shortcut, - onClick: ({ topic }) => appStore.pinItemToMenu(topic), - }, -]; - const menuItems = [ { key: "edit", @@ -63,15 +54,22 @@ const menuItems = [ onClick: ({ topic }) => hashNavigate(`/notebooks/${topic.notebookId}/topics/${topic.id}/edit`), }, - ...generalTopicMenuItems, + { + key: "shortcut", + title: ({ topic }) => + db.settings.isPinned(topic.id) ? "Remove shortcut" : "Create shortcut", + icon: Icon.Shortcut, + onClick: ({ topic }) => appStore.pinItemToMenu(topic), + }, { key: "delete", title: "Delete", icon: Icon.Trash, - color: "red", - onClick: async ({ topic }) => { - await db.notebooks.notebook(topic.notebookId).topics.delete(topic.id); - store.setSelectedNotebook(topic.notebookId); + color: "error", + iconColor: "error", + onClick: async ({ items, notebookId }) => { + await Multiselect.deleteTopics(notebookId, items); }, + multiSelect: true, }, ]; diff --git a/apps/web/src/components/trash-item/index.js b/apps/web/src/components/trash-item/index.js index 23b4656a2..b26d18b25 100644 --- a/apps/web/src/components/trash-item/index.js +++ b/apps/web/src/components/trash-item/index.js @@ -1,13 +1,16 @@ import React from "react"; import ListItem from "../list-item"; -import { confirm } from "../../common/dialog-controller"; +import { + confirm, + showMultiPermanentDeleteConfirmation, +} from "../../common/dialog-controller"; import * as Icon from "../icons"; import { store } from "../../stores/trash-store"; import { Flex, Text } from "rebass"; import TimeAgo from "../time-ago"; import { toTitleCase } from "../../utils/string"; +import { showUndoableToast } from "../../common/toasts"; import { showToast } from "../../utils/toast"; -import { showPermanentDeleteToast } from "../../common/toasts"; function TrashItem({ item, index, date }) { return ( @@ -36,44 +39,25 @@ const menuItems = [ { title: "Restore", icon: Icon.Restore, - onClick: ({ item }) => { - store.restore(item.id); - showToast( - "success", - `${ - item.itemType === "note" ? "Note" : "Notebook" - } restored successfully!` - ); + onClick: ({ items }) => { + store.restore(items.map((i) => i.id)); + showToast("success", `${items.length} items restored`); }, + multiSelect: true, }, { title: "Delete", icon: Icon.DeleteForver, color: "red", - onClick: ({ item }) => { - confirm({ - title: `Permanently delete ${item.itemType}`, - subtitle: `Are you sure you want to permanently delete this ${item.itemType}?`, - yesText: `Delete`, - noText: "Cancel", - message: ( - <> - This action is{" "} - - IRREVERSIBLE - - . You will{" "} - - not be able to recover this {item.itemType}. - - - ), - }).then(async (res) => { - if (res) { - await store.delete(item.id); - showPermanentDeleteToast(item); - } - }); + onClick: async ({ items }) => { + if (!(await showMultiPermanentDeleteConfirmation(items.length))) return; + const ids = items.map((i) => i.id); + showUndoableToast( + `${items.length} items permanently deleted`, + () => store.delete(ids), + () => store.delete(ids, true) + ); }, + multiSelect: true, }, ]; diff --git a/apps/web/src/react-app-env.d.ts b/apps/web/src/react-app-env.d.ts new file mode 100644 index 000000000..6431bc5fc --- /dev/null +++ b/apps/web/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/src/stores/note-store.js b/apps/web/src/stores/note-store.js index ed5e25868..58cbe3e2c 100644 --- a/apps/web/src/stores/note-store.js +++ b/apps/web/src/stores/note-store.js @@ -68,13 +68,15 @@ class NoteStore extends BaseStore { }); }; - delete = async (id) => { + delete = async (...ids) => { const { session, clearSession } = editorStore.get(); - if (session && session.id === id) { - await clearSession(); + for (let id of ids) { + if (session && session.id === id) { + await clearSession(); + } } - await db.notes.delete(id); + await db.notes.delete(ids); this.refresh(); appStore.refreshNavItems(); diff --git a/apps/web/src/stores/notebook-store.js b/apps/web/src/stores/notebook-store.js index 4803f81e6..abe0f510e 100644 --- a/apps/web/src/stores/notebook-store.js +++ b/apps/web/src/stores/notebook-store.js @@ -27,8 +27,8 @@ class NotebookStore extends BaseStore { this.setSelectedNotebook(this.get().selectedNotebookId); }; - delete = async (id) => { - await db.notebooks.delete(id); + delete = async (...ids) => { + await db.notebooks.delete(...ids); this.refresh(); appStore.refreshNavItems(); noteStore.refresh(); diff --git a/apps/web/src/stores/trash-store.js b/apps/web/src/stores/trash-store.js index ba3edad9a..fd0662bf5 100644 --- a/apps/web/src/stores/trash-store.js +++ b/apps/web/src/stores/trash-store.js @@ -18,18 +18,20 @@ class TrashStore extends BaseStore { ); }; - delete = (id, commit = false) => { + delete = (ids, commit = false) => { if (!commit) { return this.set((state) => { - const index = state.trash.findIndex((item) => item.id === id); - if (index > -1) state.trash.splice(index, 1); + for (let id of ids) { + const index = state.trash.findIndex((item) => item.id === id); + if (index > -1) state.trash.splice(index, 1); + } }); } - return db.trash.delete(id); + return db.trash.delete(...ids); }; - restore = (id) => { - return db.trash.restore(id).then(() => { + restore = (ids) => { + return db.trash.restore(...ids).then(() => { this.refresh(); appStore.refreshNavItems(); notestore.refresh(); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..6e2a0f8c0 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "downlevelIteration": true, + "maxNodeModuleJsDepth": 1, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +}