From b3fe79b0a734268e46424dafa83d707944652407 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Wed, 15 Nov 2023 15:11:51 +0500 Subject: [PATCH] web: fix note editor properties --- apps/web/package-lock.json | 39 +- apps/web/package.json | 2 +- apps/web/src/common/attachments.ts | 30 +- apps/web/src/components/attachment/index.tsx | 110 ++--- .../src/components/cached-router/index.tsx | 3 +- apps/web/src/components/editor/index.tsx | 7 +- apps/web/src/components/editor/types.ts | 7 + .../src/components/list-container/index.tsx | 128 ++---- .../list-container/list-profiles.tsx | 217 +-------- .../list-container/resolved-item.tsx | 174 +++++++ apps/web/src/components/note/index.tsx | 4 +- apps/web/src/components/properties/index.jsx | 426 ----------------- apps/web/src/components/properties/index.tsx | 430 ++++++++++++++++++ apps/web/src/components/reminder/index.tsx | 8 +- .../web/src/components/session-item/index.tsx | 109 +++++ .../src/components/theme-preview/index.tsx | 1 - .../src/components/virtualized-grid/index.tsx | 121 +++++ .../src/components/virtualized-list/index.tsx | 96 ++++ .../components/virtualized-table/index.tsx | 106 +++++ apps/web/src/dialogs/add-reminder-dialog.tsx | 31 +- apps/web/src/dialogs/attachments-dialog.tsx | 356 +++++++-------- .../settings/components/themes-selector.tsx | 46 +- apps/web/src/dialogs/settings/index.tsx | 2 + .../src/dialogs/settings/profile-settings.ts | 2 +- apps/web/src/stores/editor-store.ts | 4 +- apps/web/src/utils/compressor.ts | 13 +- packages/core/src/api/vault.ts | 15 +- packages/core/src/collections/attachments.ts | 8 + packages/core/src/collections/note-history.ts | 42 +- .../core/src/collections/session-content.ts | 4 +- packages/core/src/collections/settings.ts | 2 +- packages/core/src/database/backup.ts | 40 +- packages/core/src/database/migrations.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/migrations.ts | 19 +- packages/core/src/types.ts | 5 + 36 files changed, 1516 insertions(+), 1094 deletions(-) create mode 100644 apps/web/src/components/list-container/resolved-item.tsx delete mode 100644 apps/web/src/components/properties/index.jsx create mode 100644 apps/web/src/components/properties/index.tsx create mode 100644 apps/web/src/components/session-item/index.tsx create mode 100644 apps/web/src/components/virtualized-grid/index.tsx create mode 100644 apps/web/src/components/virtualized-list/index.tsx create mode 100644 apps/web/src/components/virtualized-table/index.tsx diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 975b25723..be8b28e9f 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -33,6 +33,7 @@ "@react-pdf-viewer/core": "^3.12.0", "@react-pdf-viewer/toolbar": "^3.12.0", "@tanstack/react-query": "^4.29.19", + "@tanstack/react-virtual": "^3.0.0-beta.68", "@theme-ui/color": "^0.14.7", "@theme-ui/components": "^0.14.7", "@theme-ui/core": "^0.14.7", @@ -68,7 +69,6 @@ "react-modal": "3.13.1", "react-qrcode-logo": "^2.2.1", "react-scroll-sync": "^0.9.0", - "react-virtuoso": "^4.4.2", "timeago.js": "4.0.2", "tinycolor2": "^1.6.0", "w3c-keyname": "^2.2.6", @@ -24335,6 +24335,7 @@ "async-mutex": "^0.3.2", "dayjs": "1.11.9", "entities": "^4.3.1", + "fuzzyjs": "^5.0.1", "html-to-text": "^9.0.5", "htmlparser2": "^8.0.1", "katex": "0.16.2", @@ -41227,6 +41228,31 @@ } } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.68.tgz", + "integrity": "sha512-YEFNCf+N3ZlNou2r4qnh+GscMe24foYEjTL05RS0ZHvah2RoUDPGuhnuedTv0z66dO2vIq6+Bl4TXatht5T7GQ==", + "dependencies": { + "@tanstack/virtual-core": "3.0.0-beta.68" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.68.tgz", + "integrity": "sha512-CnvsEJWK7cugigckt13AeY80FMzH+OMdEP0j0bS3/zjs44NiRe49x8FZC6R9suRXGMVMXtUHet0zbTp/Ec9Wfg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@theme-ui/color": { "version": "0.14.7", "license": "MIT", @@ -46869,17 +46895,6 @@ "react-dom": "0.14.x || 15.x || 16.x || 17.x" } }, - "node_modules/react-virtuoso": { - "version": "4.6.2", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16 || >=17 || >= 18", - "react-dom": ">=16 || >=17 || >= 18" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "license": "MIT", diff --git a/apps/web/package.json b/apps/web/package.json index eb1db3af6..21a330e0b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@react-pdf-viewer/core": "^3.12.0", "@react-pdf-viewer/toolbar": "^3.12.0", "@tanstack/react-query": "^4.29.19", + "@tanstack/react-virtual": "^3.0.0-beta.68", "@theme-ui/color": "^0.14.7", "@theme-ui/components": "^0.14.7", "@theme-ui/core": "^0.14.7", @@ -67,7 +68,6 @@ "react-modal": "3.13.1", "react-qrcode-logo": "^2.2.1", "react-scroll-sync": "^0.9.0", - "react-virtuoso": "^4.4.2", "timeago.js": "4.0.2", "tinycolor2": "^1.6.0", "w3c-keyname": "^2.2.6", diff --git a/apps/web/src/common/attachments.ts b/apps/web/src/common/attachments.ts index 650859dd8..abae11871 100644 --- a/apps/web/src/common/attachments.ts +++ b/apps/web/src/common/attachments.ts @@ -45,12 +45,12 @@ export async function saveAttachment(hash: string) { if (!response) return; const { attachment, key } = response; - await lazify(import("../interfaces/fs"), ({ default: FS }) => - FS.saveFile(attachment.metadata.hash, { + await lazify(import("../interfaces/fs"), ({ saveFile }) => + saveFile(attachment.metadata.hash, { key, iv: attachment.iv, - name: attachment.metadata.filename, - type: attachment.metadata.type, + name: attachment.filename, + type: attachment.mimeType, isUploaded: !!attachment.dateUploaded }) ); @@ -76,12 +76,12 @@ export async function downloadAttachment< if (type === "base64" || type === "text") return (await db.attachments.read(hash, type)) as TOutputType; - const blob = await lazify(import("../interfaces/fs"), ({ default: FS }) => - FS.decryptFile(attachment.metadata.hash, { + const blob = await lazify(import("../interfaces/fs"), ({ decryptFile }) => + decryptFile(attachment.metadata.hash, { key, iv: attachment.iv, - name: attachment.metadata.filename, - type: attachment.metadata.type, + name: attachment.filename, + type: attachment.mimeType, isUploaded: !!attachment.dateUploaded }) ); @@ -95,8 +95,9 @@ export async function checkAttachment(hash: string) { if (!attachment) return { failed: "Attachment not found." }; try { - const size = await lazify(import("../interfaces/fs"), ({ default: FS }) => - FS.getUploadedFileSize(hash) + const size = await lazify( + import("../interfaces/fs"), + ({ getUploadedFileSize }) => getUploadedFileSize(hash) ); if (size <= 0) return { failed: "File length is 0." }; } catch (e) { @@ -104,12 +105,3 @@ export async function checkAttachment(hash: string) { } return { success: true }; } - -const ABYTES = 17; -export function getTotalSize(attachments: Attachment[]) { - let size = 0; - for (const attachment of attachments) { - size += attachment.length + ABYTES; - } - return size; -} diff --git a/apps/web/src/components/attachment/index.tsx b/apps/web/src/components/attachment/index.tsx index 526af57b9..40801afd8 100644 --- a/apps/web/src/components/attachment/index.tsx +++ b/apps/web/src/components/attachment/index.tsx @@ -33,18 +33,16 @@ import { FileWebClip, Icon, Loading, - References, Rename, Reupload, Uploading } from "../icons"; -import { showToast } from "../../utils/toast"; import { hashNavigate } from "../../navigation"; import { closeOpenedDialog, showPromptDialog } from "../../common/dialog-controller"; -import { store } from "../../stores/attachment-store"; +import { store, useStore } from "../../stores/attachment-store"; import { db } from "../../common/db"; import { saveAttachment } from "../../common/attachments"; import { reuploadAttachment } from "../editor/picker"; @@ -55,11 +53,11 @@ import { WebClipMimeType, PDFMimeType } from "@notesnook/core/dist/utils/filename"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { AppEventManager, AppEvents } from "../../common/app-events"; import { getFormattedDate } from "@notesnook/common"; import { MenuItem } from "@notesnook/ui"; -import { Attachment } from "@notesnook/core"; +import { Attachment as AttachmentType } from "@notesnook/core"; const FILE_ICONS: Record = { "image/": FileImage, @@ -86,24 +84,29 @@ type AttachmentProgressStatus = { }; type AttachmentProps = { - attachment: Attachment; + item: AttachmentType; isSelected?: boolean; onSelected?: () => void; compact?: boolean; + style?: React.CSSProperties; + rowRef?: React.Ref; }; export function Attachment({ - attachment, + item, isSelected, onSelected, - compact + compact, + rowRef, + style }: AttachmentProps) { const [status, setStatus] = useState(); + const processing = useStore((store) => store.processing[item.hash]); useEffect(() => { const event = AppEventManager.subscribe( AppEvents.UPDATE_ATTACHMENT_PROGRESS, (progress: any) => { - if (progress.hash === attachment.metadata.hash) { + if (progress.hash === item.hash) { const percent = Math.round((progress.loaded / progress.total) * 100); setStatus( percent < 100 @@ -120,18 +123,20 @@ export function Attachment({ return () => { event.unsubscribe(); }; - }, [attachment.metadata.hash]); + }, [item.hash]); - const FileIcon = getFileIcon(attachment.metadata.type); + const FileIcon = getFileIcon(item.type); return ( { e.preventDefault(); - Menu.openMenu(AttachmentMenuItems(attachment, status)); + Menu.openMenu(AttachmentMenuItems(item, status)); }} onClick={onSelected} + style={style} + ref={rowRef} > {!compact && ( @@ -161,7 +166,7 @@ export function Attachment({ ) : ( ) - ) : attachment.failed ? ( + ) : processing?.failed || item.failed ? ( - ) : attachment.working ? ( + ) : processing?.working ? ( ) : ( @@ -185,14 +190,12 @@ export function Attachment({ textOverflow: "ellipsis" }} > - {attachment.metadata.filename} + {item.filename} - {attachment.isDeleting ? ( - - ) : attachment.dateUploaded ? ( + {item.dateUploaded ? ( ) : ( - formatBytes(attachment.length, compact ? 1 : 2) + formatBytes(item.size, compact ? 1 : 2) )} {!compact && ( - {attachment.dateUploaded - ? getFormattedDate(attachment.dateUploaded, "date") + {item.dateUploaded + ? getFormattedDate(item.dateUploaded, "date") : "-"} )} @@ -232,37 +235,28 @@ export function Attachment({ } const AttachmentMenuItems: ( - attachment: any, + attachment: AttachmentType, status?: AttachmentProgressStatus ) => MenuItem[] = (attachment, status) => { return [ { - type: "button", key: "notes", - title: "Notes", - icon: References.path, - menu: { - items: (attachment.noteIds as string[]).reduce((prev, curr) => { - const note = db.notes.note(curr); - if (!note) - prev.push({ - type: "button", - key: curr, - title: `Note with id ${curr}`, - onClick: () => showToast("error", "This note does not exist.") - }); - else - prev.push({ - type: "button", - key: note.id, - title: note.title, - onClick: () => { - hashNavigate(`/notes/${curr}/edit`); - closeOpenedDialog(); - } - }); - return prev; - }, [] as MenuItem[]) + type: "lazy-loader", + async items() { + const menuItems: MenuItem[] = []; + for await (const note of db.relations.from(attachment, "note") + .selector) { + menuItems.push({ + type: "button", + key: note.id, + title: note.title, + onClick: () => { + hashNavigate(`/notes/${note.id}/edit`); + closeOpenedDialog(); + } + }); + } + return menuItems; } }, { @@ -272,7 +266,7 @@ const AttachmentMenuItems: ( icon: DoubleCheckmark.path, isDisabled: !attachment.dateUploaded, onClick: async () => { - await store.recheck([attachment.metadata.hash]); + await store.recheck([attachment.id]); } }, { @@ -283,11 +277,11 @@ const AttachmentMenuItems: ( onClick: async () => { const newName = await showPromptDialog({ title: "Rename attachment", - description: attachment.metadata.filename, - defaultValue: attachment.metadata.filename + description: attachment.filename, + defaultValue: attachment.filename }); if (!newName) return; - await store.rename(attachment.metadata.hash, newName); + await store.rename(attachment.hash, newName); } }, { @@ -298,8 +292,8 @@ const AttachmentMenuItems: ( onClick: async () => { const isDownloading = status?.type === "download"; if (isDownloading) { - await db.fs().cancel(attachment.metadata.hash, "download"); - } else await saveAttachment(attachment.metadata.hash); + await db.fs().cancel(attachment.hash, "download"); + } else await saveAttachment(attachment.hash); } }, { @@ -310,12 +304,8 @@ const AttachmentMenuItems: ( onClick: async () => { const isDownloading = status?.type === "upload"; if (isDownloading) { - await db.fs().cancel(attachment.metadata.hash, "upload"); - } else - await reuploadAttachment( - attachment.metadata.type, - attachment.metadata.hash - ); + await db.fs().cancel(attachment.hash, "upload"); + } else await reuploadAttachment(attachment.type, attachment.hash); } }, { @@ -324,7 +314,7 @@ const AttachmentMenuItems: ( variant: "dangerous", title: "Delete permanently", icon: DeleteForver.path, - onClick: () => Multiselect.deleteAttachments([attachment]) + onClick: () => Multiselect.deleteAttachments([attachment.id]) } ]; }; diff --git a/apps/web/src/components/cached-router/index.tsx b/apps/web/src/components/cached-router/index.tsx index 08be7ea10..542eb5f11 100644 --- a/apps/web/src/components/cached-router/index.tsx +++ b/apps/web/src/components/cached-router/index.tsx @@ -56,7 +56,8 @@ function CachedRouter() { sx={{ display: key === RouteResult.key ? "flex" : "none", flexDirection: "column", - flex: 1 + flex: 1, + overflow: "hidden" }} > diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index f916e8156..31d1f2326 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -62,15 +62,10 @@ import { isDeleted } from "@notesnook/core/dist/types"; import { isEncryptedContent } from "@notesnook/core/dist/collections/content"; +import { PreviewSession } from "./types"; const PDFPreview = React.lazy(() => import("../pdf-preview")); -type PreviewSession = { - content: { data: string; type: ContentType }; - dateCreated: number; - dateEdited: number; -}; - type DocumentPreview = { url?: string; hash: string; diff --git a/apps/web/src/components/editor/types.ts b/apps/web/src/components/editor/types.ts index 9a07d3dae..3ec761857 100644 --- a/apps/web/src/components/editor/types.ts +++ b/apps/web/src/components/editor/types.ts @@ -18,6 +18,7 @@ along with this program. If not, see . */ import { Attachment } from "@notesnook/editor"; +import { ContentType } from "@notesnook/core"; export type NoteStatistics = { words: { @@ -37,3 +38,9 @@ export interface IEditor { attachFile: (file: Attachment) => void; sendAttachmentProgress: (hash: string, progress: number) => void; } + +export type PreviewSession = { + content: { data: string; type: ContentType }; + dateCreated: number; + dateEdited: number; +}; diff --git a/apps/web/src/components/list-container/index.tsx b/apps/web/src/components/list-container/index.tsx index 517aa0bf7..dcd0e0274 100644 --- a/apps/web/src/components/list-container/index.tsx +++ b/apps/web/src/components/list-container/index.tsx @@ -17,15 +17,9 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { forwardRef, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Flex, Button } from "@theme-ui/components"; import { Plus } from "../icons"; -import { - ItemProps, - ScrollerProps, - Virtuoso, - VirtuosoHandle -} from "react-virtuoso"; import { useStore as useSelectionStore, store as selectionStore @@ -34,31 +28,17 @@ import GroupHeader from "../group-header"; import { DEFAULT_ITEM_HEIGHT, ListItemWrapper } from "./list-profiles"; import Announcements from "../announcements"; import { ListLoader } from "../loaders/list-loader"; -import ScrollContainer from "../scroll-container"; +import { FlexScrollContainer } from "../scroll-container"; import { useKeyboardListNavigation } from "../../hooks/use-keyboard-list-navigation"; import { Context } from "./types"; import { VirtualizedGrouping, - GroupHeader as GroupHeaderType, GroupingKey, Item, isGroupHeader } from "@notesnook/core"; - -export const CustomScrollbarsVirtualList = forwardRef< - HTMLDivElement, - ScrollerProps ->(function CustomScrollbarsVirtualList(props, ref) { - return ( - { - if (typeof ref === "function") ref(sRef); - else if (ref) ref.current = sRef; - }} - /> - ); -}); +import { VirtualizedList } from "../virtualized-list"; +import { Virtualizer } from "@tanstack/react-virtual"; type ListContainerProps = { group?: GroupingKey; @@ -87,8 +67,7 @@ function ListContainer(props: ListContainerProps) { (store) => store.toggleSelectionMode ); - const listRef = useRef(null); - const listContainerRef = useRef(null); + const listRef = useRef>(); useEffect(() => { return () => { @@ -125,7 +104,7 @@ function ListContainer(props: ListContainerProps) { }); return ( - + {!props.items.ids.length && props.placeholder ? ( <> {header} @@ -139,29 +118,25 @@ function ListContainer(props: ListContainerProps) { ) : ( <> - - items.getKey(index)} - defaultItemHeight={DEFAULT_ITEM_HEIGHT} - totalCount={items.ids.length} + {header ? header : } + items.getKey(index)} + items={items.ids} + mode="dynamic" + tabIndex={-1} onBlur={() => setFocusedGroupIndex(-1)} onKeyDown={(e) => onKeyDown(e.nativeEvent)} - components={{ - Scroller: CustomScrollbarsVirtualList, - Item: VirtuosoItem, - Header: () => (header ? header : ) - }} - context={{ - onMouseDown, - onFocus - }} - itemContent={(index, item) => { + itemWrapperProps={(_, index) => ({ + onFocus: () => onFocus(index), + onMouseDown: (e) => onMouseDown(e.nativeEvent, index) + })} + renderItem={({ index, item }) => { if (isGroupHeader(item)) { if (!group) return null; return ( @@ -197,8 +172,7 @@ function ListContainer(props: ListContainerProps) { (v) => isGroupHeader(v) && v.title === title ); if (index < 0) return; - listRef.current?.scrollToIndex({ - index, + listRef.current?.scrollToIndex(index, { align: "center", behavior: "auto" }); @@ -220,7 +194,7 @@ function ListContainer(props: ListContainerProps) { ); }} /> - + )} {button && ( @@ -250,29 +224,6 @@ function ListContainer(props: ListContainerProps) { } export default ListContainer; -function VirtuosoItem({ - item: _item, - context, - ...props -}: ItemProps & { - context?: { - onMouseDown: (e: MouseEvent, itemIndex: number) => void; - onFocus: (itemIndex: number) => void; - }; -}) { - return ( -
context?.onFocus(props["data-item-index"])} - onMouseDown={(e) => - context?.onMouseDown(e.nativeEvent, props["data-item-index"]) - } - > - {props.children} -
- ); -} - /** * Scroll the element at the specified index into view and * wait until it renders into the DOM. This function keeps @@ -281,29 +232,28 @@ function VirtuosoItem({ * 50ms interval. */ function waitForElement( - list: VirtuosoHandle, + list: Virtualizer, index: number, elementId: string, callback: (element: HTMLElement) => void ) { let waitInterval = 0; let maxAttempts = 3; - list.scrollIntoView({ - index, - done: function scrollDone() { - if (!maxAttempts) return; - clearTimeout(waitInterval); + list.scrollToIndex(index); + function scrollDone() { + if (!maxAttempts) return; + clearTimeout(waitInterval); - const element = document.getElementById(elementId); - if (!element) { - --maxAttempts; - waitInterval = setTimeout(() => { - scrollDone(); - }, 50) as unknown as number; - return; - } - - callback(element); + const element = document.getElementById(elementId); + if (!element) { + --maxAttempts; + waitInterval = setTimeout(() => { + scrollDone(); + }, 50) as unknown as number; + return; } - }); + + callback(element); + } + scrollDone(); } diff --git a/apps/web/src/components/list-container/list-profiles.tsx b/apps/web/src/components/list-container/list-profiles.tsx index 72efe3fef..50ee675d2 100644 --- a/apps/web/src/components/list-container/list-profiles.tsx +++ b/apps/web/src/components/list-container/list-profiles.tsx @@ -23,121 +23,70 @@ import Tag from "../tag"; import TrashItem from "../trash-item"; import { db } from "../../common/db"; import Reminder from "../reminder"; -import { - Context, - TagsWithDateEdited, - WithDateEdited, - NotebooksWithDateEdited -} from "./types"; +import { Context } from "./types"; import { getSortValue } from "@notesnook/core/dist/utils/grouping"; -import { - GroupingKey, - Item, - VirtualizedGrouping, - Color, - Reminder as ReminderItem -} from "@notesnook/core"; -import { useEffect, useRef, useState } from "react"; -import SubNotebook from "../sub-notebook"; +import { GroupingKey, Item, VirtualizedGrouping } from "@notesnook/core"; +import { Attachment } from "../attachment"; +import { isNoteResolvedData, useResolvedItem } from "./resolved-item"; const SINGLE_LINE_HEIGHT = 1.4; const DEFAULT_LINE_HEIGHT = (document.getElementById("p")?.clientHeight || 16) - 1; -export const DEFAULT_ITEM_HEIGHT = SINGLE_LINE_HEIGHT * 2 * DEFAULT_LINE_HEIGHT; +export const DEFAULT_ITEM_HEIGHT = SINGLE_LINE_HEIGHT * 4 * DEFAULT_LINE_HEIGHT; -type ListItemWrapperProps = { +type ListItemWrapperProps = { group?: GroupingKey; - items: VirtualizedGrouping; + items: VirtualizedGrouping; id: string; context?: Context; compact?: boolean; + simplified?: boolean; }; export function ListItemWrapper(props: ListItemWrapperProps) { - const { id, items, group, compact, context } = props; - const [item, setItem] = useState(); - const tags = useRef(); - const notebooks = useRef(); - const reminder = useRef(); - const color = useRef(); - const totalNotes = useRef(0); + const { group, compact, context, simplified } = props; - useEffect(() => { - (async function () { - const { item, data } = (await items.item(id, resolveItems)) || {}; - if (!item) return; - if (item.type === "note" && isNoteResolvedData(data)) { - tags.current = data.tags; - notebooks.current = data.notebooks; - reminder.current = data.reminder; - color.current = data.color; - } else if (item.type === "notebook" && typeof data === "number") { - totalNotes.current = data; - } else if (item.type === "tag" && typeof data === "number") { - totalNotes.current = data; - } - setItem(item); - })(); - }, [id, items]); - - if (!item) + const resolvedItem = useResolvedItem(props); + if (!resolvedItem) return
; - const { type } = item; - switch (type) { + const { data, item } = resolvedItem; + switch (item.type) { case "note": { return ( ); } case "notebook": - if (context?.type === "notebook") - return ( - - ); - return ( ); case "trash": - return ; + return ; case "reminder": - return ; + return ; case "tag": - return ; + return ( + + ); + case "attachment": + return ; default: return null; } } -function withDateEdited< - T extends { dateEdited: number } | { dateModified: number } ->(items: T[]): WithDateEdited { - let latestDateEdited = 0; - items.forEach((item) => { - const date = "dateEdited" in item ? item.dateEdited : item.dateModified; - if (latestDateEdited < date) latestDateEdited = date; - }); - return { dateEdited: latestDateEdited, items }; -} - function getDate(item: Item, groupType?: GroupingKey): number { return ( getSortValue( @@ -152,123 +101,3 @@ function getDate(item: Item, groupType?: GroupingKey): number { ) || 0 ); } - -export async function resolveItems(ids: string[], items: Record) { - const { type } = items[ids[0]]; - if (type === "note") return resolveNotes(ids); - else if (type === "notebook") { - const data: Record = {}; - for (const id of ids) data[id] = await db.notebooks.totalNotes(id); - return data; - } else if (type === "tag") { - const data: Record = {}; - for (const id of ids) - data[id] = await db.relations.from({ id, type: "tag" }, "note").count(); - return data; - } - return {}; -} - -type NoteResolvedData = { - notebooks?: NotebooksWithDateEdited; - reminder?: ReminderItem; - color?: Color; - tags?: TagsWithDateEdited; -}; -async function resolveNotes(ids: string[]) { - console.time("relations"); - const relations = [ - ...(await db.relations - .to({ type: "note", ids }, ["notebook", "tag", "color"]) - .get()), - ...(await db.relations.from({ type: "note", ids }, "reminder").get()) - ]; - console.timeEnd("relations"); - console.log( - relations, - ids, - await db.relations - .from({ type: "notebook", id: "6549b4c373c7f3a40852f80c" }, "note") - .get() - ); - const relationIds: { - notebooks: Set; - colors: Set; - tags: Set; - reminders: Set; - } = { - colors: new Set(), - notebooks: new Set(), - tags: new Set(), - reminders: new Set() - }; - - const grouped: Record< - string, - { - notebooks: string[]; - color?: string; - tags: string[]; - reminder?: string; - } - > = {}; - for (const relation of relations) { - const noteId = - relation.toType === "relation" ? relation.fromId : relation.toId; - const data = grouped[noteId] || { - notebooks: [], - tags: [] - }; - - if (relation.toType === "relation" && !data.reminder) { - data.reminder = relation.fromId; - relationIds.reminders.add(relation.fromId); - } else if (relation.fromType === "notebook" && data.notebooks.length < 2) { - data.notebooks.push(relation.fromId); - relationIds.notebooks.add(relation.fromId); - } else if (relation.fromType === "tag" && data.tags.length < 3) { - data.tags.push(relation.fromId); - relationIds.tags.add(relation.fromId); - } else if (relation.fromType === "color" && !data.color) { - data.color = relation.fromId; - relationIds.colors.add(relation.fromId); - } - grouped[relation.toId] = data; - } - - console.time("resolve"); - const resolved = { - notebooks: await db.notebooks.all.records( - Array.from(relationIds.notebooks) - ), - tags: await db.tags.all.records(Array.from(relationIds.tags)), - colors: await db.colors.all.records(Array.from(relationIds.colors)), - reminders: await db.reminders.all.records(Array.from(relationIds.reminders)) - }; - console.timeEnd("resolve"); - - const data: Record = {}; - for (const noteId in grouped) { - const group = grouped[noteId]; - data[noteId] = { - color: group.color ? resolved.colors[group.color] : undefined, - reminder: group.reminder ? resolved.reminders[group.reminder] : undefined, - tags: withDateEdited(group.tags.map((id) => resolved.tags[id])), - notebooks: withDateEdited( - group.notebooks.map((id) => resolved.notebooks[id]) - ) - }; - } - return data; -} - -function isNoteResolvedData(data: unknown): data is NoteResolvedData { - return ( - typeof data === "object" && - !!data && - "notebooks" in data && - "reminder" in data && - "color" in data && - "tags" in data - ); -} diff --git a/apps/web/src/components/list-container/resolved-item.tsx b/apps/web/src/components/list-container/resolved-item.tsx new file mode 100644 index 000000000..c59baf202 --- /dev/null +++ b/apps/web/src/components/list-container/resolved-item.tsx @@ -0,0 +1,174 @@ +/* +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 { Color, Item, Reminder, VirtualizedGrouping } from "@notesnook/core"; +import usePromise from "../../hooks/use-promise"; +import { + NotebooksWithDateEdited, + TagsWithDateEdited, + WithDateEdited +} from "./types"; +import { db } from "../../common/db"; +import React from "react"; + +type ResolvedItemProps = { + items: VirtualizedGrouping; + id: string; + children: (item: { item: Item; data: unknown }) => React.ReactNode; +}; +export function ResolvedItem(props: ResolvedItemProps) { + const { id, items, children } = props; + const result = usePromise(() => items.item(id, resolveItems), [id, items]); + + if (result.status !== "fulfilled" || !result.value) return null; + + return <>{children(result.value)}; +} + +export function useResolvedItem(props: Omit) { + const { id, items } = props; + const result = usePromise(() => items.item(id, resolveItems), [id, items]); + + if (result.status !== "fulfilled" || !result.value) return null; + return result.value; +} + +function withDateEdited< + T extends { dateEdited: number } | { dateModified: number } +>(items: T[]): WithDateEdited { + let latestDateEdited = 0; + items.forEach((item) => { + const date = "dateEdited" in item ? item.dateEdited : item.dateModified; + if (latestDateEdited < date) latestDateEdited = date; + }); + return { dateEdited: latestDateEdited, items }; +} + +export async function resolveItems(ids: string[], items: Record) { + const { type } = items[ids[0]]; + if (type === "note") return resolveNotes(ids); + else if (type === "notebook") { + const data: Record = {}; + for (const id of ids) data[id] = await db.notebooks.totalNotes(id); + return data; + } else if (type === "tag") { + const data: Record = {}; + for (const id of ids) + data[id] = await db.relations.from({ id, type: "tag" }, "note").count(); + return data; + } + return {}; +} + +type NoteResolvedData = { + notebooks?: NotebooksWithDateEdited; + reminder?: Reminder; + color?: Color; + tags?: TagsWithDateEdited; +}; +async function resolveNotes(ids: string[]) { + console.time("relations"); + const relations = [ + ...(await db.relations + .to({ type: "note", ids }, ["notebook", "tag", "color"]) + .get()), + ...(await db.relations.from({ type: "note", ids }, "reminder").get()) + ]; + console.timeEnd("relations"); + const relationIds: { + notebooks: Set; + colors: Set; + tags: Set; + reminders: Set; + } = { + colors: new Set(), + notebooks: new Set(), + tags: new Set(), + reminders: new Set() + }; + + const grouped: Record< + string, + { + notebooks: string[]; + color?: string; + tags: string[]; + reminder?: string; + } + > = {}; + for (const relation of relations) { + const noteId = + relation.toType === "reminder" ? relation.fromId : relation.toId; + const data = grouped[noteId] || { + notebooks: [], + tags: [] + }; + + if (relation.toType === "reminder" && !data.reminder) { + data.reminder = relation.toId; + relationIds.reminders.add(relation.toId); + } else if (relation.fromType === "notebook" && data.notebooks.length < 2) { + data.notebooks.push(relation.fromId); + relationIds.notebooks.add(relation.fromId); + } else if (relation.fromType === "tag" && data.tags.length < 3) { + data.tags.push(relation.fromId); + relationIds.tags.add(relation.fromId); + } else if (relation.fromType === "color" && !data.color) { + data.color = relation.fromId; + relationIds.colors.add(relation.fromId); + } + grouped[noteId] = data; + } + + console.time("resolve"); + const resolved = { + notebooks: await db.notebooks.all.records( + Array.from(relationIds.notebooks) + ), + tags: await db.tags.all.records(Array.from(relationIds.tags)), + colors: await db.colors.all.records(Array.from(relationIds.colors)), + reminders: await db.reminders.all.records(Array.from(relationIds.reminders)) + }; + console.timeEnd("resolve"); + + const data: Record = {}; + for (const noteId in grouped) { + const group = grouped[noteId]; + data[noteId] = { + color: group.color ? resolved.colors[group.color] : undefined, + reminder: group.reminder ? resolved.reminders[group.reminder] : undefined, + tags: withDateEdited(group.tags.map((id) => resolved.tags[id])), + notebooks: withDateEdited( + group.notebooks.map((id) => resolved.notebooks[id]) + ) + }; + } + return data; +} + +export function isNoteResolvedData(data: unknown): data is NoteResolvedData { + return ( + typeof data === "object" && + !!data && + "notebooks" in data && + "reminder" in data && + "color" in data && + "tags" in data + ); +} diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index 4ac92501e..ab4daf664 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -183,7 +183,7 @@ function Note(props: NoteProps) { icon={Notebook} /> ))} - {reminder && isReminderActive(reminder) && ( + {reminder && isReminderActive(reminder) ? ( - )} + ) : null} } footer={ diff --git a/apps/web/src/components/properties/index.jsx b/apps/web/src/components/properties/index.jsx deleted file mode 100644 index 6128c9d08..000000000 --- a/apps/web/src/components/properties/index.jsx +++ /dev/null @@ -1,426 +0,0 @@ -/* -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 React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - Pin, - StarOutline, - Unlock, - Readonly, - SyncOff, - ArrowLeft, - Circle, - Checkmark, - Lock -} from "../icons"; -import { Flex, Text } from "@theme-ui/components"; -import { useStore, store } from "../../stores/editor-store"; -import { COLORS } from "../../common/constants"; -import { db } from "../../common/db"; -import { useStore as useAppStore } from "../../stores/app-store"; -import { useStore as useAttachmentStore } from "../../stores/attachment-store"; -import { store as noteStore } from "../../stores/note-store"; -import { AnimatedFlex } from "../animated"; -import Toggle from "./toggle"; -import ScrollContainer from "../scroll-container"; -import Vault from "../../common/vault"; -import TimeAgo from "../time-ago"; -import { Attachment } from "../attachment"; -import { formatBytes } from "@notesnook/common"; -import { getTotalSize } from "../../common/attachments"; -import Notebook from "../notebook"; -import Reminder from "../reminder"; -import { getFormattedDate } from "@notesnook/common"; -import { ScopedThemeProvider } from "../theme-provider"; - -const tools = [ - { key: "pin", property: "pinned", icon: Pin, label: "Pin" }, - { - key: "favorite", - property: "favorite", - icon: StarOutline, - label: "Favorite" - }, - { key: "lock", icon: Unlock, label: "Lock", property: "locked" }, - { - key: "readonly", - icon: Readonly, - label: "Readonly", - property: "readonly" - }, - { - key: "local-only", - icon: SyncOff, - label: "Disable sync", - property: "localOnly" - } -]; - -const metadataItems = [ - { - key: "dateCreated", - label: "Created at", - value: (date) => getFormattedDate(date || Date.now()) - }, - { - key: "dateEdited", - label: "Last edited at", - value: (date) => (date ? getFormattedDate(date) : "never") - } -]; - -function Properties(props) { - const { onOpenPreviewSession } = props; - - const [versionHistory, setVersionHistory] = useState([]); - - const toggleProperties = useStore((store) => store.toggleProperties); - const isFocusMode = useAppStore((store) => store.isFocusMode); - const session = useStore((store) => store.session); - const attachments = useAttachmentStore((store) => - store.attachments.filter((a) => a.noteIds.includes(session.id)) - ); - const { - id: sessionId, - color, - notebooks = [], - sessionType, - dateCreated - } = session; - const isPreviewMode = sessionType === "preview"; - const reminders = db.relations - .from({ id: session.id, type: "note" }, "reminder") - .resolved(); - const allNotebooks = useMemo( - () => - [ - ...notebooks.map((ref) => db.notebooks.notebook(ref.id)?.data), - ...db.relations.to({ id: sessionId, type: "note" }, "notebook") - ].filter(Boolean), - [sessionId, notebooks] - ); - - const changeState = useCallback( - function changeState(prop) { - switch (prop) { - case "lock": - return store.get().session.locked - ? noteStore.unlock(sessionId) - : noteStore.lock(sessionId); - case "readonly": - return noteStore.readonly(sessionId); - case "local-only": - return noteStore.localOnly(sessionId); - case "pin": - return noteStore.pin(sessionId); - case "favorite": - return noteStore.favorite(sessionId); - default: - return; - } - }, - [sessionId] - ); - - useEffect(() => { - if (!sessionId) return; - - (async () => { - const history = await db.noteHistory.get(sessionId); - setVersionHistory(history); - })(); - }, [sessionId]); - - if (isFocusMode || !sessionId) return null; - return ( - - - - toggleProperties(false)} - size={18} - sx={{ mr: 1, cursor: "pointer" }} - /> - } - > - {!isPreviewMode && ( - <> - {tools.map((tool) => ( - changeState(tool.key, state)} - testId={`properties-${tool.key}`} - /> - ))} - - )} - {metadataItems.map((item) => ( - - - {item.label} - - - {item.value(session[item.key])} - - - ))} - {!isPreviewMode && ( - <> - - {COLORS.map((label) => ( - noteStore.get().setColor(sessionId, label)} - sx={{ - cursor: "pointer", - position: "relative", - alignItems: "center", - justifyContent: "space-between" - }} - data-test-id={`properties-${label}`} - > - - {label.toLowerCase() === color?.toLowerCase() && ( - - )} - - ))} - - - )} - - {allNotebooks?.length > 0 && ( - - {allNotebooks.map((notebook) => ( - - ))} - - )} - {reminders.length > 0 && ( - - {reminders.map((reminder) => { - return ( - - ); - })} - - )} - - {attachments.length > 0 && ( - - - - {attachments.map((attachment, i) => ( - - ))} - -
-
- )} - - {versionHistory.map((session) => { - const fromDate = getFormattedDate(session.dateCreated, "date"); - const toDate = getFormattedDate(session.dateModified, "date"); - const fromTime = getFormattedDate(session.dateCreated, "time"); - const toTime = getFormattedDate(session.dateModified, "time"); - const label = `${fromDate}, ${fromTime} — ${ - fromDate !== toDate ? `${toDate}, ` : "" - }${toTime}`; - const isSelected = - isPreviewMode && session.dateCreated === dateCreated; - - return ( - { - toggleProperties(false); - const content = await db.noteHistory.content(session.id); - - if (session.locked) { - await Vault.askPassword(async (password) => { - try { - const decryptedContent = - await db.vault.decryptContent(content, password); - onOpenPreviewSession({ - content: decryptedContent, - dateCreated: session.dateCreated, - dateEdited: session.dateModified - }); - return true; - } catch (e) { - return false; - } - }); - } else { - onOpenPreviewSession({ - content, - dateCreated: session.dateCreated, - dateEdited: session.dateModified - }); - } - }} - > - - {label} - - - {session.locked && } - - - - ); - })} - -
-
-
- ); -} -export default React.memo(Properties); - -function Card({ title, subtitle, button, children }) { - return ( - - - {button} - {title} - - {subtitle && ( - - {subtitle} - - )} - {children} - - ); -} diff --git a/apps/web/src/components/properties/index.tsx b/apps/web/src/components/properties/index.tsx new file mode 100644 index 000000000..3774482e2 --- /dev/null +++ b/apps/web/src/components/properties/index.tsx @@ -0,0 +1,430 @@ +/* +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 React, { PropsWithChildren } from "react"; +import { + Pin, + StarOutline, + Unlock, + Readonly, + SyncOff, + ArrowLeft, + Circle, + Checkmark +} from "../icons"; +import { Flex, Text } from "@theme-ui/components"; +import { useStore, store, EditorSession } from "../../stores/editor-store"; +import { db } from "../../common/db"; +import { useStore as useAppStore } from "../../stores/app-store"; +import { store as noteStore } from "../../stores/note-store"; +import { AnimatedFlex } from "../animated"; +import Toggle from "./toggle"; +import ScrollContainer from "../scroll-container"; +import { getFormattedDate } from "@notesnook/common"; +import { ScopedThemeProvider } from "../theme-provider"; +import { PreviewSession } from "../editor/types"; +import usePromise from "../../hooks/use-promise"; +import { ListItemWrapper } from "../list-container/list-profiles"; +import { VirtualizedList } from "../virtualized-list"; +import { ResolvedItem } from "../list-container/resolved-item"; +import { SessionItem } from "../session-item"; +import { COLORS } from "../../common/constants"; +import { DefaultColors } from "@notesnook/core"; + +const tools = [ + { key: "pin", property: "pinned", icon: Pin, label: "Pin" }, + { + key: "favorite", + property: "favorite", + icon: StarOutline, + label: "Favorite" + }, + { key: "lock", icon: Unlock, label: "Lock", property: "locked" }, + { + key: "readonly", + icon: Readonly, + label: "Readonly", + property: "readonly" + }, + { + key: "local-only", + icon: SyncOff, + label: "Disable sync", + property: "localOnly" + } +] as const; + +type MetadataItem = { + key: T; + label: string; + value: (value: EditorSession[T]) => string; +}; + +const metadataItems = [ + { + key: "dateCreated", + label: "Created at", + value: (date) => getFormattedDate(date || Date.now()) + } as MetadataItem<"dateCreated">, + { + key: "dateEdited", + label: "Last edited at", + value: (date) => (date ? getFormattedDate(date) : "never") + } as MetadataItem<"dateEdited"> +]; + +type EditorPropertiesProps = { + onOpenPreviewSession: (session: PreviewSession) => void; +}; +function EditorProperties(props: EditorPropertiesProps) { + const { onOpenPreviewSession } = props; + + const toggleProperties = useStore((store) => store.toggleProperties); + const isFocusMode = useAppStore((store) => store.isFocusMode); + const session = useStore((store) => store.session); + + const { id: sessionId, sessionType, dateCreated } = session; + const isPreviewMode = sessionType === "preview"; + + if (isFocusMode || !sessionId) return null; + return ( + + + +
toggleProperties(false)} + size={18} + sx={{ mr: 1, cursor: "pointer" }} + /> + } + > + {!isPreviewMode && ( + <> + {tools.map((tool) => ( + changeToggleState(tool.key)} + testId={`properties-${tool.key}`} + /> + ))} + + )} + {metadataItems.map((item) => ( + + + {item.label} + + + {item.value(session[item.key])} + + + ))} + {!isPreviewMode && } +
+ + + + +
+
+
+ ); +} +export default React.memo(EditorProperties); + +function Colors({ noteId }: { noteId: string }) { + const result = usePromise(async () => + ( + await db.relations.to({ id: noteId, type: "note" }, "color").resolve(1) + ).at(0) + ); + + return ( + + {COLORS.map((label) => ( + noteStore.get().setColor(label.key, noteId)} + sx={{ + cursor: "pointer", + position: "relative", + alignItems: "center", + justifyContent: "space-between" + }} + data-test-id={`properties-${label}`} + > + + {result.status === "fulfilled" && + label.key === result.value?.colorCode && ( + + )} + + ))} + + ); +} + +function Notebooks({ noteId }: { noteId: string }) { + const result = usePromise(() => + db.relations + .to({ id: noteId, type: "note" }, "notebook") + .selector.sorted(db.settings.getGroupOptions("notebooks")) + ); + + if (result.status !== "fulfilled" || result.value.ids.length <= 0) + return null; + + return ( +
+ result.value.getKey(index)} + items={result.value.ids} + renderItem={(id) => ( + + )} + /> +
+ ); +} +function Reminders({ noteId }: { noteId: string }) { + const result = usePromise(() => + db.relations + .from({ id: noteId, type: "note" }, "reminder") + .selector.sorted(db.settings.getGroupOptions("reminders")) + ); + if (result.status !== "fulfilled" || result.value.ids.length <= 0) + return null; + + return ( +
+ result.value.getKey(index)} + items={result.value.ids} + renderItem={(id) => ( + + )} + /> +
+ ); +} +function Attachments({ noteId }: { noteId: string }) { + const result = usePromise(() => + db.attachments + .ofNote(noteId, "all") + .sorted({ sortBy: "dateCreated", sortDirection: "desc" }) + ); + if (result.status !== "fulfilled" || result.value.ids.length <= 0) + return null; + + return ( +
+ {result.value.ids.map((id, index) => ( + + ))} +
+ ); +} +function SessionHistory({ + noteId, + dateCreated, + isPreviewMode, + onOpenPreviewSession +}: { + noteId: string; + dateCreated: number; + isPreviewMode: boolean; + onOpenPreviewSession: (session: PreviewSession) => void; +}) { + const result = usePromise(() => + db.noteHistory + .get(noteId) + .sorted({ sortBy: "dateModified", sortDirection: "desc" }) + ); + if (result.status !== "fulfilled" || result.value.ids.length <= 0) + return null; + + return ( +
+ result.value.getKey(index)} + items={result.value.ids} + renderItem={(id) => ( + + {({ item }) => + item.type === "session" ? ( + + ) : null + } + + )} + /> +
+ ); +} + +type SectionProps = { title: string; subtitle?: string; button?: JSX.Element }; +function Section({ + title, + subtitle, + button, + children +}: PropsWithChildren) { + return ( + + + {button} + {title} + + {subtitle && ( + + {subtitle} + + )} + {children} + + ); +} + +function changeToggleState( + prop: "lock" | "readonly" | "local-only" | "pin" | "favorite" +) { + const { + id: sessionId, + locked, + readonly, + localOnly, + pinned, + favorite + } = store.get().session; + if (!sessionId) return; + switch (prop) { + case "lock": + return locked ? noteStore.unlock(sessionId) : noteStore.lock(sessionId); + case "readonly": + return noteStore.readonly(!readonly, sessionId); + case "local-only": + return noteStore.localOnly(!localOnly, sessionId); + case "pin": + return noteStore.pin(!pinned, sessionId); + case "favorite": + return noteStore.favorite(!favorite, sessionId); + default: + return; + } +} diff --git a/apps/web/src/components/reminder/index.tsx b/apps/web/src/components/reminder/index.tsx index 0fcbc8106..e8cb08376 100644 --- a/apps/web/src/components/reminder/index.tsx +++ b/apps/web/src/components/reminder/index.tsx @@ -111,10 +111,10 @@ export default React.memo(Reminder, (prev, next) => { return prev?.item?.title === next?.item?.title; }); -const menuItems: ( - reminder: ReminderType, - items?: ReminderType[] -) => MenuItem[] = (reminder, items = []) => { +const menuItems: (reminder: ReminderType, items?: string[]) => MenuItem[] = ( + reminder, + items = [] +) => { return [ { type: "button", diff --git a/apps/web/src/components/session-item/index.tsx b/apps/web/src/components/session-item/index.tsx new file mode 100644 index 000000000..94a7bbc26 --- /dev/null +++ b/apps/web/src/components/session-item/index.tsx @@ -0,0 +1,109 @@ +/* +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 { getFormattedDate } from "@notesnook/common"; +import { HistorySession } from "@notesnook/core"; +import { Flex, Text } from "@theme-ui/components"; +import TimeAgo from "../time-ago"; +import { Lock } from "../icons"; +import Vault from "../../common/vault"; +import { db } from "../../common/db"; +import { PreviewSession } from "../editor/types"; + +type SessionItemProps = { + session: HistorySession; + isPreviewMode: boolean; + noteId: string; + dateCreated: number; + onOpenPreviewSession: (session: PreviewSession) => void; +}; +export function SessionItem(props: SessionItemProps) { + const { session, isPreviewMode, dateCreated, noteId, onOpenPreviewSession } = + props; + const fromDate = getFormattedDate(session.dateCreated, "date"); + const toDate = getFormattedDate(session.dateModified, "date"); + const fromTime = getFormattedDate(session.dateCreated, "time"); + const toTime = getFormattedDate(session.dateModified, "time"); + const label = `${fromDate}, ${fromTime} — ${ + fromDate !== toDate ? `${toDate}, ` : "" + }${toTime}`; + const isSelected = isPreviewMode && session.dateCreated === dateCreated; + + return ( + { + const content = await db.noteHistory.content(session.id); + if (!content) return; + // toggleProperties(false); + if (session.locked) { + await Vault.askPassword(async (password) => { + try { + const decryptedContent = await db.vault.decryptContent( + content, + noteId, + password + ); + onOpenPreviewSession({ + content: decryptedContent, + dateCreated: session.dateCreated, + dateEdited: session.dateModified + }); + return true; + } catch (e) { + return false; + } + }); + } else { + onOpenPreviewSession({ + content, + dateCreated: session.dateCreated, + dateEdited: session.dateModified + }); + } + }} + > + + {label} + + + {session.locked && } + + + + ); +} diff --git a/apps/web/src/components/theme-preview/index.tsx b/apps/web/src/components/theme-preview/index.tsx index 4021f42e3..b60bb6a71 100644 --- a/apps/web/src/components/theme-preview/index.tsx +++ b/apps/web/src/components/theme-preview/index.tsx @@ -66,7 +66,6 @@ export function ThemePreview(props: ThemePreviewProps) { theme.previewColors.background ].map((color) => ( . +*/ + +import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; +import { Box } from "@theme-ui/components"; +import React, { useEffect, useRef } from "react"; + +type VirtualizedGridProps = { + virtualizerRef?: React.MutableRefObject< + Virtualizer | undefined + >; + mode?: "fixed" | "dynamic"; + items: T[]; + estimatedSize: number; + getItemKey: (index: number) => string; + scrollElement?: Element | null; + renderItem: (props: { item: T; index: number }) => JSX.Element | null; + scrollMargin?: number; + columns: number; + onEndReached?: () => void; +}; +export function VirtualizedGrid(props: VirtualizedGridProps) { + const { + items, + getItemKey, + scrollElement, + scrollMargin, + renderItem: Item, + estimatedSize, + mode, + virtualizerRef, + columns, + onEndReached + } = props; + const containerRef = useRef(null); + + const virtualizer = useVirtualizer({ + lanes: columns, + count: items.length, + estimateSize: () => estimatedSize, + getItemKey, + getScrollElement: () => + scrollElement || containerRef.current?.closest(".ms-container") || null, + scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0 + }); + + useEffect(() => { + if (!virtualizer.scrollElement || !onEndReached) return; + function onScroll() { + if (!virtualizer.scrollElement) return; + const { clientHeight, scrollHeight, scrollTop } = + virtualizer.scrollElement; + const endThreshold = scrollHeight - clientHeight - 50; + if (scrollTop > endThreshold) { + onEndReached?.(); + } + } + (virtualizer.scrollElement as HTMLElement)?.addEventListener( + "scroll", + onScroll + ); + return () => { + (virtualizer.scrollElement as HTMLElement)?.removeEventListener( + "scroll", + onScroll + ); + }; + }, [virtualizer.scrollElement, onEndReached]); + + if (virtualizerRef) virtualizerRef.current = virtualizer; + + const virtualItems = virtualizer.getVirtualItems(); + + return ( + + + {virtualItems.map((row) => ( + + + + ))} + + + ); +} diff --git a/apps/web/src/components/virtualized-list/index.tsx b/apps/web/src/components/virtualized-list/index.tsx new file mode 100644 index 000000000..5999e406b --- /dev/null +++ b/apps/web/src/components/virtualized-list/index.tsx @@ -0,0 +1,96 @@ +/* +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 { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; +import { Box, BoxProps } from "@theme-ui/components"; +import React, { useRef } from "react"; + +type VirtualizedListProps = { + virtualizerRef?: React.MutableRefObject< + Virtualizer | undefined + >; + mode?: "fixed" | "dynamic"; + items: T[]; + estimatedSize: number; + getItemKey: (index: number) => string; + scrollElement?: Element | null; + itemWrapperProps?: (item: T, index: number) => BoxProps; + renderItem: (props: { item: T; index: number }) => JSX.Element | null; + scrollMargin?: number; +} & BoxProps; +export function VirtualizedList(props: VirtualizedListProps) { + const { + items, + getItemKey, + scrollElement, + scrollMargin, + renderItem: Item, + estimatedSize, + mode, + virtualizerRef, + itemWrapperProps, + ...containerProps + } = props; + const containerRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + estimateSize: () => estimatedSize, + getItemKey, + getScrollElement: () => + scrollElement || containerRef.current?.closest(".ms-container") || null, + scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0 + }); + + if (virtualizerRef) virtualizerRef.current = virtualizer; + + const virtualItems = virtualizer.getVirtualItems(); + return ( + + + {virtualItems.map((row) => ( + + + + ))} + + + ); +} diff --git a/apps/web/src/components/virtualized-table/index.tsx b/apps/web/src/components/virtualized-table/index.tsx new file mode 100644 index 000000000..303dd89a1 --- /dev/null +++ b/apps/web/src/components/virtualized-table/index.tsx @@ -0,0 +1,106 @@ +/* +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 { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; +import { Box } from "@theme-ui/components"; +import React, { useRef } from "react"; + +export type VirtualizedTableRowProps = { + item: T; + index: number; + style: React.CSSProperties; + rowRef?: React.Ref; + context?: C; +}; + +type VirtualizedTableProps = { + virtualizerRef?: React.MutableRefObject< + Virtualizer | undefined + >; + mode?: "fixed" | "dynamic"; + items: T[]; + estimatedSize: number; + getItemKey: (index: number) => string; + scrollElement?: Element | null; + context?: C; + renderRow: (props: VirtualizedTableRowProps) => JSX.Element | null; + scrollMargin?: number; + header: React.ReactNode; + style?: React.CSSProperties; +}; +export function VirtualizedTable(props: VirtualizedTableProps) { + const { + items, + getItemKey, + scrollElement, + scrollMargin, + renderRow: Row, + estimatedSize, + mode, + virtualizerRef, + header, + style, + context + } = props; + const containerRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + estimateSize: () => estimatedSize, + getItemKey, + getScrollElement: () => + scrollElement || containerRef.current?.closest(".ms-container") || null, + scrollMargin: scrollMargin || containerRef.current?.offsetTop || 0 + }); + + if (virtualizerRef) virtualizerRef.current = virtualizer; + + const virtualItems = virtualizer.getVirtualItems(); + return ( + + + {header} + + {virtualItems.map((row, index) => ( + + ))} + +
+
+ ); +} diff --git a/apps/web/src/dialogs/add-reminder-dialog.tsx b/apps/web/src/dialogs/add-reminder-dialog.tsx index ce3793599..fc628f966 100644 --- a/apps/web/src/dialogs/add-reminder-dialog.tsx +++ b/apps/web/src/dialogs/add-reminder-dialog.tsx @@ -120,26 +120,27 @@ export default function AddReminderDialog(props: AddReminderDialogProps) { useEffect(() => { if (!reminderId) return; - const reminder = db.reminders.reminder(reminderId); - if (!reminder) return; + db.reminders.reminder(reminderId).then((reminder) => { + if (!reminder) return; - setSelectedDays(reminder.selectedDays || []); - setRecurringMode(reminder.recurringMode || RecurringModes.DAY); - setMode(reminder.mode || Modes.ONCE); - setPriority(reminder.priority || Priorities.VIBRATE); - setDate(dayjs(reminder.date).format("YYYY-MM-DD")); - setTime(dayjs(reminder.date).format("HH:mm")); - setTitle(reminder.title); - setDescription(reminder.description); + setSelectedDays(reminder.selectedDays || []); + setRecurringMode(reminder.recurringMode || RecurringModes.DAY); + setMode(reminder.mode || Modes.ONCE); + setPriority(reminder.priority || Priorities.VIBRATE); + setDate(dayjs(reminder.date).format("YYYY-MM-DD")); + setTime(dayjs(reminder.date).format("HH:mm")); + setTitle(reminder.title); + setDescription(reminder.description); + }); }, [reminderId]); useEffect(() => { if (!noteId) return; - const note = db.notes.note(noteId); - if (!note) return; - - setTitle(note.title); - setDescription(note.headline); + db.notes.note(noteId).then((note) => { + if (!note) return; + setTitle(note.title); + setDescription(note.headline); + }); }, [noteId]); const repeatsDaily = diff --git a/apps/web/src/dialogs/attachments-dialog.tsx b/apps/web/src/dialogs/attachments-dialog.tsx index 81a7e319b..fa44c7999 100644 --- a/apps/web/src/dialogs/attachments-dialog.tsx +++ b/apps/web/src/dialogs/attachments-dialog.tsx @@ -31,7 +31,6 @@ import { import { store, useStore } from "../stores/attachment-store"; import { formatBytes } from "@notesnook/common"; import Dialog from "../components/dialog"; -import { ItemProps, TableVirtuoso } from "react-virtuoso"; import { ChevronDown, ChevronUp, @@ -51,15 +50,20 @@ import NavigationItem from "../components/navigation-menu/navigation-item"; import { pluralize } from "@notesnook/common"; import { db } from "../common/db"; import { Perform } from "../common/dialog-controller"; -import { CustomScrollbarsVirtualList } from "../components/list-container"; import { Attachment } from "../components/attachment"; import { ScopedThemeProvider } from "../components/theme-provider"; import { Attachment as AttachmentType, VirtualizedGrouping } from "@notesnook/core"; -import usePromise from "../hooks/use-promise"; import { Multiselect } from "../common/multi-select"; +import { ResolvedItem } from "../components/list-container/resolved-item"; +import { + VirtualizedTable, + VirtualizedTableRowProps +} from "../components/virtualized-table"; +import { FlexScrollContainer } from "../components/scroll-container"; +import usePromise from "../hooks/use-promise"; type ToolbarAction = { title: string; @@ -141,12 +145,6 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) { })(); }, [allAttachments]); - const totalSize = 0; - // useMemo( - // () => getTotalSize(allAttachments), - // [allAttachments] - // ); - return ( download(allAttachments?.ungrouped || [])} - filter={(query) => { - // setAttachments( - // db.lookup?.attachments(db.attachments.all || [], query) || [] - // ); + filter={async (query) => { + setAttachments(await db.lookup.attachments(query)); }} counts={counts} onRouteChange={async (route) => { @@ -185,41 +181,35 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) { sx={{ bg: "background", flexDirection: "column", - px: 4, + pl: 4, pt: 2, - overflowY: "hidden", - overflow: "hidden", - table: { width: "100%", tableLayout: "fixed" }, - "tbody::before": { - content: `''`, - display: "block", - height: 5 - } + overflow: "hidden" }} > - - - {TOOLBAR_ACTIONS.map((tool) => ( - - ))} - - {/* + ))} + + {/* */} - - {attachments && ( - attachments.getKey(index)} - data={attachments.ungrouped} - context={{ - isSelected: (id: string) => selected.indexOf(id) > -1, - select: (id: string) => { - setSelected((s) => { - const copy = s.slice(); - const index = copy.indexOf(id); - if (index > -1) copy.splice(index, 1); - else copy.push(id); - return copy; - }); - }, - attachments - }} - fixedItemHeight={30} - defaultItemHeight={30} - fixedHeaderContent={() => ( - - + {attachments && ( + - - - {COLUMNS.map((column) => - !column.title ? ( - - ) : ( - { - setSortBy((sortBy) => ({ - direction: - sortBy.id === column.id && - sortBy.direction === "asc" - ? "desc" - : "asc", - id: column.id - })); - }} - > - + + + {COLUMNS.map((column) => + !column.title ? ( + + ) : ( + { + setSortBy((sortBy) => ({ + direction: + sortBy.id === column.id && + sortBy.direction === "asc" + ? "desc" + : "asc", + id: column.id + })); }} > - - {column.title} - - {sortBy.id === column.id ? ( - sortBy.direction === "asc" ? ( - - ) : ( - - ) - ) : null} - - - ) - )} - - )} - itemContent={() => <>} - /> - )} + + {column.title} + + {sortBy.id === column.id ? ( + sortBy.direction === "asc" ? ( + + ) : ( + + ) + ) : null} + + + ) + )} + + } + mode="fixed" + estimatedSize={30} + getItemKey={(index) => attachments.getKey(index)} + items={attachments.ungrouped} + context={{ + isSelected: (id: string) => selected.indexOf(id) > -1, + select: (id: string) => { + setSelected((s) => { + const copy = s.slice(); + const index = copy.indexOf(id); + if (index > -1) copy.splice(index, 1); + else copy.push(id); + return copy; + }); + }, + attachments + }} + renderRow={AttachmentRow} + /> + )} + @@ -348,6 +337,34 @@ function AttachmentsDialog({ onClose }: AttachmentsDialogProps) { export default AttachmentsDialog; +function AttachmentRow( + props: VirtualizedTableRowProps< + string, + { + isSelected: (id: string) => boolean; + select: (id: string) => void; + attachments: VirtualizedGrouping; + } + > +) { + if (!props.context) return null; + return ( + + {({ item }) => + item.type === "attachment" ? ( + props.context?.select(props.item)} + /> + ) : null + } + + ); +} + type Route = "all" | "images" | "documents" | "videos" | "uploads" | "orphaned"; const routes: { id: Route; icon: Icon; title: string }[] = [ @@ -388,14 +405,14 @@ type SidebarProps = { onRouteChange: (route: Route) => void; filter: (query: string) => void; counts: Record; - totalSize: number; }; const Sidebar = memo( function Sidebar(props: SidebarProps) { - const { onRouteChange, filter, counts, totalSize, onDownloadAll } = props; + const { onRouteChange, filter, counts, onDownloadAll } = props; const [route, setRoute] = useState("all"); const downloadStatus = useStore((store) => store.status); const cancelDownload = useStore((store) => store.cancel); + const result = usePromise(() => db.attachments.totalSize()); return ( @@ -410,6 +427,8 @@ const Sidebar = memo( > { @@ -435,7 +454,11 @@ const Sidebar = memo( {pluralize(counts.all, "file")} - {formatBytes(totalSize)} + {result.status === "fulfilled" && ( + + {formatBytes(result.value || 0)} + + )}